From 44ad2eabf387a88bdac70da09ed03e492df25ba7 Mon Sep 17 00:00:00 2001 From: Zareian Date: Tue, 13 Oct 2026 14:44:50 +0330 Subject: [PATCH] Payment events: one central payment_result row for every path (callback, reconcile, Bazaar, renewal, 100% code), discount_applied, no duplicate order_created; Bazaar token never stored Co-Authored-By: Claude Opus 5.5 --- discount/views.py | 3 +++ payment/services.py | 13 ++++++++++--- payment/tests_events.py | 37 +++++++++++++++++++++++++++++++++++++ payment/views.py | 2 -- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/discount/views.py b/discount/views.py index 6e6d83a..fb445c4 100644 --- a/discount/views.py +++ b/discount/views.py @@ -136,6 +136,9 @@ class RedeemDiscountView(APIView): # مبلغ سفارش واقعاً کم می‌شود تا مرحله‌ی پرداخت مبلغ تخفیف‌خورده را ببیند. order_locked.amount = max(order_locked.amount - discount_amount, 0) order_locked.save(update_fields=['amount']) + from payment import events as payment_events + payment_events.record('discount_applied', order=order_locked, code_id=discount.id, + discount_amount=discount_amount, amount=order_locked.amount) # سفارشِ صفرتومانی را هیچ درگاهی نمی‌پذیرد؛ کدِ ۱۰۰٪ یعنی خرید # همین‌جا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت). diff --git a/payment/services.py b/payment/services.py index 49e73e2..006417d 100644 --- a/payment/services.py +++ b/payment/services.py @@ -18,7 +18,6 @@ from rest_framework.exceptions import APIException from statuslog.signals import status_changed from . import events - from .models import Order, Transaction from .providers import PROVIDERS, provider_for @@ -111,6 +110,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT', from subscription.models import Plan plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None + created = False try: with db_transaction.atomic(): order = Order.objects.create( @@ -127,6 +127,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT', bazaar_product_sku=bazaar_product_sku, idempotency_key=idempotency_key, ) + created = True except IntegrityError: # درخواست هم‌زمان دیگری با همین کلید زودتر ساخت. order = Order.objects.filter(idempotency_key=idempotency_key).first() @@ -137,8 +138,9 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT', subscription_id=subscription_id, plan_id=plan_id, provider=provider, ) - events.record('order_created', order=order, plan_id=plan_id, amount=amount, currency=currency, - duration_days=order.duration_days, sku=bazaar_product_sku) + if created: + events.record('order_created', order=order, plan_id=plan_id, amount=amount, currency=currency, + duration_days=order.duration_days, sku=bazaar_product_sku) # بازار paymentUrl ندارد (اپ با Poolakey می‌خرد)؛ زرین‌پال آدرسِ PaymentRedirectView را دارد (providers.py). payment_url = provider_for(order.provider).payment_url(order) return order, payment_url @@ -223,6 +225,11 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st order.id, order.user_id, gateway, event, old_status, new_status, gateway_ref_id or '-', is_duplicate, ) + # همان نتیجه در دفترِ خرید (به ۲۴۷ هم می‌رود). همه‌ی مسیرها از این‌جا می‌گذرند: کال‌بک، بازسنجی، + # بازار، تمدیدِ بازار، کدِ ۱۰۰٪. رفرنسِ بازار خودِ توکنِ خرید است و در دفتر نمی‌رود. + events.record('payment_result', order=order, via=event, gateway=gateway, + old_status=old_status, new_status=new_status, ignored=is_duplicate, + ref_id=gateway_ref_id if gateway == 'zarinpal' else '') newly_paid = new_status == 'paid' and not already_paid and not is_duplicate if newly_paid: if order.subscription_id: diff --git a/payment/tests_events.py b/payment/tests_events.py index ecd59d9..1ecaadf 100644 --- a/payment/tests_events.py +++ b/payment/tests_events.py @@ -25,3 +25,40 @@ class PaymentEventTests(TestCase): with self.assertLogs('payment.events', level='ERROR'): events.record('x', user_id=1) self.assertEqual(Order.objects.count(), 0) + + +class PaymentResultEventTests(TestCase): + def test_result_is_recorded_without_bazaar_token(self): + from payment.services import apply_order_payment_result + order, _ = create_order(user_id=7, amount=1000, provider='bazaar', bazaar_product_sku='10342') + apply_order_payment_result(order=order, gateway='bazaar', gateway_ref_id='SECRET-TOKEN', + new_status='paid', event='bazaar_verify') + row = PaymentEvent.objects.get(order_id=order.id, event='payment_result') + self.assertEqual(row.detail['new_status'], 'paid') + self.assertFalse(row.detail['ignored']) + self.assertNotIn('SECRET-TOKEN', str(row.detail)) + + def test_duplicate_result_is_recorded_as_ignored(self): + from payment.services import apply_order_payment_result + order, _ = create_order(user_id=7, amount=1000) + for _ in range(2): + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', + new_status='paid', event='zarinpal_callback') + flags = list(PaymentEvent.objects.filter(order_id=order.id, event='payment_result') + .order_by('id').values_list('detail__ignored', flat=True)) + self.assertEqual(flags, [False, True]) + + def test_zarinpal_callback_records_arrival_and_result(self): + from payment.zarinpal import ZarinpalVerifyResult + order, _ = create_order(user_id=7, amount=1000) + Order.objects.filter(pk=order.pk).update(gateway_authority='A123') + with mock.patch('payment.zarinpal.verify_payment', + return_value=ZarinpalVerifyResult(is_valid=True, ref_id='999')): + self.client.get('/api/payment/zarinpal/callback', {'Authority': 'A123', 'Status': 'OK'}) + got = list(PaymentEvent.objects.filter(order_id=order.id).order_by('id').values_list('event', flat=True)) + self.assertEqual(got, ['order_created', 'callback_received', 'payment_result']) + + def test_unknown_authority_is_recorded_without_order(self): + self.client.get('/api/payment/zarinpal/callback', {'Authority': 'NOPE', 'Status': 'OK'}) + row = PaymentEvent.objects.get(event='callback_unknown_authority') + self.assertIsNone(row.order_id) diff --git a/payment/views.py b/payment/views.py index ae904ba..8eebf68 100644 --- a/payment/views.py +++ b/payment/views.py @@ -177,7 +177,6 @@ class ZarinpalCallbackView(APIView): ) new_status = 'paid' if result.is_valid else 'failed' gateway_ref_id = result.ref_id - events.record('verify_result', order=order, valid=bool(result.is_valid), ref_id=gateway_ref_id) with transaction.atomic(): locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk) @@ -316,7 +315,6 @@ class BazaarVerifyPurchaseView(APIView): # apply_order_payment_result سفارش را از نو می‌خواند و همان را عوض می‌کند؛ این نمونه هنوز # وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالی‌که سفارش «paid» شده بود. order.refresh_from_db(fields=['status']) - events.record('bazaar_verify', order=order, valid=bool(result.is_valid), status=order.status) data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid} return Response(BazaarVerifyResponseSerializer(data).data)