fix(payments): make checkout attempts idempotent and reconcilable

This commit is contained in:
2026-09-30 21:14:16 +03:30
parent 77567178ad
commit 1daa755543
9 changed files with 862 additions and 173 deletions

View File

@@ -2,6 +2,7 @@ from typing import Literal, Optional
from ninja import Schema from ninja import Schema
from datetime import datetime from datetime import datetime
from uuid import UUID
class DiscountCodeSchema(Schema): class DiscountCodeSchema(Schema):
@@ -47,16 +48,22 @@ class CreatePaymentIn(Schema):
discount_code: str | None = None discount_code: str | None = None
mobile: str | None = None mobile: str | None = None
email: str | None = None email: str | None = None
idempotency_key: UUID | None = None
class CreatePaymentOut(Schema): class CreatePaymentOut(Schema):
start_pay_url: str | None = None start_pay_url: str | None = None
authority: 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 base_amount: int
discount_amount: int discount_amount: int
amount: int amount: int
class PaymentDetailOut(Schema): class PaymentDetailOut(Schema):
payment_id: int
event_id: int
ref_id: str | None = None ref_id: str | None = None
authority: str | None = None authority: str | None = None
base_amount: int base_amount: int

View File

@@ -1,7 +1,11 @@
import logging
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.db.models import Count, Q from django.db.models import Count, Q
from django.db import transaction
from typing import List from typing import List
from ninja import Query, Router from ninja import Query, Router
@@ -11,6 +15,8 @@ import requests
from apps.payments.models import Payment, DiscountCode from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.notifications.services import notify_user 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 apps.users.tasks import send_critical_sms
from core.authentication import jwt_auth from core.authentication import jwt_auth
from core.api.schemas import ErrorSchema, MessageSchema from core.api.schemas import ErrorSchema, MessageSchema
@@ -28,6 +34,7 @@ from apps.payments.api.schemas import (
) )
payments_router = Router(tags=["Payments"]) payments_router = Router(tags=["Payments"])
logger = logging.getLogger(__name__)
@payments_router.get("/me", response=List[UserPaymentSchema], auth=jwt_auth) @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) 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) @payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn): 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(): # Serialize checkout creation for this event so concurrent taps cannot
raise HttpError(400, "You have already registered in this event") # both create a new active attempt for the same user/event pair.
with transaction.atomic():
registration = ( event = get_object_or_404(
Registration.objects.filter(event=event, user=request.auth, is_deleted=False) Event.objects.select_for_update(),
.order_by("-registered_at") pk=payload.event_id,
.first() is_deleted=False,
)
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 keyed_payment = Payment.objects.filter(
discount_amount = 0 user=request.auth,
final_amount = event.price event=event,
idempotency_key=idempotency_key,
).select_related("event").first()
if keyed_payment:
return _payment_checkout_payload(keyed_payment)
if payload.discount_code: if Payment.objects.filter(
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first() status=Payment.OrderStatusChoices.PAID,
user=request.auth,
event=event,
).exists():
raise HttpError(400, "You have already registered in this event")
if discount_code: active_payment = (
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth) 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 = [] registration = (
if discount_code and registration.discount_code_id != discount_code.id: Registration.objects.select_for_update()
registration.discount_code = discount_code .filter(event=event, user=request.auth, is_deleted=False)
registration_updates.append("discount_code") .order_by("-registered_at")
if registration.discount_amount != discount_amount: .first()
registration.discount_amount = discount_amount )
registration_updates.append("discount_amount") if not registration or registration.status == Registration.StatusChoices.CANCELLED:
if registration.final_price != final_amount: registration = Registration.objects.create(
registration.final_price = final_amount event=event,
registration_updates.append("final_price") 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: if registration_updates:
registration.save(update_fields=list(set(registration_updates))) registration.save(update_fields=list(set(registration_updates)))
else:
registration.save(update_fields=["status"])
return { pay = Payment.objects.create(
"start_pay_url": None, user=request.auth,
"authority": None, event=event,
"base_amount": event.price, base_amount=event.price,
"discount_amount": discount_amount if discount_amount else 0, discount_code=discount_code,
"amount": 0, discount_amount=discount_amount,
} amount=final_amount,
status=Payment.OrderStatusChoices.INIT,
if registration_updates: registration=registration,
registration.save(update_fields=list(set(registration_updates))) idempotency_key=idempotency_key,
)
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,
)
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback") callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
body = { body = {
@@ -336,28 +411,188 @@ def create_payment(request, payload: CreatePaymentIn):
headers={"accept":"application/json","content-type":"application/json"}, headers={"accept":"application/json","content-type":"application/json"},
timeout=15 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() jd = response.json()
except Exception as e: except Exception:
pay.delete() logger.exception(
raise HttpError(502, f"Gateway request failed: {e}") "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") data = jd.get("data") if isinstance(jd, dict) else None
if code != 100: code = data.get("code") if isinstance(data, dict) else None
pay.delete() try:
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}") 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.authority = authority
pay.status = Payment.OrderStatusChoices.PENDING pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["authority","status"]) pay.save(update_fields=["authority","status"])
return { return _payment_checkout_payload(pay)
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
"authority": authority, def _callback_redirect(payment: Payment, status: str):
"base_amount": event.price, url = (
"discount_amount": discount_amount if discount_amount else 0, f"{settings.FRONTEND_CALLBACK_URL}?status={status}"
"amount": final_amount, 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") @payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None): 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() pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay: if not pay:
raise HttpError(404, "Payment not found") raise HttpError(404, "Payment not found")
previous_status = pay.status if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
pay, _ = _mark_payment_paid(pay.id)
if Status != "OK": return _callback_redirect(pay, "success")
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,
}
try: try:
vresp = requests.post( vcode, data = request_payment_verification(pay)
settings.ZARINPAL_VERIFY_URL, except PaymentVerificationUncertain:
json=verify_body, pay = _mark_payment_pending(pay.id)
headers={"accept":"application/json","content-type":"application/json"}, if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority:
timeout=15 try:
) reconcile_payment.apply_async(args=(pay.id,), countdown=60)
vjd = vresp.json() except Exception:
except Exception: logger.exception(
pay.status = Payment.OrderStatusChoices.FAILED "Unable to queue payment verification retry for payment %s; it remains pending for manual review",
pay.save(update_fields=["status"]) pay.id,
if previous_status != Payment.OrderStatusChoices.FAILED: )
_notify_payment_status( return _callback_redirect(pay, _payment_status_name(pay))
pay,
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
message="خطا در تأیید پرداخت رخ داد. در صورت نیاز دوباره تلاش کنید.",
level="error",
)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
vcode = (vjd.get("data") or {}).get("code")
if vcode in (100, 101): if vcode in (100, 101):
data = vjd.get("data") or {} pay, newly_verified = _mark_payment_paid(pay.id, data)
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"])
registration = pay.registration or Registration.objects.filter( if newly_verified:
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:
_notify_payment_status( _notify_payment_status(
pay, pay,
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد", title=f"پرداخت رویداد «{pay.event.title}» تأیید شد",
@@ -438,39 +631,55 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
level="success", 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, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED)
pay.save(update_fields=["status"]) if transitioned:
if previous_status != Payment.OrderStatusChoices.FAILED:
_notify_payment_status( _notify_payment_status(
pay, pay,
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود", title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.", message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
level="error", 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) @payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
def payment_by_ref(request, ref_id: str): def payment_by_ref(request, ref_id: str):
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id) pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
ev = pay.event return _payment_detail_payload(request, pay)
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,
},
}
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth) @payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
def check_coupon(request, payload: CouponVerifyIn): def check_coupon(request, payload: CouponVerifyIn):

View File

@@ -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'),
),
]

