"""Stripe adapter for :class:`PaymentProviderPort`. Uses Stripe's REST API directly over ``httpx`` rather than the official SDK: the two calls we need are stable, and it keeps the API process free of an extra dependency and its release cadence. Responsibility boundary ----------------------- The internal usage ledger is the **source of truth** for what a tenant owes. This adapter only moves money — it never decides a balance, and a successful checkout does not by itself credit a wallet. The application layer settles the payment and then writes the credit, so a webhook replayed twice cannot double-credit. Amounts are converted to Stripe's minor units (cents) with ``Decimal`` rounding, never float multiplication, so ``19.99`` cannot become ``1998``. """ from __future__ import annotations import hashlib import hmac import time from decimal import ROUND_HALF_UP, Decimal import httpx from app.application.ports import CheckoutSession, PaymentResult from app.core.errors import ProviderUnavailableError, ValidationError STRIPE_API_BASE = "https://api.stripe.com/v1" DEFAULT_TIMEOUT_SECONDS = 20.0 #: Stripe rejects signatures older than this; mirrored locally to fail early. WEBHOOK_TOLERANCE_SECONDS = 300 #: Card networks require a minimum charge. Refuse early with a clear message #: rather than letting Stripe return a confusing error. MINIMUM_CHARGE_USD = Decimal("0.50") class StripePaymentAdapter: def __init__( self, *, secret_key: str, webhook_secret: str = "", api_base: str = STRIPE_API_BASE, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, ) -> None: if not secret_key: raise ProviderUnavailableError( "PAYMENT_PROVIDER=stripe نیازمند تنظیم STRIPE_SECRET_KEY است." ) self._secret_key = secret_key self._webhook_secret = webhook_secret self.api_base = api_base.rstrip("/") self.timeout_seconds = timeout_seconds # -- port ---------------------------------------------------------------- async def create_checkout( self, *, organization_id: str, amount: float, currency: str, success_url: str, cancel_url: str, ) -> CheckoutSession: """Create a one-off Checkout Session for a wallet top-up.""" minor_units = self._to_minor_units(amount) currency_code = (currency or "usd").strip().lower() form = { "mode": "payment", "success_url": success_url, "cancel_url": cancel_url, # Ties the Stripe object back to our tenant without exposing an id # the customer can edit meaningfully. "client_reference_id": organization_id, "metadata[organization_id]": organization_id, "line_items[0][quantity]": "1", "line_items[0][price_data][currency]": currency_code, "line_items[0][price_data][unit_amount]": str(minor_units), "line_items[0][price_data][product_data][name]": "AGY credit top-up", } payload = await self._request("POST", "/checkout/sessions", data=form) url = payload.get("url") if not url: raise ProviderUnavailableError("Stripe آدرس پرداخت را برنگرداند.") return CheckoutSession( external_id=payload.get("id", ""), url=url, # Report back in major units so the caller never sees minor units. amount=float(Decimal(payload.get("amount_total") or minor_units) / 100), currency=(payload.get("currency") or currency_code).upper(), ) async def refund( self, *, external_payment_id: str, amount: float | None = None ) -> PaymentResult: if not external_payment_id: raise ValidationError("شناسهٔ پرداخت برای بازگشت وجه لازم است.") form: dict[str, str] = {} # A Checkout Session is not refundable directly; its PaymentIntent is. # Accept either so a caller does not have to know Stripe's object graph. if external_payment_id.startswith("cs_"): session = await self._request( "GET", f"/checkout/sessions/{external_payment_id}", params={} ) intent = session.get("payment_intent") if not intent: return PaymentResult( ok=False, external_id=external_payment_id, message="این پرداخت هنوز تکمیل نشده است.", ) form["payment_intent"] = str(intent) else: form["payment_intent"] = external_payment_id if amount is not None: form["amount"] = str(self._to_minor_units(amount)) payload = await self._request("POST", "/refunds", data=form) return PaymentResult( ok=payload.get("status") in {"succeeded", "pending"}, external_id=payload.get("id", ""), message=f"وضعیت بازگشت وجه: {payload.get('status', 'unknown')}", ) # -- webhook ------------------------------------------------------------- def verify_webhook_signature(self, *, payload: bytes, signature_header: str) -> bool: """Verify a ``Stripe-Signature`` header over the raw request body. The body must be the *raw bytes* — re-serialising parsed JSON changes the byte sequence and the HMAC will never match. """ if not self._webhook_secret: return False timestamp = "" signatures: list[str] = [] for part in (signature_header or "").split(","): key, _, value = part.partition("=") if key.strip() == "t": timestamp = value.strip() elif key.strip() == "v1": signatures.append(value.strip()) if not timestamp or not signatures: return False try: age = abs(time.time() - int(timestamp)) except ValueError: return False if age > WEBHOOK_TOLERANCE_SECONDS: # Replay protection. return False signed_payload = f"{timestamp}.".encode() + payload expected = hmac.new( self._webhook_secret.encode(), signed_payload, hashlib.sha256 ).hexdigest() # Constant-time compare against every offered signature. return any(hmac.compare_digest(expected, candidate) for candidate in signatures) # -- helpers ------------------------------------------------------------- @staticmethod def _to_minor_units(amount: float) -> int: value = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if value <= 0: raise ValidationError("مبلغ باید بزرگ‌تر از صفر باشد.") if value < MINIMUM_CHARGE_USD: raise ValidationError(f"حداقل مبلغ پرداخت {MINIMUM_CHARGE_USD} دلار است.") return int(value * 100) async def _request(self, method: str, path: str, **kwargs) -> dict: headers = {"Authorization": f"Bearer {self._secret_key}"} try: async with httpx.AsyncClient( base_url=self.api_base, timeout=self.timeout_seconds ) as client: response = await client.request(method, path, headers=headers, **kwargs) except httpx.TimeoutException as exc: raise ProviderUnavailableError("ارتباط با Stripe زمان‌بر شد.") from exc except httpx.HTTPError as exc: raise ProviderUnavailableError("ارتباط با Stripe برقرار نشد.") from exc if response.status_code >= 400: detail = "" try: detail = (response.json().get("error") or {}).get("message", "") except ValueError: detail = response.text[:200] raise ProviderUnavailableError( f"Stripe خطا برگرداند ({response.status_code}): {detail}" ) return response.json()