Files
smart-org/apps/api/app/infrastructure/adapters/stripe_payments.py
T
mjalmoudy94 717e53a7ce نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
2026-10-13 00:39:28 +03:30

204 lines
8.0 KiB
Python

"""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()