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

View File

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