242 lines
8.8 KiB
Python
242 lines
8.8 KiB
Python
"""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
|
|
|
|
|
|
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(
|
|
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"]
|
|
|
|
|
|
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",
|
|
])
|