مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
204 lines
8.0 KiB
Python
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()
|