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