diff --git a/docs/operations.md b/docs/operations.md index 92bb29f..250c954 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -177,6 +177,26 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U مرحلهی ۳ را فقط یک پرداختِ واقعی ثابت میکند؛ تا آن انجام نشده، پرداخت را «کار میکند» ننویسید. +## لغو و بازپرداختِ سفارش (از پنلِ ادمین) + +از ۲۰۲۶-۱۰-۰۲. همه از `/admin/payment/order/` انجام میشود و SSH لازم نیست: + +1. سفارش را با شمارهاش یا شناسهی کاربر (`user_id`) پیدا کنید. شمارهی فاکتورِ `TD-000123` یعنی سفارشِ ۱۲۳. +2. تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید: + - **«لغوِ سفارشِ در انتظار»**: برای سفارشی که هنوز پرداخت نشده. پولی جابهجا نشده است. اگر کاربر + همان لحظه در درگاه پرداخت کرده باشد، تأییدِ زرینپال سفارش را دوباره «پرداختشده» میکند و او چیزش را میگیرد. + - **«بازپرداخت»**: برای سفارشِ پرداختشده. اول یک صفحهی تأیید میآید. بعد از تأیید، سفارش + «بازپرداختشده» میشود و هر چه داده بود تا جایی که هنوز مانده پس گرفته میشود: سوالهای + خرجنشدهی همان بسته (موجودی منفی نمیشود)، روزهای قلبِ نامحدودِ همان بسته، یا روزهای همان اشتراک. + پورسانتِ معرف هم برمیگردد. پیامِ بالای صفحه میگوید دقیقاً چه چیزی پس گرفته شد. +3. **خودِ پول را این دکمه برنمیگرداند.** پول را در پنلِ زرینپال برگردانید. خریدِ کافهبازار هم از + سمتِ خودِ بازار برگردانده میشود. + +وضعیتِ سفارش در فرمِ ادمین فقطخواندنی است. اگر وضعیت را با دست از «پرداختشده» به «ناموفق» عوض +میکردید، اعتبار و اشتراکی که سفارش داده بود سرِ جایش میماند. کاربر بازپرداخت را در صفحهی +«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`) +است و تستهایش در `payment/tests_refund.py`. + ## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک) تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچکس نمیتواند با شماره diff --git a/payment/admin.py b/payment/admin.py index ea1ab00..350c35d 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -12,9 +12,59 @@ class TransactionInline(admin.TabularInline): @admin.register(Order) class OrderAdmin(admin.ModelAdmin): - list_display = ('id', 'user_id', 'subscription_id', 'amount', 'currency', 'status', 'provider', 'created_at') + """ + لغو و بازپرداخت فقط با دو action (از ۲۰۲۶-۱۰-۰۲)، نه با عوض کردنِ دستیِ وضعیت: «paid» را با دست + «failed» کردن اعتبار یا اشتراکی را که سفارش داده بود پس نمیگرفت. برای همین وضعیت اینجا فقطخواندنی است. + منطق در payment.services (cancel_pending_order، refund_order) است و تستهایش در payment/tests_refund.py. + """ + + list_display = ('id', 'user_id', 'plan_title', 'amount', 'currency', 'status', 'provider', 'created_at') list_filter = ('status', 'provider', 'currency') + search_fields = ('=id', '=user_id') + readonly_fields = ('status',) inlines = [TransactionInline] + actions = ['cancel_pending', 'refund'] + + @admin.action(description='لغوِ سفارشِ در انتظار') + def cancel_pending(self, request, queryset): + from .services import OrderStateError, cancel_pending_order + + done = 0 + for order in queryset: + try: + cancel_pending_order(order, staff=request.user.get_username()) + done += 1 + except OrderStateError as exc: + self.message_user(request, str(exc), messages.WARNING) + if done: + self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS) + + @admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداختشده)') + def refund(self, request, queryset): + from django.template.response import TemplateResponse + + from .services import OrderStateError, refund_order + + if request.POST.get('confirm') != 'yes': + return TemplateResponse(request, 'admin/payment/order/refund_confirm.html', { + **self.admin_site.each_context(request), 'orders': list(queryset), 'opts': self.model._meta, + 'title': 'تأییدِ بازپرداخت', + }) + for order in queryset: + try: + r = refund_order(order, staff=request.user.get_username()) + except OrderStateError as exc: + self.message_user(request, str(exc), messages.WARNING) + continue + if r['subscription_days']: + what = f'{r["subscription_days"]} روز از اشتراک کم شد' + else: + what = f'{r["credits_taken"]} از {r["credits_granted"]} سوالِ بسته پس گرفته شد' + if r['credits_taken'] < r['credits_granted']: + what += f' (بقیه خرج شده بود)' + if r['hearts_days']: + what += f'، {r["hearts_days"]} روز قلبِ نامحدود کم شد' + self.message_user(request, f'سفارشِ {order.id}: {what}. پول را از پنلِ درگاه برگردانید.', messages.SUCCESS) @admin.register(Transaction) diff --git a/payment/migrations/0010_order_cancel_refund.py b/payment/migrations/0010_order_cancel_refund.py new file mode 100644 index 0000000..1ec0f00 --- /dev/null +++ b/payment/migrations/0010_order_cancel_refund.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-02 19:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0009_order_invoice_snapshot'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='status', + field=models.CharField(choices=[('pending', 'در انتظار'), ('paid', 'پرداخت\u200cشده'), ('failed', 'ناموفق'), ('canceled', 'لغوشده'), ('refunded', 'بازپرداخت\u200cشده')], default='pending', max_length=10), + ), + ] diff --git a/payment/models.py b/payment/models.py index cd97ee1..fa21a0e 100644 --- a/payment/models.py +++ b/payment/models.py @@ -6,7 +6,11 @@ class Order(models.Model): تنها اپی که واقعاً پول جابهجا میکنه. عمداً از Subscription جدا شده چون منطق و ریسکش فرق داره (درگاه، retry، idempotency). """ - STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق')] + # «لغوشده» و «بازپرداختشده» فقط از پنلِ ادمین (actionهای OrderAdmin) گذاشته میشوند، از ۲۰۲۶-۱۰-۰۲. + STATUS_CHOICES = [ + ('pending', 'در انتظار'), ('paid', 'پرداختشده'), ('failed', 'ناموفق'), + ('canceled', 'لغوشده'), ('refunded', 'بازپرداختشده'), + ] # 'gateway' و 'bazaar' منطق و صحتسنجیِ کاملاً متفاوتی دارن (webhook در # برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست diff --git a/payment/serializers.py b/payment/serializers.py index 4fd618d..e3b6378 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -64,7 +64,7 @@ class OrderDetailResponseSerializer(serializers.Serializer): class InvoiceSerializer(serializers.Serializer): orderId = serializers.CharField() invoiceNumber = serializers.CharField(help_text='شمارهی فاکتور: «TD-» و شناسهی سفارش، شش رقمی.') - status = serializers.ChoiceField(choices=['pending', 'paid', 'failed']) + status = serializers.ChoiceField(choices=['pending', 'paid', 'failed', 'canceled', 'refunded']) provider = serializers.CharField(help_text='gateway (زرینپال) یا bazaar') planTitle = serializers.CharField(allow_blank=True) listPrice = serializers.IntegerField(allow_null=True, help_text='قیمتِ پایه پیش از تخفیف؛ null برای سفارشهای پیش از ۱۴۰۵-۰۷-۰۹.') diff --git a/payment/services.py b/payment/services.py index fba8b58..fdf2441 100644 --- a/payment/services.py +++ b/payment/services.py @@ -234,3 +234,87 @@ def reconcile_user_orders(user_id: int) -> None: user_id=user_id, status='pending', provider='gateway', created_at__gte=since, ).exclude(gateway_authority='')[:3]: reconcile_pending_order(order) + + +class OrderStateError(Exception): + """سفارش در وضعیتی نیست که این کار رویش معنا داشته باشد (پیامش به ادمین نشان داده میشود).""" + + +def cancel_pending_order(order: Order, *, staff: str = '') -> None: + """ + سفارشِ «در انتظار» را لغو میکند (پنلِ ادمین). پولی جابهجا نشده و چیزی داده نشده. + + اگر کاربر در همان لحظه واقعاً در درگاه پرداخت کرده باشد، تأییدِ بعدیِ زرینپال سفارش را باز + «paid» میکند (apply_order_payment_result فقط سفارشِ از قبل paid را نادیده میگیرد)؛ این عمدی + است: کسی که پول داده باید چیزش را بگیرد. + """ + with db_transaction.atomic(): + order = Order.objects.select_for_update().get(pk=order.pk) + if order.status != 'pending': + raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ در انتظار لغو میشود.') + order.status = 'canceled' + order.save(update_fields=['status']) + status_changed.send(sender='payment.Order', object_id=order.id, old_status='pending', + new_status='canceled', event=f'admin_cancel:{staff}'[:50], user_id=order.user_id) + + +def refund_order(order: Order, *, staff: str = '') -> dict: + """ + سفارشِ پرداختشده را «بازپرداختشده» میکند و هر چه داده بود تا جایی که هنوز مانده پس میگیرد. + + **پول را برنمیگرداند.** برگرداندنِ پول در پنلِ زرینپال (یا کافهبازار) انجام میشود؛ این تابع + فقط حسابوکتابِ خودِ اپ را با آن یکی میکند. چه چیزی پس گرفته میشود (تصمیمِ ۲۰۲۶-۱۰-۰۲): + - بسته: اعتبارِ همین سفارش، ولی فقط آنقدر که هنوز خرج نشده (موجودی منفی نمیشود)، و روزهای + قلبِ نامحدودِ همین بسته از پایانِ کارتِ قلب. + - اشتراک: روزهای همین پلن از پایانِ اشتراک؛ اگر چیزی نماند اشتراک «لغوشده» میشود. + - پورسانتِ معرف برای همین سفارش به «در انتظار» برمیگردد. + خروجی: خلاصه برای پیامِ ادمین. + """ + from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription + from subscription.services import credit_balance + + with db_transaction.atomic(): + order = Order.objects.select_for_update().get(pk=order.pk) + if order.status != 'paid': + raise OrderStateError(f'سفارشِ {order.id} «{order.get_status_display()}» است؛ فقط سفارشِ پرداختشده بازپرداخت میشود.') + now = timezone.now() + summary = {'credits_granted': 0, 'credits_taken': 0, 'hearts_days': 0, 'subscription_days': 0} + plan = Plan.objects.filter(pk=order.plan_id).first() if order.plan_id else None + + if order.subscription_id: + sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first() + days = plan.duration_days if plan else 30 + if sub is not None and sub.expires_at is not None: + sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now) + if sub.expires_at <= now: + sub.status = 'canceled' + sub.version += 1 # PurchaseView با version تشخیص میدهد کسی ردیف را همزمان عوض کرده + sub.save(update_fields=['expires_at', 'status', 'version']) + summary['subscription_days'] = days + else: + granted = QuestionCredit.objects.filter(order_id=order.id, reason='purchase').first() + if granted is not None: + take = min(granted.delta, max(credit_balance(order.user_id), 0)) + summary['credits_granted'] = granted.delta + summary['credits_taken'] = take + if take: + QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund', + order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}') + if plan is not None and plan.hearts_days: + hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() + if hp is not None: + hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now) + hp.save(update_fields=['expires_at']) + summary['hearts_days'] = plan.hearts_days + + from affiliate.models import Conversion + Conversion.objects.filter(order_id=order.id, status='paid').update( + status='pending', order_id=None, commission_amount=0) + + order.status = 'refunded' + order.save(update_fields=['status']) + Transaction.objects.create(order=order, gateway='admin', status='refunded', + gateway_ref_id=f'refund-{order.id}') + status_changed.send(sender='payment.Order', object_id=order.id, old_status='paid', + new_status='refunded', event=f'admin_refund:{staff}'[:50], user_id=order.user_id) + return summary diff --git a/payment/tests_refund.py b/payment/tests_refund.py new file mode 100644 index 0000000..dc1126c --- /dev/null +++ b/payment/tests_refund.py @@ -0,0 +1,177 @@ +""" +لغو و بازپرداختِ سفارش از پنلِ ادمین (۲۰۲۶-۱۰-۰۲). + +راههای شکستن که امتحان میشوند: بازپرداختِ دوبارهی یک سفارش، منفی شدنِ موجودیِ کسی که بخشی از +بسته را خرج کرده، بازپرداختِ سفارشی که پرداخت نشده، کلیکِ اشتباه در ادمین (بیصفحهی تأیید)، و +سفارشِ لغوشدهای که کاربرش واقعاً پول داده بود. خودِ برگرداندنِ پول در درگاه آزموده نمیشود چون این +کد آن را انجام نمیدهد. +""" +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from affiliate.models import AffiliateAccount, Conversion +from subscription.models import HeartsPass, Plan, QuestionCredit, Subscription +from subscription.services import credit_balance + +from .models import Order, Transaction +from .services import OrderStateError, apply_order_payment_result, cancel_pending_order, refund_order + + +class _Base(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09120007401', is_guest=False) + self.client_app = APIClient() + self.client_app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + self.pack = Plan.objects.get(kind='pack', question_credits=20) + + def tearDown(self): + cache.clear() + + def paid_pack(self, ref='R1'): + response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json') + order = Order.objects.get(pk=response.json()['orderId']) + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref, new_status='paid', event='t') + order.refresh_from_db() + return order + + def spend(self, n): + for i in range(n): + QuestionCredit.objects.create(user_id=self.user.id, delta=-1, reason='question', message_id=10_000 + i) + + +class RefundServiceTests(_Base): + def test_an_unused_pack_is_taken_back_whole_with_its_heart_days(self): + order = self.paid_pack() + summary = refund_order(order) + self.assertEqual((summary['credits_granted'], summary['credits_taken']), (20, 20)) + self.assertEqual(credit_balance(self.user.id), 0) + # ۷ روزِ همین بسته از کارتِ ۷روزه کم شد، پس قلبِ نامحدود همین الان تمام است. + self.assertLessEqual(HeartsPass.objects.get(user_id=self.user.id).expires_at, timezone.now() + timedelta(seconds=5)) + order.refresh_from_db() + self.assertEqual(order.status, 'refunded') + self.assertTrue(Transaction.objects.filter(order=order, status='refunded', gateway='admin').exists()) + + def test_what_was_already_spent_is_not_taken_twice_and_balance_never_goes_negative(self): + order = self.paid_pack() + self.spend(15) + summary = refund_order(order) + self.assertEqual(summary['credits_taken'], 5) + self.assertEqual(credit_balance(self.user.id), 0) + + def test_only_this_orders_credits_are_taken_not_a_second_packs(self): + first = self.paid_pack(ref='R1') + self.paid_pack(ref='R2') + refund_order(first) + self.assertEqual(credit_balance(self.user.id), 20) + + def test_a_second_refund_of_the_same_order_is_refused(self): + order = self.paid_pack() + refund_order(order) + with self.assertRaises(OrderStateError): + refund_order(order) + self.assertEqual(credit_balance(self.user.id), 0) + self.assertEqual(QuestionCredit.objects.filter(reason='refund').count(), 1) + + def test_an_unpaid_order_cannot_be_refunded(self): + order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='u1') + with self.assertRaises(OrderStateError): + refund_order(order) + + def test_a_subscription_refund_takes_its_days_off(self): + plan = Plan.objects.filter(kind='subscription').first() + sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active', + expires_at=timezone.now() + timedelta(days=plan.duration_days + 10)) + order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id, + plan_id=plan.id, status='paid', idempotency_key='s1') + refund_order(order) + sub.refresh_from_db() + self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 9, delta=1) + self.assertEqual(sub.status, 'active') + + def test_a_subscription_with_nothing_left_is_canceled(self): + plan = Plan.objects.filter(kind='subscription').first() + sub = Subscription.objects.create(user_id=self.user.id, plan=plan, status='active', + expires_at=timezone.now() + timedelta(days=3)) + order = Order.objects.create(user_id=self.user.id, amount=plan.price, subscription_id=sub.id, + plan_id=plan.id, status='paid', idempotency_key='s2') + refund_order(order) + sub.refresh_from_db() + self.assertEqual(sub.status, 'canceled') + + def test_the_referrers_commission_for_this_order_is_undone(self): + referrer = User.objects.create(phone='09120007402', is_guest=False) + account = AffiliateAccount.objects.create(user_id=referrer.id, referral_code='REF1', commission_rate=0.1) + Conversion.objects.create(affiliate_account=account, referred_user_id=self.user.id) + order = self.paid_pack() + self.assertEqual(Conversion.objects.get().status, 'paid') + refund_order(order) + conversion = Conversion.objects.get() + self.assertEqual((conversion.status, conversion.commission_amount, conversion.order_id), ('pending', 0, None)) + + def test_the_invoice_shows_the_refund(self): + refund_order(self.paid_pack()) + invoice = self.client_app.get('/api/payment/invoices').json()['invoices'][0] + self.assertEqual(invoice['status'], 'refunded') + + +class CancelServiceTests(_Base): + def test_a_pending_order_is_canceled_and_a_paid_one_is_not(self): + order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='c1') + cancel_pending_order(order) + order.refresh_from_db() + self.assertEqual(order.status, 'canceled') + with self.assertRaises(OrderStateError): + cancel_pending_order(self.paid_pack()) + + def test_if_the_user_did_pay_a_canceled_order_the_payment_still_counts(self): + response = self.client_app.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json') + order = Order.objects.get(pk=response.json()['orderId']) + cancel_pending_order(order) + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='LATE', new_status='paid', event='t') + self.assertEqual(credit_balance(self.user.id), 20) + + +class OrderAdminTests(_Base): + def setUp(self): + super().setUp() + staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only') + self.admin = self.client + self.admin.force_login(staff) + + def action(self, name, order, **extra): + return self.admin.post('/admin/payment/order/', {'action': name, '_selected_action': [order.pk], **extra}) + + def test_refund_asks_for_confirmation_first(self): + order = self.paid_pack() + page = self.action('refund', order) + self.assertEqual(page.status_code, 200) + self.assertContains(page, 'پول را برنمیگرداند') + order.refresh_from_db() + self.assertEqual(order.status, 'paid') + self.assertEqual(credit_balance(self.user.id), 20) + + done = self.action('refund', order, confirm='yes') + self.assertEqual(done.status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, 'refunded') + self.assertEqual(credit_balance(self.user.id), 0) + + def test_cancel_action(self): + order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='c2') + self.assertEqual(self.action('cancel_pending', order).status_code, 302) + order.refresh_from_db() + self.assertEqual(order.status, 'canceled') + + def test_status_cannot_be_edited_by_hand(self): + order = self.paid_pack() + page = self.admin.get(f'/admin/payment/order/{order.pk}/change/') + self.assertEqual(page.status_code, 200) + self.assertNotContains(page, 'name="status"') diff --git a/subscription/migrations/0011_questioncredit_refund_reason.py b/subscription/migrations/0011_questioncredit_refund_reason.py new file mode 100644 index 0000000..d69c4f7 --- /dev/null +++ b/subscription/migrations/0011_questioncredit_refund_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-02 19:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0010_seed_question_packs'), + ] + + operations = [ + migrations.AlterField( + model_name='questioncredit', + name='reason', + field=models.CharField(choices=[('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه\u200cی ادمین'), ('refund', 'بازپرداخت')], max_length=10), + ), + ] diff --git a/subscription/models.py b/subscription/models.py index 1505651..9752084 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -86,7 +86,8 @@ class QuestionCredit(models.Model): ردیفِ ۱− فقط وقتی پاک میشود که خودِ سوال پس گرفته شود: جوابِ ناموفق یا جوابی که از کش آمد و به API نرسید. اعتبار منقضی نمیشود (تصمیمِ صاحبِ پروژه). """ - REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیهی ادمین')] + # «refund»: ردیفِ منفیِ بازپرداختِ سفارش (payment.services.refund_order)؛ فقط اعتبارِ خرجنشده را پس میگیرد. + REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیهی ادمین'), ('refund', 'بازپرداخت')] user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User delta = models.IntegerField() diff --git a/templates/admin/payment/order/refund_confirm.html b/templates/admin/payment/order/refund_confirm.html new file mode 100644 index 0000000..06faa12 --- /dev/null +++ b/templates/admin/payment/order/refund_confirm.html @@ -0,0 +1,25 @@ +{% extends "admin/base_site.html" %} +{% comment %}صفحهی تأییدِ بازپرداخت (payment/admin.py → OrderAdmin.refund). بیاین صفحه یک کلیکِ اشتباه اعتبارِ کاربر را پس میگرفت.{% endcomment %} +{% block content %} +
این کار پول را برنمیگرداند. پول را جدا از پنلِ زرینپال (یا کافهبازار) برگردانید. اینجا فقط چیزی که + سفارش داده بود پس گرفته میشود: اعتبارِ خرجنشدهی بسته، روزهای قلبِ نامحدود، یا روزهای اشتراک.
+| سفارش | کاربر | پلن | مبلغ | درگاه | تاریخ |
|---|---|---|---|---|---|
| {{ o.id }} | {{ o.user_id }} | {{ o.plan_title|default:"—" }} | +{{ o.amount }} {{ o.currency }} | {{ o.get_provider_display }} | {{ o.created_at|date:"Y-m-d H:i" }} |