feat(events): harden registration checkout lifecycle
This commit is contained in:
@@ -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",
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user