View File

@@ -97,6 +97,7 @@ class Payment(BaseModel):
related_name="payments", related_name="payments",
editable=False, 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) 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) status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
ref_id = models.CharField(max_length=64, null=True, blank=True, 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) card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
verified_at = models.DateTimeField(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): def clean(self):
if self.discount_amount and self.amount + self.discount_amount != self.base_amount: if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"}) raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
@@ -119,4 +129,3 @@ class Payment(BaseModel):
def __str__(self): def __str__(self):
return f"{self.user.email}:{self.event} - {self.get_status_display()}" return f"{self.user.email}:{self.event} - {self.get_status_display()}"

48
apps/payments/services.py Normal file
View File

@@ -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"]

73
apps/payments/tasks.py Normal file
View File

@@ -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

View File

@@ -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() assert DiscountCode.objects.filter(pk=code.pk).exists()
@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)]) @pytest.mark.parametrize(
def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory, "gateway_result,expected_status,expected_payment_status",
mocker, settings, gateway_result, expected): [({"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_MERCHANT_ID = "test"
settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request" settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request"
user = user_factory() 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 gateway.return_value.json.return_value = gateway_result
response = _send(client, "post", "/api/payments/create", auth_headers(user), response = _send(client, "post", "/api/payments/create", auth_headers(user),
{"event_id": event.pk, "description": "Ticket"}) {"event_id": event.pk, "description": "Ticket"})
assert response.status_code == expected assert response.status_code == 200
assert not Payment.objects.filter(user=user, event=event).exists() 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() 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) base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING)
gateway = mocker.patch("apps.payments.api.views.requests.post") gateway = mocker.patch("apps.payments.api.views.requests.post")
notify = mocker.patch("apps.payments.api.views.notify_user") 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: if gateway_result is None:
gateway.side_effect = RuntimeError("offline") gateway.side_effect = RuntimeError("offline")
else: else:
gateway.return_value.json.return_value = gateway_result gateway.return_value.json.return_value = gateway_result
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"}) 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() payment.refresh_from_db()
registration.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 assert registration.status == Registration.StatusChoices.PENDING
notify.assert_called_once() notify.assert_not_called()
retry.assert_called_once_with(args=(payment.id,), countdown=60)

View File

@@ -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

View File

@@ -57,6 +57,108 @@ class TestPaymentsAPIIntegrationTests:
payment = Payment.objects.get(user=self.user, event=self.event) payment = Payment.objects.get(user=self.user, event=self.event)
assert payment.discount_code == code 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): def test_callback_success_marks_paid(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post') 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') 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'}) response = self.client.get('/api/payments/callback', {'Status': 'OK'})
assert response.status_code == 400 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') 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'}) response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
payment.refresh_from_db() payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.CANCELED assert payment.status == Payment.OrderStatusChoices.FAILED
assert 'status=failed' in response.url assert 'status=failed' in response.url
def test_create_payment_gateway_failure(self, mocker): def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) 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()) 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 response.status_code == 200
assert not Payment.objects.filter(user=self.user).exists() 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): 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) Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)