feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

View File

@@ -1,6 +1,10 @@
"""Gateway operations whose responses must be interpreted conservatively."""
"""Gateway operations and durable verification scheduling."""
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
import requests
@@ -8,6 +12,14 @@ class PaymentVerificationUncertain(Exception):
"""The gateway response does not prove either payment success or failure."""
class PaymentReversalUncertain(Exception):
"""The gateway did not give a conclusive response to a reversal request."""
class PaymentReversalRejected(Exception):
"""The gateway definitively rejected the reversal and needs human review."""
def request_payment_verification(payment):
try:
response = requests.post(
@@ -46,3 +58,184 @@ def request_payment_verification(payment):
)
return code, body["data"]
def request_payment_reversal(payment):
"""Reverse a paid transaction through ZarinPal's transaction reversal API."""
try:
response = requests.post(
settings.ZARINPAL_REVERSE_URL,
json={
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"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 >= 500:
raise PaymentReversalUncertain(
f"ZarinPal reversal returned HTTP {status_code}"
)
if isinstance(status_code, int) and status_code >= 400:
raise PaymentReversalRejected(
f"ZarinPal reversal was rejected with HTTP {status_code}"
)
body = response.json()
except (PaymentReversalUncertain, PaymentReversalRejected):
raise
except Exception as exc:
raise PaymentReversalUncertain("ZarinPal reversal request failed") from exc
if not isinstance(body, dict) or not isinstance(body.get("data"), dict):
if isinstance(body, dict) and isinstance(body.get("errors"), dict):
raise PaymentReversalRejected("ZarinPal rejected the reversal request")
raise PaymentReversalUncertain("ZarinPal returned an invalid reversal response")
code = body["data"].get("code")
try:
code = int(code)
except (TypeError, ValueError) as exc:
raise PaymentReversalUncertain("ZarinPal returned no reversal status code") from exc
if code != 100:
raise PaymentReversalRejected(f"ZarinPal rejected the reversal with code {code}")
return body["data"]
def claim_payment_verification(
payment_id: int,
*,
lease_seconds: int = 120,
allow_terminal: bool = False,
allow_manual_review: bool = False,
):
"""Claim one bounded verification attempt using the database as the lock."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None, False
if payment.status == Payment.OrderStatusChoices.PAID or (
payment.status in (
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
)
and not allow_terminal
):
return payment, False
now = timezone.now()
if payment.verification_manual_review_at is not None and not allow_manual_review:
return payment, False
if not payment.authority:
_mark_payment_manual_review(payment, now)
return payment, False
if payment.verification_lease_until and payment.verification_lease_until > now:
return payment, False
was_dispatched = bool(
payment.verification_dispatch_expires_at
and payment.verification_dispatch_expires_at > now
)
if (
payment.verification_next_attempt_at
and payment.verification_next_attempt_at > now
and not was_dispatched
):
return payment, False
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts and not allow_manual_review:
_mark_payment_manual_review(payment, now)
return payment, False
payment.verification_attempts += 1
payment.verification_last_attempt_at = now
payment.verification_next_attempt_at = None
payment.verification_lease_until = now + timedelta(seconds=lease_seconds)
payment.verification_dispatch_expires_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_attempts",
"verification_last_attempt_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_manual_review_at",
])
return payment, True
def schedule_payment_verification_retry(payment_id: int):
"""Persist the next backoff step or stop at manual review after exhaustion."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
if payment.status in (
Payment.OrderStatusChoices.PAID,
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
):
payment.verification_next_attempt_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return None
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts:
_mark_payment_manual_review(payment, timezone.now())
return None
attempt_index = max(payment.verification_attempts - 1, 0)
delay = min(60 * (2 ** attempt_index), 15 * 60)
payment.verification_next_attempt_at = timezone.now() + timedelta(seconds=delay)
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return delay
def mark_payment_manual_review(payment_id: int):
"""Keep an unresolved payment pending and make support review durable."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
_mark_payment_manual_review(payment, timezone.now())
return payment
def _mark_payment_manual_review(payment, now):
payment.verification_manual_review_at = payment.verification_manual_review_at or now
payment.verification_next_attempt_at = None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
payment.save(update_fields=[
"verification_manual_review_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
])