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 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
editable=False,
|
||||
)
|
||||
idempotency_key = models.UUIDField(null=True, blank=True, editable=False)
|
||||
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
|
||||
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
|
||||
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
||||
@@ -104,6 +105,15 @@ class Payment(BaseModel):
|
||||
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
||||
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "event", "idempotency_key"],
|
||||
condition=Q(idempotency_key__isnull=False),
|
||||
name="payment_user_event_idempotency_key_uniq",
|
||||
),
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
|
||||
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
|
||||
@@ -119,4 +129,3 @@ class Payment(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)])
|
||||
def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory,
|
||||
mocker, settings, gateway_result, expected):
|
||||
@pytest.mark.parametrize(
|
||||
"gateway_result,expected_status,expected_payment_status",
|
||||
[({"errors": {"code": -9}}, "failed", Payment.OrderStatusChoices.FAILED), (None, "pending", Payment.OrderStatusChoices.PENDING)],
|
||||
)
|
||||
def test_payment_create_gateway_failure_is_persisted_conservatively(
|
||||
client, user_factory, auth_headers, event_factory, mocker, settings,
|
||||
gateway_result, expected_status, expected_payment_status,
|
||||
):
|
||||
settings.ZARINPAL_MERCHANT_ID = "test"
|
||||
settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request"
|
||||
user = user_factory()
|
||||
@@ -56,8 +61,9 @@ def test_payment_create_gateway_failures_leave_no_active_payment(client, user_fa
|
||||
gateway.return_value.json.return_value = gateway_result
|
||||
response = _send(client, "post", "/api/payments/create", auth_headers(user),
|
||||
{"event_id": event.pk, "description": "Ticket"})
|
||||
assert response.status_code == expected
|
||||
assert not Payment.objects.filter(user=user, event=event).exists()
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == expected_status
|
||||
assert Payment.objects.filter(user=user, event=event, status=expected_payment_status).count() == 1
|
||||
assert Registration.objects.filter(user=user, event=event).exists()
|
||||
|
||||
|
||||
@@ -74,14 +80,16 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory
|
||||
base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING)
|
||||
gateway = mocker.patch("apps.payments.api.views.requests.post")
|
||||
notify = mocker.patch("apps.payments.api.views.notify_user")
|
||||
retry = mocker.patch("apps.payments.api.views.reconcile_payment.apply_async")
|
||||
if gateway_result is None:
|
||||
gateway.side_effect = RuntimeError("offline")
|
||||
else:
|
||||
gateway.return_value.json.return_value = gateway_result
|
||||
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
|
||||
assert response.status_code == 302 and "status=failed" in response.url
|
||||
assert response.status_code == 302 and "status=pending" in response.url
|
||||
payment.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
assert registration.status == Registration.StatusChoices.PENDING
|
||||
notify.assert_called_once()
|
||||
notify.assert_not_called()
|
||||
retry.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||
|
||||
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)
|
||||
assert payment.discount_code == code
|
||||
|
||||
def test_create_payment_reuses_an_active_attempt(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
gateway.return_value.json.return_value = {'data': {'code': 100, 'authority': 'AUTH-REUSED'}}
|
||||
first = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'First tap',
|
||||
'idempotency_key': 'b0b849d1-8d8f-4f0d-94b6-7b60876f1501',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
second = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'Repeated tap',
|
||||
'idempotency_key': 'b5f06335-1301-48c2-b504-f009c8e087d3',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json()['payment_id'] == second.json()['payment_id']
|
||||
assert first.json()['start_pay_url'] == second.json()['start_pay_url']
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
|
||||
gateway.assert_called_once()
|
||||
|
||||
def test_create_payment_replays_same_key_after_gateway_timeout(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('timeout'))
|
||||
payload = {
|
||||
'event_id': self.event.id,
|
||||
'description': 'Checkout retry',
|
||||
'idempotency_key': 'ed7e1426-320d-4faf-b808-7ad63e73477c',
|
||||
}
|
||||
|
||||
first = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
|
||||
second = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json()['status'] == second.json()['status'] == 'pending'
|
||||
assert first.json()['payment_id'] == second.json()['payment_id']
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
|
||||
gateway.assert_called_once()
|
||||
|
||||
def test_gateway_server_error_is_not_treated_as_a_definitive_failure(self, mocker):
|
||||
gateway_response = mocker.Mock(status_code=503)
|
||||
gateway_response.json.return_value = {"errors": {"code": -9}}
|
||||
mocker.patch("apps.payments.api.views.requests.post", return_value=gateway_response)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({"event_id": self.event.id, "description": "Gateway server error"}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending"
|
||||
assert Payment.objects.filter(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority__isnull=True,
|
||||
).exists()
|
||||
|
||||
def test_definitive_gateway_rejection_allows_a_new_attempt(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
gateway.side_effect = [
|
||||
mocker.Mock(**{'json.return_value': {'errors': {'code': -9}}}),
|
||||
mocker.Mock(**{'json.return_value': {'data': {'code': 100, 'authority': 'AUTH-RETRY'}}}),
|
||||
]
|
||||
rejected = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'Rejected attempt',
|
||||
'idempotency_key': '060826cf-b8c0-4828-b0f2-d37c0f2678dd',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
retried = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'New attempt',
|
||||
'idempotency_key': '865f3c43-2888-4fd0-8706-1c2ae60cb5f6',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert rejected.status_code == retried.status_code == 200
|
||||
assert rejected.json()['status'] == 'failed'
|
||||
assert retried.json()['status'] == 'pending'
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 2
|
||||
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.FAILED).count() == 1
|
||||
assert gateway.call_count == 2
|
||||
|
||||
def test_callback_success_marks_paid(self, mocker):
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH123')
|
||||
@@ -83,18 +185,87 @@ class TestPaymentsAPIIntegrationTests:
|
||||
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_callback_not_ok_cancels(self):
|
||||
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
gateway.return_value.json.return_value = {'data': {'code': -51}}
|
||||
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
|
||||
payment.refresh_from_db()
|
||||
assert payment.status == Payment.OrderStatusChoices.CANCELED
|
||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||
assert 'status=failed' in response.url
|
||||
|
||||
def test_create_payment_gateway_failure(self, mocker):
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
||||
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority='AUTH-LATE-NOK',
|
||||
)
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
||||
mock_response = mocker.Mock()
|
||||
mock_response.json.return_value = {'data': {'code': 100, 'ref_id': 'REF-LATE-NOK'}}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
success_response = self.client.get(
|
||||
'/api/payments/callback',
|
||||
{'Authority': payment.authority, 'Status': 'OK'},
|
||||
)
|
||||
late_failure_response = self.client.get(
|
||||
'/api/payments/callback',
|
||||
{'Authority': payment.authority, 'Status': 'NOK'},
|
||||
)
|
||||
|
||||
payment.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||
assert payment.verified_at is not None
|
||||
assert registration.status == Registration.StatusChoices.CONFIRMED
|
||||
assert 'status=success' in success_response.url
|
||||
assert 'status=success' in late_failure_response.url
|
||||
|
||||
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
|
||||
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
||||
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers())
|
||||
assert response.status_code == 502
|
||||
assert not Payment.objects.filter(user=self.user).exists()
|
||||
assert response.status_code == 200
|
||||
assert response.json()['status'] == 'pending'
|
||||
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.PENDING).exists()
|
||||
|
||||
def test_verification_timeout_stays_pending_and_queues_reconciliation(self, mocker):
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-TIMEOUT')
|
||||
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('gateway timeout'))
|
||||
reconcile = mocker.patch('apps.payments.api.views.reconcile_payment.apply_async')
|
||||
notify = mocker.patch('apps.payments.api.views.notify_user')
|
||||
|
||||
response = self.client.get('/api/payments/callback', {'Authority': payment.authority, 'Status': 'OK'})
|
||||
|
||||
payment.refresh_from_db()
|
||||
assert response.status_code == 302
|
||||
assert 'status=pending' in response.url
|
||||
assert f'payment_id={payment.id}' in response.url
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||
notify.assert_not_called()
|
||||
|
||||
def test_payment_status_is_user_scoped_and_reflects_saved_state(self):
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-STATUS')
|
||||
own = self.client.get(f'/api/payments/status/{payment.id}', **self._headers())
|
||||
assert own.status_code == 200
|
||||
assert own.json()['payment_id'] == payment.id
|
||||
assert own.json()['status'] == 'pending'
|
||||
|
||||
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
|
||||
other_token = create_jwt_token(other)
|
||||
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
|
||||
assert forbidden.status_code == 404
|
||||
|
||||
def test_create_payment_when_already_paid(self):
|
||||
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
|
||||
|
||||
Reference in New Issue
Block a user