نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user