Files
guilan-ace-backend/apps/payments/services.py

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",
])