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 <noreply@anthropic.com>
This commit is contained in:
@@ -136,6 +136,9 @@ class RedeemDiscountView(APIView):
|
|||||||
# مبلغ سفارش واقعاً کم میشود تا مرحلهی پرداخت مبلغ تخفیفخورده را ببیند.
|
# مبلغ سفارش واقعاً کم میشود تا مرحلهی پرداخت مبلغ تخفیفخورده را ببیند.
|
||||||
order_locked.amount = max(order_locked.amount - discount_amount, 0)
|
order_locked.amount = max(order_locked.amount - discount_amount, 0)
|
||||||
order_locked.save(update_fields=['amount'])
|
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)
|
||||||
|
|
||||||
# سفارشِ صفرتومانی را هیچ درگاهی نمیپذیرد؛ کدِ ۱۰۰٪ یعنی خرید
|
# سفارشِ صفرتومانی را هیچ درگاهی نمیپذیرد؛ کدِ ۱۰۰٪ یعنی خرید
|
||||||
# همینجا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت).
|
# همینجا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت).
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from rest_framework.exceptions import APIException
|
|||||||
from statuslog.signals import status_changed
|
from statuslog.signals import status_changed
|
||||||
|
|
||||||
from . import events
|
from . import events
|
||||||
|
|
||||||
from .models import Order, Transaction
|
from .models import Order, Transaction
|
||||||
from .providers import PROVIDERS, provider_for
|
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
|
from subscription.models import Plan
|
||||||
|
|
||||||
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
|
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
|
||||||
|
created = False
|
||||||
try:
|
try:
|
||||||
with db_transaction.atomic():
|
with db_transaction.atomic():
|
||||||
order = Order.objects.create(
|
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,
|
bazaar_product_sku=bazaar_product_sku,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
|
created = True
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# درخواست همزمان دیگری با همین کلید زودتر ساخت.
|
# درخواست همزمان دیگری با همین کلید زودتر ساخت.
|
||||||
order = Order.objects.filter(idempotency_key=idempotency_key).first()
|
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,
|
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
events.record('order_created', order=order, plan_id=plan_id, amount=amount, currency=currency,
|
if created:
|
||||||
duration_days=order.duration_days, sku=bazaar_product_sku)
|
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).
|
# بازار paymentUrl ندارد (اپ با Poolakey میخرد)؛ زرینپال آدرسِ PaymentRedirectView را دارد (providers.py).
|
||||||
payment_url = provider_for(order.provider).payment_url(order)
|
payment_url = provider_for(order.provider).payment_url(order)
|
||||||
return order, payment_url
|
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,
|
order.id, order.user_id, gateway, event, old_status, new_status,
|
||||||
gateway_ref_id or '-', is_duplicate,
|
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
|
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
|
||||||
if newly_paid:
|
if newly_paid:
|
||||||
if order.subscription_id:
|
if order.subscription_id:
|
||||||
|
|||||||
@@ -25,3 +25,40 @@ class PaymentEventTests(TestCase):
|
|||||||
with self.assertLogs('payment.events', level='ERROR'):
|
with self.assertLogs('payment.events', level='ERROR'):
|
||||||
events.record('x', user_id=1)
|
events.record('x', user_id=1)
|
||||||
self.assertEqual(Order.objects.count(), 0)
|
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)
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ class ZarinpalCallbackView(APIView):
|
|||||||
)
|
)
|
||||||
new_status = 'paid' if result.is_valid else 'failed'
|
new_status = 'paid' if result.is_valid else 'failed'
|
||||||
gateway_ref_id = result.ref_id
|
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():
|
with transaction.atomic():
|
||||||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
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 سفارش را از نو میخواند و همان را عوض میکند؛ این نمونه هنوز
|
# apply_order_payment_result سفارش را از نو میخواند و همان را عوض میکند؛ این نمونه هنوز
|
||||||
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالیکه سفارش «paid» شده بود.
|
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالیکه سفارش «paid» شده بود.
|
||||||
order.refresh_from_db(fields=['status'])
|
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}
|
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
||||||
return Response(BazaarVerifyResponseSerializer(data).data)
|
return Response(BazaarVerifyResponseSerializer(data).data)
|
||||||
|
|||||||
Reference in New Issue
Block a user