feat: integrate CREEM web payment for credits purchase
Replace abandoned iOS App Store route with CREEM Merchant of Record payment integration for web-based credits purchase. Backend changes: - Add CreemClient for CREEM API communication - Add CreemService for checkout creation and webhook handling - Add creem_transactions table for payment tracking - Fix webhook payload parsing (id, order.id, customer.id structure) - Integrate with existing points ledger system Frontend changes: - Display dynamic prices from CREEM API - Support decimal price formatting (e.g., $1.00) - Add checkout flow with redirect to CREEM hosted page
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config.settings import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreemProduct:
|
||||
product_id: str
|
||||
name: str
|
||||
price_cents: int
|
||||
currency: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreemCheckout:
|
||||
checkout_id: str
|
||||
checkout_url: str
|
||||
|
||||
|
||||
class CreemClient:
|
||||
def __init__(self) -> None:
|
||||
settings = config.creem
|
||||
self._api_key = settings.api_key.get_secret_value() if settings.api_key else None
|
||||
self._base_url = settings.base_url.rstrip("/")
|
||||
self._timeout = httpx.Timeout(30.0, connect=5.0)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if not self._api_key:
|
||||
raise RuntimeError("CREEM API key not configured")
|
||||
return {
|
||||
"x-api-key": self._api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def get_products(self) -> list[CreemProduct]:
|
||||
"""Fetch all products from CREEM."""
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base_url}/v1/products/search",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data: Any = resp.json()
|
||||
|
||||
products: list[CreemProduct] = []
|
||||
for item in data.get("items", []):
|
||||
product_id = item.get("id", "")
|
||||
name = item.get("name", "")
|
||||
price = item.get("price", 0)
|
||||
currency = item.get("currency", "USD")
|
||||
products.append(
|
||||
CreemProduct(
|
||||
product_id=product_id,
|
||||
name=name,
|
||||
price_cents=int(price),
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
return products
|
||||
|
||||
async def get_product(self, product_id: str) -> CreemProduct | None:
|
||||
"""Fetch a single product by ID."""
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base_url}/v1/products",
|
||||
params={"product_id": product_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
data: Any = resp.json()
|
||||
|
||||
return CreemProduct(
|
||||
product_id=data.get("id", ""),
|
||||
name=data.get("name", ""),
|
||||
price_cents=int(data.get("price", 0)),
|
||||
currency=data.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def create_checkout(
|
||||
self,
|
||||
*,
|
||||
product_id: str,
|
||||
success_url: str,
|
||||
customer_email: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> CreemCheckout:
|
||||
"""Create a checkout session."""
|
||||
payload: dict[str, Any] = {
|
||||
"product_id": product_id,
|
||||
"success_url": success_url,
|
||||
}
|
||||
if customer_email:
|
||||
payload["customer"] = {"email": customer_email}
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base_url}/v1/checkouts",
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data: Any = resp.json()
|
||||
|
||||
return CreemCheckout(
|
||||
checkout_id=data.get("id", ""),
|
||||
checkout_url=data.get("checkout_url", ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
secret: str,
|
||||
) -> bool:
|
||||
"""Verify webhook signature using HMAC-SHA256."""
|
||||
expected = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
Reference in New Issue
Block a user