fix(payments): make checkout attempts idempotent and reconcilable
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
48
apps/payments/services.py
Normal 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
73
apps/payments/tasks.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
139
apps/payments/tests/integration/test_payment_reconciliation.py
Normal file
139
apps/payments/tests/integration/test_payment_reconciliation.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user