From 1daa7555430d6ed757759b200e0390e7ac838b81 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 30 Sep 2026 21:14:16 +0330 Subject: [PATCH] fix(payments): make checkout attempts idempotent and reconcilable --- apps/payments/api/schemas.py | 7 + apps/payments/api/views.py | 525 ++++++++++++------ .../0004_payment_idempotency_key_and_more.py | 25 + apps/payments/models.py | 11 +- apps/payments/services.py | 48 ++ apps/payments/tasks.py | 73 +++ .../tests/integration/test_payment_admin.py | 24 +- .../test_payment_reconciliation.py | 139 +++++ .../tests/integration/test_payments.py | 183 +++++- 9 files changed, 862 insertions(+), 173 deletions(-) create mode 100644 apps/payments/migrations/0004_payment_idempotency_key_and_more.py create mode 100644 apps/payments/services.py create mode 100644 apps/payments/tasks.py create mode 100644 apps/payments/tests/integration/test_payment_reconciliation.py diff --git a/apps/payments/api/schemas.py b/apps/payments/api/schemas.py index 57a01b2..34c83aa 100644 --- a/apps/payments/api/schemas.py +++ b/apps/payments/api/schemas.py @@ -2,6 +2,7 @@ from typing import Literal, Optional from ninja import Schema from datetime import datetime +from uuid import UUID class DiscountCodeSchema(Schema): @@ -47,16 +48,22 @@ class CreatePaymentIn(Schema): discount_code: str | None = None mobile: str | None = None email: str | None = None + idempotency_key: UUID | None = None class CreatePaymentOut(Schema): start_pay_url: str | None = None authority: str | None = None + payment_id: int | None = None + idempotency_key: UUID | None = None + status: Literal["initiated", "pending", "paid", "failed", "canceled"] base_amount: int discount_amount: int amount: int class PaymentDetailOut(Schema): + payment_id: int + event_id: int ref_id: str | None = None authority: str | None = None base_amount: int diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py index b96bbf5..b349620 100644 --- a/apps/payments/api/views.py +++ b/apps/payments/api/views.py @@ -1,7 +1,11 @@ +import logging +from uuid import uuid4 + from django.conf import settings from django.shortcuts import redirect, get_object_or_404 from django.utils import timezone from django.db.models import Count, Q +from django.db import transaction from typing import List from ninja import Query, Router @@ -11,6 +15,8 @@ import requests from apps.payments.models import Payment, DiscountCode from apps.events.models import Event, Registration from apps.notifications.services import notify_user +from apps.payments.services import PaymentVerificationUncertain, request_payment_verification +from apps.payments.tasks import reconcile_payment from apps.users.tasks import send_critical_sms from core.authentication import jwt_auth from core.api.schemas import ErrorSchema, MessageSchema @@ -28,6 +34,7 @@ from apps.payments.api.schemas import ( ) payments_router = Router(tags=["Payments"]) +logger = logging.getLogger(__name__) @payments_router.get("/me", response=List[UserPaymentSchema], auth=jwt_auth) @@ -236,80 +243,148 @@ def _notify_payment_status(payment: Payment, *, title: str, message: str, level: send_critical_sms.delay(payment.user.mobile, "payment_status", payment.event.title) +def _payment_status_name(payment: Payment) -> str: + return { + Payment.OrderStatusChoices.INIT: "initiated", + Payment.OrderStatusChoices.PENDING: "pending", + Payment.OrderStatusChoices.PAID: "paid", + Payment.OrderStatusChoices.FAILED: "failed", + Payment.OrderStatusChoices.CANCELED: "canceled", + }[payment.status] + + +def _payment_checkout_payload(payment: Payment): + return { + "start_pay_url": ( + f"{settings.ZARINPAL_STARTPAY}{payment.authority}" + if payment.authority and payment.status == Payment.OrderStatusChoices.PENDING + else None + ), + "authority": payment.authority, + "payment_id": payment.id, + "idempotency_key": payment.idempotency_key, + "status": _payment_status_name(payment), + "base_amount": payment.base_amount, + "discount_amount": payment.discount_amount or 0, + "amount": payment.amount, + } + + @payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth) def create_payment(request, payload: CreatePaymentIn): - event = get_object_or_404(Event, pk=payload.event_id) + idempotency_key = payload.idempotency_key or uuid4() - if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists(): - raise HttpError(400, "You have already registered in this event") - - registration = ( - Registration.objects.filter(event=event, user=request.auth, is_deleted=False) - .order_by("-registered_at") - .first() - ) - if not registration or registration.status == Registration.StatusChoices.CANCELLED: - registration = Registration.objects.create( - event=event, - user=request.auth, - status=Registration.StatusChoices.PENDING, - final_price=event.price, + # Serialize checkout creation for this event so concurrent taps cannot + # both create a new active attempt for the same user/event pair. + with transaction.atomic(): + event = get_object_or_404( + Event.objects.select_for_update(), + pk=payload.event_id, + is_deleted=False, ) - elif registration.final_price is None: - registration.final_price = event.price - registration.save(update_fields=["final_price"]) - discount_code = None - discount_amount = 0 - final_amount = event.price + keyed_payment = Payment.objects.filter( + user=request.auth, + event=event, + idempotency_key=idempotency_key, + ).select_related("event").first() + if keyed_payment: + return _payment_checkout_payload(keyed_payment) - if payload.discount_code: - discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first() + if Payment.objects.filter( + status=Payment.OrderStatusChoices.PAID, + user=request.auth, + event=event, + ).exists(): + raise HttpError(400, "You have already registered in this event") - if discount_code: - final_amount, discount_amount = discount_code.calculate_discount(event, request.auth) + active_payment = ( + Payment.objects.filter( + user=request.auth, + event=event, + status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING], + ) + .order_by("-created_at", "-id") + .first() + ) + if active_payment: + return _payment_checkout_payload(active_payment) - registration_updates = [] - if discount_code and registration.discount_code_id != discount_code.id: - registration.discount_code = discount_code - registration_updates.append("discount_code") - if registration.discount_amount != discount_amount: - registration.discount_amount = discount_amount - registration_updates.append("discount_amount") - if registration.final_price != final_amount: - registration.final_price = final_amount - registration_updates.append("final_price") + registration = ( + Registration.objects.select_for_update() + .filter(event=event, user=request.auth, is_deleted=False) + .order_by("-registered_at") + .first() + ) + if not registration or registration.status == Registration.StatusChoices.CANCELLED: + registration = Registration.objects.create( + event=event, + user=request.auth, + status=Registration.StatusChoices.PENDING, + final_price=event.price, + ) + elif registration.final_price is None: + registration.final_price = event.price + registration.save(update_fields=["final_price"]) + + discount_code = None + discount_amount = 0 + final_amount = event.price + + if payload.discount_code: + discount_code = DiscountCode.objects.filter( + code=payload.discount_code, + applicable_events=event, + is_active=True, + ).first() + if discount_code: + final_amount, discount_amount = discount_code.calculate_discount(event, request.auth) + + registration_updates = [] + if discount_code and registration.discount_code_id != discount_code.id: + registration.discount_code = discount_code + registration_updates.append("discount_code") + if registration.discount_amount != discount_amount: + registration.discount_amount = discount_amount + registration_updates.append("discount_amount") + if registration.final_price != final_amount: + registration.final_price = final_amount + registration_updates.append("final_price") + + if final_amount == 0: + if registration.status != Registration.StatusChoices.CONFIRMED: + registration.status = Registration.StatusChoices.CONFIRMED + registration_updates.append("status") + if registration_updates: + registration.save(update_fields=list(set(registration_updates))) + else: + registration.save(update_fields=["status"]) + + return { + "start_pay_url": None, + "authority": None, + "payment_id": None, + "idempotency_key": idempotency_key, + "status": "paid", + "base_amount": event.price, + "discount_amount": discount_amount, + "amount": 0, + } - if final_amount == 0: - if registration.status != Registration.StatusChoices.CONFIRMED: - registration.status = Registration.StatusChoices.CONFIRMED - registration_updates.append("status") if registration_updates: registration.save(update_fields=list(set(registration_updates))) - else: - registration.save(update_fields=["status"]) - return { - "start_pay_url": None, - "authority": None, - "base_amount": event.price, - "discount_amount": discount_amount if discount_amount else 0, - "amount": 0, - } - - if registration_updates: - registration.save(update_fields=list(set(registration_updates))) - - pay = Payment.objects.create( - user=request.auth, - event=event, - base_amount=event.price, - discount_code=discount_code, - discount_amount=discount_amount, - amount=final_amount, - status=Payment.OrderStatusChoices.INIT, - registration=registration, - ) + pay = Payment.objects.create( + user=request.auth, + event=event, + base_amount=event.price, + discount_code=discount_code, + discount_amount=discount_amount, + amount=final_amount, + status=Payment.OrderStatusChoices.INIT, + registration=registration, + idempotency_key=idempotency_key, + ) callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback") body = { @@ -336,28 +411,188 @@ def create_payment(request, payload: CreatePaymentIn): headers={"accept":"application/json","content-type":"application/json"}, timeout=15 ) + status_code = getattr(response, "status_code", 200) + if isinstance(status_code, int) and status_code >= 500: + raise RuntimeError(f"ZarinPal payment request returned HTTP {status_code}") jd = response.json() - except Exception as e: - pay.delete() - raise HttpError(502, f"Gateway request failed: {e}") + except Exception: + logger.exception( + "Gateway payment request outcome is unknown for payment %s; leaving it pending for review", + pay.id, + ) + pay.status = Payment.OrderStatusChoices.PENDING + pay.save(update_fields=["status"]) + return _payment_checkout_payload(pay) - code = (jd.get("data") or {}).get("code") - if code != 100: - pay.delete() - raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}") + data = jd.get("data") if isinstance(jd, dict) else None + code = data.get("code") if isinstance(data, dict) else None + try: + code = int(code) + except (TypeError, ValueError): + code = None + + authority = data.get("authority") if isinstance(data, dict) else None + if code != 100 or not authority: + if isinstance(jd, dict) and isinstance(jd.get("errors"), dict): + pay, _, _ = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED) + else: + logger.error( + "Gateway payment request outcome is unknown for payment %s; response had no usable authority", + pay.id, + ) + pay.status = Payment.OrderStatusChoices.PENDING + pay.save(update_fields=["status"]) + return _payment_checkout_payload(pay) - authority = jd["data"]["authority"] pay.authority = authority pay.status = Payment.OrderStatusChoices.PENDING pay.save(update_fields=["authority","status"]) - return { - "start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}", - "authority": authority, - "base_amount": event.price, - "discount_amount": discount_amount if discount_amount else 0, - "amount": final_amount, - } + return _payment_checkout_payload(pay) + +def _callback_redirect(payment: Payment, status: str): + url = ( + f"{settings.FRONTEND_CALLBACK_URL}?status={status}" + f"&event_id={payment.event_id}&payment_id={payment.id}" + ) + if status == "success" and payment.ref_id: + url += f"&ref_id={payment.ref_id}" + return redirect(url) + + +def _mark_payment_paid(payment_id: int, verification_data: dict | None = None): + """Persist a verified payment and its registration as one idempotent transition.""" + with transaction.atomic(): + payment = ( + Payment.objects.select_for_update() + .select_related("event", "user") + .get(pk=payment_id) + ) + was_already_verified = ( + payment.status == Payment.OrderStatusChoices.PAID + or payment.verified_at is not None + ) + + updates = [] + if payment.status != Payment.OrderStatusChoices.PAID: + payment.status = Payment.OrderStatusChoices.PAID + updates.append("status") + if verification_data is not None and payment.verified_at is None: + payment.ref_id = verification_data.get("ref_id") + payment.card_pan = verification_data.get("card_pan") + payment.card_hash = verification_data.get("card_hash") + payment.verified_at = timezone.now() + updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"]) + if updates: + payment.save(update_fields=updates) + + registration = None + if payment.registration_id: + registration = Registration.objects.select_for_update().filter( + pk=payment.registration_id + ).first() + if registration is None: + registration = Registration.objects.select_for_update().filter( + user_id=payment.user_id, + event_id=payment.event_id, + status=Registration.StatusChoices.PENDING, + ).first() + if registration and registration.status != Registration.StatusChoices.CONFIRMED: + registration.status = Registration.StatusChoices.CONFIRMED + registration_updates = ["status"] + if registration.final_price is None: + registration.final_price = payment.amount + registration_updates.append("final_price") + registration.save(update_fields=registration_updates) + + return payment, not was_already_verified + + +def _mark_payment_pending(payment_id: int): + """Do not let an inconclusive response turn into a terminal failure.""" + with transaction.atomic(): + payment = Payment.objects.select_for_update().get(pk=payment_id) + if payment.verified_at is not None or payment.status == Payment.OrderStatusChoices.PAID: + if payment.status != Payment.OrderStatusChoices.PAID: + payment.status = Payment.OrderStatusChoices.PAID + payment.save(update_fields=["status"]) + elif payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.CANCELED): + payment.status = Payment.OrderStatusChoices.PENDING + payment.save(update_fields=["status"]) + return payment + + +def _mark_payment_unsuccessful(payment_id: int, target_status: int): + """Apply a failure only while the payment has not been verified successfully.""" + with transaction.atomic(): + payment = ( + Payment.objects.select_for_update() + .select_related("event", "user") + .get(pk=payment_id) + ) + if ( + payment.status == Payment.OrderStatusChoices.PAID + or payment.verified_at is not None + ): + if payment.status != Payment.OrderStatusChoices.PAID: + payment.status = Payment.OrderStatusChoices.PAID + payment.save(update_fields=["status"]) + result = "success" + transitioned = False + elif payment.status in ( + Payment.OrderStatusChoices.FAILED, + Payment.OrderStatusChoices.CANCELED, + ): + result = ( + "canceled" + if payment.status == Payment.OrderStatusChoices.CANCELED + else "failed" + ) + transitioned = False + else: + payment.status = target_status + payment.save(update_fields=["status"]) + result = ( + "canceled" + if target_status == Payment.OrderStatusChoices.CANCELED + else "failed" + ) + transitioned = True + + registration = None + if payment.registration_id: + registration = Registration.objects.select_for_update().filter( + pk=payment.registration_id, + status=Registration.StatusChoices.PENDING, + ).first() + else: + registration = Registration.objects.select_for_update().filter( + user_id=payment.user_id, + event_id=payment.event_id, + status=Registration.StatusChoices.PENDING, + is_deleted=False, + ).order_by("-registered_at").first() + + if registration: + another_live_payment = Payment.objects.filter( + user_id=payment.user_id, + event_id=payment.event_id, + status__in=[ + Payment.OrderStatusChoices.INIT, + Payment.OrderStatusChoices.PENDING, + Payment.OrderStatusChoices.PAID, + ], + ).exclude(pk=payment.pk).exists() + if not another_live_payment: + # The payment failure notification already informs the user; + # update directly to avoid sending a duplicate cancellation notice. + Registration.objects.filter( + pk=registration.pk, + status=Registration.StatusChoices.PENDING, + ).update(status=Registration.StatusChoices.CANCELLED) + + return payment, result, transitioned + @payments_router.get("callback") def callback(request, Authority: str | None = None, Status: str | None = None): @@ -367,70 +602,28 @@ def callback(request, Authority: str | None = None, Status: str | None = None): pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first() if not pay: raise HttpError(404, "Payment not found") - previous_status = pay.status - - if Status != "OK": - pay.status = Payment.OrderStatusChoices.CANCELED - pay.save(update_fields=["status"]) - if previous_status != Payment.OrderStatusChoices.CANCELED: - _notify_payment_status( - pay, - title=f"پرداخت رویداد «{pay.event.title}» لغو شد", - message="پرداخت شما کامل نشد و ثبت‌نام نهایی انجام نگرفت.", - level="warning", - ) - return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") - - verify_body = { - "merchant_id": settings.ZARINPAL_MERCHANT_ID, - "amount": pay.amount, - "authority": Authority, - } + if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None: + pay, _ = _mark_payment_paid(pay.id) + return _callback_redirect(pay, "success") try: - vresp = requests.post( - settings.ZARINPAL_VERIFY_URL, - json=verify_body, - headers={"accept":"application/json","content-type":"application/json"}, - timeout=15 - ) - vjd = vresp.json() - except Exception: - pay.status = Payment.OrderStatusChoices.FAILED - pay.save(update_fields=["status"]) - if previous_status != Payment.OrderStatusChoices.FAILED: - _notify_payment_status( - pay, - title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود", - message="خطا در تأیید پرداخت رخ داد. در صورت نیاز دوباره تلاش کنید.", - level="error", - ) - return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") + vcode, data = request_payment_verification(pay) + except PaymentVerificationUncertain: + pay = _mark_payment_pending(pay.id) + if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority: + try: + reconcile_payment.apply_async(args=(pay.id,), countdown=60) + except Exception: + logger.exception( + "Unable to queue payment verification retry for payment %s; it remains pending for manual review", + pay.id, + ) + return _callback_redirect(pay, _payment_status_name(pay)) - vcode = (vjd.get("data") or {}).get("code") if vcode in (100, 101): - data = vjd.get("data") or {} - pay.status = Payment.OrderStatusChoices.PAID - pay.ref_id = data.get("ref_id") - pay.card_pan = data.get("card_pan") - pay.card_hash = data.get("card_hash") - pay.verified_at = timezone.now() - pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"]) + pay, newly_verified = _mark_payment_paid(pay.id, data) - registration = pay.registration or Registration.objects.filter( - user=pay.user, - event=pay.event, - status=Registration.StatusChoices.PENDING, - ).first() - if registration: - registration.status = Registration.StatusChoices.CONFIRMED - updates = ["status"] - if registration.final_price is None: - registration.final_price = pay.amount - updates.append("final_price") - registration.save(update_fields=updates) - - if previous_status != Payment.OrderStatusChoices.PAID: + if newly_verified: _notify_payment_status( pay, title=f"پرداخت رویداد «{pay.event.title}» تأیید شد", @@ -438,39 +631,55 @@ def callback(request, Authority: str | None = None, Status: str | None = None): level="success", ) - return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}") + return _callback_redirect(pay, "success") - pay.status = Payment.OrderStatusChoices.FAILED - pay.save(update_fields=["status"]) - if previous_status != Payment.OrderStatusChoices.FAILED: + pay, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED) + if transitioned: _notify_payment_status( pay, title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود", message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.", level="error", ) - return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") + return _callback_redirect(pay, result) + + +@payments_router.get("status/{int:payment_id}", response=PaymentDetailOut, auth=jwt_auth) +def payment_status(request, payment_id: int): + payment = get_object_or_404( + Payment.objects.select_related("event"), + pk=payment_id, + user=request.auth, + ) + return _payment_detail_payload(request, payment) + + +def _payment_detail_payload(request, payment: Payment): + event = payment.event + return { + "payment_id": payment.id, + "event_id": event.id, + "ref_id": payment.ref_id, + "authority": payment.authority, + "base_amount": payment.base_amount, + "discount_amount": payment.discount_amount or 0, + "amount": payment.amount, + "status": _payment_status_name(payment), + "verified_at": payment.verified_at.isoformat() if payment.verified_at else None, + "event": { + "id": event.id, + "title": event.title, + "slug": event.slug, + "image_url": request.build_absolute_uri(event.featured_image.url) if event.featured_image else None, + "success_markdown": event.registration_success_markdown, + }, + } + @payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut) def payment_by_ref(request, ref_id: str): pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id) - ev = pay.event - return { - "ref_id": pay.ref_id, - "authority": pay.authority, - "base_amount": pay.base_amount, - "discount_amount": pay.discount_amount or 0, - "amount": pay.amount, - "status": pay.get_status_display(), - "verified_at": pay.verified_at.isoformat() if pay.verified_at else None, - "event": { - "id": ev.id, - "title": ev.title, - "slug": ev.slug, - "image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None, - "success_markdown": ev.registration_success_markdown, - }, - } + return _payment_detail_payload(request, pay) @payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth) def check_coupon(request, payload: CouponVerifyIn): diff --git a/apps/payments/migrations/0004_payment_idempotency_key_and_more.py b/apps/payments/migrations/0004_payment_idempotency_key_and_more.py new file mode 100644 index 0000000..7152f56 --- /dev/null +++ b/apps/payments/migrations/0004_payment_idempotency_key_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.5 on 2026-09-30 17:06 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0015_registration_attendance_verification_source_and_more'), + ('payments', '0003_payment_registration'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='idempotency_key', + field=models.UUIDField(blank=True, editable=False, null=True), + ), + migrations.AddConstraint( + model_name='payment', + constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False)), fields=('user', 'event', 'idempotency_key'), name='payment_user_event_idempotency_key_uniq'), + ), + ] diff --git a/apps/payments/models.py b/apps/payments/models.py index 0edb723..9180b89 100644 --- a/apps/payments/models.py +++ b/apps/payments/models.py @@ -97,6 +97,7 @@ class Payment(BaseModel): related_name="payments", editable=False, ) + idempotency_key = models.UUIDField(null=True, blank=True, editable=False) authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False) status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False) ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False) @@ -104,6 +105,15 @@ class Payment(BaseModel): card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False) verified_at = models.DateTimeField(null=True, blank=True, editable=False) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "event", "idempotency_key"], + condition=Q(idempotency_key__isnull=False), + name="payment_user_event_idempotency_key_uniq", + ), + ] + def clean(self): if self.discount_amount and self.amount + self.discount_amount != self.base_amount: raise ValidationError({"amount": "amount + discount_amount must equal base_amount"}) @@ -119,4 +129,3 @@ class Payment(BaseModel): def __str__(self): return f"{self.user.email}:{self.event} - {self.get_status_display()}" - diff --git a/apps/payments/services.py b/apps/payments/services.py new file mode 100644 index 0000000..cad7dd4 --- /dev/null +++ b/apps/payments/services.py @@ -0,0 +1,48 @@ +"""Gateway operations whose responses must be interpreted conservatively.""" + +from django.conf import settings +import requests + + +class PaymentVerificationUncertain(Exception): + """The gateway response does not prove either payment success or failure.""" + + +def request_payment_verification(payment): + try: + response = requests.post( + settings.ZARINPAL_VERIFY_URL, + json={ + "merchant_id": settings.ZARINPAL_MERCHANT_ID, + "amount": payment.amount, + "authority": payment.authority, + }, + headers={"accept": "application/json", "content-type": "application/json"}, + timeout=15, + ) + status_code = getattr(response, "status_code", 200) + if isinstance(status_code, int) and status_code >= 400: + raise PaymentVerificationUncertain( + f"ZarinPal verification returned HTTP {status_code}" + ) + body = response.json() + except PaymentVerificationUncertain: + raise + except Exception as exc: + raise PaymentVerificationUncertain("ZarinPal verification request failed") from exc + + if not isinstance(body, dict) or not isinstance(body.get("data"), dict): + raise PaymentVerificationUncertain("ZarinPal returned an invalid verification response") + + code = body["data"].get("code") + try: + code = int(code) + except (TypeError, ValueError) as exc: + raise PaymentVerificationUncertain("ZarinPal returned no verification code") from exc + + if code not in (100, 101, -51): + raise PaymentVerificationUncertain( + f"ZarinPal returned an inconclusive verification code: {code}" + ) + + return code, body["data"] diff --git a/apps/payments/tasks.py b/apps/payments/tasks.py new file mode 100644 index 0000000..fec2f8a --- /dev/null +++ b/apps/payments/tasks.py @@ -0,0 +1,73 @@ +"""Bounded retries for payment verification after an inconclusive callback.""" + +import logging + +from celery import shared_task +from django.shortcuts import get_object_or_404 + +from apps.payments.models import Payment +from apps.payments.services import PaymentVerificationUncertain, request_payment_verification + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=5, name="apps.payments.reconcile_payment") +def reconcile_payment(self, payment_id: int): + payment = get_object_or_404( + Payment.objects.select_related("event", "user"), + pk=payment_id, + ) + if payment.status in (Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.FAILED): + return payment.get_status_display().lower() + if not payment.authority: + logger.error( + "Payment %s remains pending without a gateway authority; manual review is required", + payment.id, + ) + return "pending_manual_review" + + # Imported at task runtime to avoid a view/task import cycle. + from apps.payments.api.views import ( + _mark_payment_paid, + _mark_payment_pending, + _mark_payment_unsuccessful, + _notify_payment_status, + ) + + try: + code, data = request_payment_verification(payment) + except PaymentVerificationUncertain as exc: + _mark_payment_pending(payment.id) + if self.request.retries >= self.max_retries: + logger.error( + "Payment %s verification remains uncertain after bounded retries; manual review is required", + payment.id, + exc_info=exc, + ) + return "pending_manual_review" + countdown = min(60 * (2 ** self.request.retries), 15 * 60) + raise self.retry(exc=exc, countdown=countdown) + + if code in (100, 101): + payment, newly_verified = _mark_payment_paid(payment.id, data) + if newly_verified: + _notify_payment_status( + payment, + title=f"پرداخت رویداد «{payment.event.title}» تأیید شد", + message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", + level="success", + ) + return "paid" + + payment, result, transitioned = _mark_payment_unsuccessful( + payment.id, + Payment.OrderStatusChoices.FAILED, + ) + if transitioned: + _notify_payment_status( + payment, + title=f"پرداخت رویداد «{payment.event.title}» ناموفق بود", + message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.", + level="error", + ) + return result diff --git a/apps/payments/tests/integration/test_payment_admin.py b/apps/payments/tests/integration/test_payment_admin.py index c94b647..5825cb7 100644 --- a/apps/payments/tests/integration/test_payment_admin.py +++ b/apps/payments/tests/integration/test_payment_admin.py @@ -42,9 +42,14 @@ def test_discount_code_admin_crud_and_restore(client, user_factory, auth_headers assert DiscountCode.objects.filter(pk=code.pk).exists() -@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)]) -def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory, - mocker, settings, gateway_result, expected): +@pytest.mark.parametrize( + "gateway_result,expected_status,expected_payment_status", + [({"errors": {"code": -9}}, "failed", Payment.OrderStatusChoices.FAILED), (None, "pending", Payment.OrderStatusChoices.PENDING)], +) +def test_payment_create_gateway_failure_is_persisted_conservatively( + client, user_factory, auth_headers, event_factory, mocker, settings, + gateway_result, expected_status, expected_payment_status, +): settings.ZARINPAL_MERCHANT_ID = "test" settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request" user = user_factory() @@ -56,8 +61,9 @@ def test_payment_create_gateway_failures_leave_no_active_payment(client, user_fa gateway.return_value.json.return_value = gateway_result response = _send(client, "post", "/api/payments/create", auth_headers(user), {"event_id": event.pk, "description": "Ticket"}) - assert response.status_code == expected - assert not Payment.objects.filter(user=user, event=event).exists() + assert response.status_code == 200 + assert response.json()["status"] == expected_status + assert Payment.objects.filter(user=user, event=event, status=expected_payment_status).count() == 1 assert Registration.objects.filter(user=user, event=event).exists() @@ -74,14 +80,16 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING) gateway = mocker.patch("apps.payments.api.views.requests.post") notify = mocker.patch("apps.payments.api.views.notify_user") + retry = mocker.patch("apps.payments.api.views.reconcile_payment.apply_async") if gateway_result is None: gateway.side_effect = RuntimeError("offline") else: gateway.return_value.json.return_value = gateway_result response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"}) - assert response.status_code == 302 and "status=failed" in response.url + assert response.status_code == 302 and "status=pending" in response.url payment.refresh_from_db() registration.refresh_from_db() - assert payment.status == Payment.OrderStatusChoices.FAILED + assert payment.status == Payment.OrderStatusChoices.PENDING assert registration.status == Registration.StatusChoices.PENDING - notify.assert_called_once() + notify.assert_not_called() + retry.assert_called_once_with(args=(payment.id,), countdown=60) diff --git a/apps/payments/tests/integration/test_payment_reconciliation.py b/apps/payments/tests/integration/test_payment_reconciliation.py new file mode 100644 index 0000000..f2e3fee --- /dev/null +++ b/apps/payments/tests/integration/test_payment_reconciliation.py @@ -0,0 +1,139 @@ +import pytest + +from apps.events.models import Registration +from apps.payments.models import Payment +from apps.payments.tasks import reconcile_payment + +pytestmark = [pytest.mark.integration, pytest.mark.django_db] + + +def _pending_payment(user_factory, event_factory): + user = user_factory() + event = event_factory(price=25000) + registration = Registration.objects.create( + event=event, + user=user, + status=Registration.StatusChoices.PENDING, + ) + payment = Payment.objects.create( + user=user, + event=event, + registration=registration, + base_amount=25000, + amount=25000, + authority=f"AUTH-{user.pk}", + status=Payment.OrderStatusChoices.PENDING, + ) + return payment, registration + + +def test_reconciliation_retry_confirms_payment_after_transient_timeout( + user_factory, event_factory, mocker, settings +): + payment, registration = _pending_payment(user_factory, event_factory) + settings.ZARINPAL_MERCHANT_ID = "test" + settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify" + gateway = mocker.patch("apps.payments.services.requests.post") + gateway.side_effect = [ + RuntimeError("temporary timeout"), + mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}), + ] + notify = mocker.patch("apps.payments.api.views._notify_payment_status") + + retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled")) + with pytest.raises(RuntimeError, match="retry scheduled"): + reconcile_payment.run(payment.id) + + retry.assert_called_once() + assert retry.call_args.kwargs["countdown"] == 60 + gateway.side_effect = [ + mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}) + ] + result = reconcile_payment.run(payment.id) + + payment.refresh_from_db() + registration.refresh_from_db() + assert result == "paid" + assert gateway.call_count == 2 + assert payment.status == Payment.OrderStatusChoices.PAID + assert payment.ref_id == "REF-RETRY" + assert registration.status == Registration.StatusChoices.CONFIRMED + notify.assert_called_once() + + +def test_reconciliation_exhaustion_leaves_payment_pending_for_review( + user_factory, event_factory, mocker, settings +): + payment, registration = _pending_payment(user_factory, event_factory) + settings.ZARINPAL_MERCHANT_ID = "test" + settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify" + gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline")) + notify = mocker.patch("apps.payments.api.views._notify_payment_status") + retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled")) + + for retry_number in range(reconcile_payment.max_retries + 1): + reconcile_payment.push_request(retries=retry_number) + try: + if retry_number < reconcile_payment.max_retries: + with pytest.raises(RuntimeError, match="retry scheduled"): + reconcile_payment.run(payment.id) + else: + result = reconcile_payment.run(payment.id) + finally: + reconcile_payment.pop_request() + + payment.refresh_from_db() + registration.refresh_from_db() + assert result == "pending_manual_review" + assert gateway.call_count == reconcile_payment.max_retries + 1 + assert [call.kwargs["countdown"] for call in retry.call_args_list] == [60, 120, 240, 480, 900] + assert payment.status == Payment.OrderStatusChoices.PENDING + assert registration.status == Registration.StatusChoices.PENDING + notify.assert_not_called() + + +def test_reconciliation_marks_only_explicit_gateway_failure_as_failed( + user_factory, event_factory, mocker, settings +): + payment, registration = _pending_payment(user_factory, event_factory) + settings.ZARINPAL_MERCHANT_ID = "test" + settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify" + gateway = mocker.patch("apps.payments.services.requests.post") + gateway.return_value.json.return_value = {"data": {"code": -51}} + notify = mocker.patch("apps.payments.api.views._notify_payment_status") + + result = reconcile_payment.run(payment.id) + + payment.refresh_from_db() + registration.refresh_from_db() + assert result == "failed" + assert gateway.call_count == 1 + assert payment.status == Payment.OrderStatusChoices.FAILED + assert registration.status == Registration.StatusChoices.CANCELLED + notify.assert_called_once() + + +def test_failed_attempt_does_not_cancel_registration_used_by_another_active_payment( + user_factory, event_factory, mocker, settings +): + payment, registration = _pending_payment(user_factory, event_factory) + Payment.objects.create( + user=payment.user, + event=payment.event, + registration=registration, + base_amount=payment.base_amount, + amount=payment.amount, + authority=f"AUTH-OTHER-{payment.user_id}", + status=Payment.OrderStatusChoices.PENDING, + ) + settings.ZARINPAL_MERCHANT_ID = "test" + settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify" + gateway = mocker.patch("apps.payments.services.requests.post") + gateway.return_value.json.return_value = {"data": {"code": -51}} + + reconcile_payment.run(payment.id) + + payment.refresh_from_db() + registration.refresh_from_db() + assert payment.status == Payment.OrderStatusChoices.FAILED + assert registration.status == Registration.StatusChoices.PENDING diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py index f728f4f..03135ab 100644 --- a/apps/payments/tests/integration/test_payments.py +++ b/apps/payments/tests/integration/test_payments.py @@ -57,6 +57,108 @@ class TestPaymentsAPIIntegrationTests: payment = Payment.objects.get(user=self.user, event=self.event) assert payment.discount_code == code + def test_create_payment_reuses_an_active_attempt(self, mocker): + gateway = mocker.patch('apps.payments.api.views.requests.post') + gateway.return_value.json.return_value = {'data': {'code': 100, 'authority': 'AUTH-REUSED'}} + first = self.client.post( + '/api/payments/create', + data=json.dumps({ + 'event_id': self.event.id, + 'description': 'First tap', + 'idempotency_key': 'b0b849d1-8d8f-4f0d-94b6-7b60876f1501', + }), + content_type='application/json', + **self._headers(), + ) + second = self.client.post( + '/api/payments/create', + data=json.dumps({ + 'event_id': self.event.id, + 'description': 'Repeated tap', + 'idempotency_key': 'b5f06335-1301-48c2-b504-f009c8e087d3', + }), + content_type='application/json', + **self._headers(), + ) + + assert first.status_code == second.status_code == 200 + assert first.json()['payment_id'] == second.json()['payment_id'] + assert first.json()['start_pay_url'] == second.json()['start_pay_url'] + assert Payment.objects.filter(user=self.user, event=self.event).count() == 1 + gateway.assert_called_once() + + def test_create_payment_replays_same_key_after_gateway_timeout(self, mocker): + gateway = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('timeout')) + payload = { + 'event_id': self.event.id, + 'description': 'Checkout retry', + 'idempotency_key': 'ed7e1426-320d-4faf-b808-7ad63e73477c', + } + + first = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers()) + second = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers()) + + assert first.status_code == second.status_code == 200 + assert first.json()['status'] == second.json()['status'] == 'pending' + assert first.json()['payment_id'] == second.json()['payment_id'] + assert Payment.objects.filter(user=self.user, event=self.event).count() == 1 + gateway.assert_called_once() + + def test_gateway_server_error_is_not_treated_as_a_definitive_failure(self, mocker): + gateway_response = mocker.Mock(status_code=503) + gateway_response.json.return_value = {"errors": {"code": -9}} + mocker.patch("apps.payments.api.views.requests.post", return_value=gateway_response) + + response = self.client.post( + "/api/payments/create", + data=json.dumps({"event_id": self.event.id, "description": "Gateway server error"}), + content_type="application/json", + **self._headers(), + ) + + assert response.status_code == 200 + assert response.json()["status"] == "pending" + assert Payment.objects.filter( + user=self.user, + event=self.event, + status=Payment.OrderStatusChoices.PENDING, + authority__isnull=True, + ).exists() + + def test_definitive_gateway_rejection_allows_a_new_attempt(self, mocker): + gateway = mocker.patch('apps.payments.api.views.requests.post') + gateway.side_effect = [ + mocker.Mock(**{'json.return_value': {'errors': {'code': -9}}}), + mocker.Mock(**{'json.return_value': {'data': {'code': 100, 'authority': 'AUTH-RETRY'}}}), + ] + rejected = self.client.post( + '/api/payments/create', + data=json.dumps({ + 'event_id': self.event.id, + 'description': 'Rejected attempt', + 'idempotency_key': '060826cf-b8c0-4828-b0f2-d37c0f2678dd', + }), + content_type='application/json', + **self._headers(), + ) + retried = self.client.post( + '/api/payments/create', + data=json.dumps({ + 'event_id': self.event.id, + 'description': 'New attempt', + 'idempotency_key': '865f3c43-2888-4fd0-8706-1c2ae60cb5f6', + }), + content_type='application/json', + **self._headers(), + ) + + assert rejected.status_code == retried.status_code == 200 + assert rejected.json()['status'] == 'failed' + assert retried.json()['status'] == 'pending' + assert Payment.objects.filter(user=self.user, event=self.event).count() == 2 + assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.FAILED).count() == 1 + assert gateway.call_count == 2 + def test_callback_success_marks_paid(self, mocker): mock_post = mocker.patch('apps.payments.api.views.requests.post') payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH123') @@ -83,18 +185,87 @@ class TestPaymentsAPIIntegrationTests: response = self.client.get('/api/payments/callback', {'Status': 'OK'}) assert response.status_code == 400 - def test_callback_not_ok_cancels(self): + def test_callback_not_ok_is_verified_before_being_canceled(self, mocker): payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789') + gateway = mocker.patch('apps.payments.api.views.requests.post') + gateway.return_value.json.return_value = {'data': {'code': -51}} response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'}) payment.refresh_from_db() - assert payment.status == Payment.OrderStatusChoices.CANCELED + assert payment.status == Payment.OrderStatusChoices.FAILED assert 'status=failed' in response.url - def test_create_payment_gateway_failure(self, mocker): - mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) + def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker): + registration = Registration.objects.create( + event=self.event, + user=self.user, + status=Registration.StatusChoices.PENDING, + final_price=self.event.price, + ) + payment = Payment.objects.create( + user=self.user, + event=self.event, + registration=registration, + base_amount=self.event.price, + amount=self.event.price, + status=Payment.OrderStatusChoices.PENDING, + authority='AUTH-LATE-NOK', + ) + mock_post = mocker.patch('apps.payments.api.views.requests.post') + mock_response = mocker.Mock() + mock_response.json.return_value = {'data': {'code': 100, 'ref_id': 'REF-LATE-NOK'}} + mock_post.return_value = mock_response + + success_response = self.client.get( + '/api/payments/callback', + {'Authority': payment.authority, 'Status': 'OK'}, + ) + late_failure_response = self.client.get( + '/api/payments/callback', + {'Authority': payment.authority, 'Status': 'NOK'}, + ) + + payment.refresh_from_db() + registration.refresh_from_db() + assert payment.status == Payment.OrderStatusChoices.PAID + assert payment.verified_at is not None + assert registration.status == Registration.StatusChoices.CONFIRMED + assert 'status=success' in success_response.url + assert 'status=success' in late_failure_response.url + + def test_create_payment_gateway_timeout_stays_pending(self, mocker): + mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers()) - assert response.status_code == 502 - assert not Payment.objects.filter(user=self.user).exists() + assert response.status_code == 200 + assert response.json()['status'] == 'pending' + assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.PENDING).exists() + + def test_verification_timeout_stays_pending_and_queues_reconciliation(self, mocker): + payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-TIMEOUT') + mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('gateway timeout')) + reconcile = mocker.patch('apps.payments.api.views.reconcile_payment.apply_async') + notify = mocker.patch('apps.payments.api.views.notify_user') + + response = self.client.get('/api/payments/callback', {'Authority': payment.authority, 'Status': 'OK'}) + + payment.refresh_from_db() + assert response.status_code == 302 + assert 'status=pending' in response.url + assert f'payment_id={payment.id}' in response.url + assert payment.status == Payment.OrderStatusChoices.PENDING + reconcile.assert_called_once_with(args=(payment.id,), countdown=60) + notify.assert_not_called() + + def test_payment_status_is_user_scoped_and_reflects_saved_state(self): + payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-STATUS') + own = self.client.get(f'/api/payments/status/{payment.id}', **self._headers()) + assert own.status_code == 200 + assert own.json()['payment_id'] == payment.id + assert own.json()['status'] == 'pending' + + other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password) + other_token = create_jwt_token(other) + forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}') + assert forbidden.status_code == 404 def test_create_payment_when_already_paid(self): Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)