Files
eryao/backend/src/v1/payments/creem_service.py
T
2026-05-21 16:26:58 +08:00

383 lines
13 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import yaml
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from models.creem_transaction import CreemTransaction
from schemas.domain.points import (
ApplyPointsChangeCommand,
PurchaseLedgerMetadata,
)
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from v1.payments.creem_client import CreemClient, CreemProduct
from v1.payments.repository import PaymentRepository
from v1.points.invite_rewards import grant_invite_rewards_for_creem_payment
from v1.points.repository import PointsRepository
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CreemProductMapping:
creem_product_id: str
credits: int
type: str
sort_order: int = 0
enabled: bool = True
_creem_product_mappings_cache: dict[str, CreemProductMapping] | None = None
def _load_creem_product_mappings() -> dict[str, CreemProductMapping]:
global _creem_product_mappings_cache
if _creem_product_mappings_cache is not None:
return _creem_product_mappings_cache
mapping_path = (
Path(__file__).parent.parent.parent / "core/config/static/packages/mapping.yaml"
)
with mapping_path.open("r", encoding="utf-8") as f:
raw: Any = yaml.safe_load(f) or {}
mappings: dict[str, CreemProductMapping] = {}
product_mappings: Any = raw.get("product_mappings", {})
for code, entry in product_mappings.items():
if entry.get("creem_product_id"):
mappings[str(code)] = CreemProductMapping(
creem_product_id=str(entry["creem_product_id"]),
credits=int(entry["credits"]),
type=str(entry["type"]),
sort_order=int(entry.get("sort_order", 0)),
enabled=bool(entry.get("enabled", True)),
)
_creem_product_mappings_cache = mappings
return mappings
def clear_creem_product_mappings_cache() -> None:
global _creem_product_mappings_cache
_creem_product_mappings_cache = None
@dataclass(frozen=True)
class PackageWithPrice:
product_code: str
creem_product_id: str
credits: int
type: str
sort_order: int
price_cents: int
currency: str
@dataclass(frozen=True)
class CreateCheckoutResult:
checkout_id: str
checkout_url: str
class CreemService:
def __init__(
self,
*,
payment_repo: PaymentRepository,
points_repo: PointsRepository,
client: CreemClient,
) -> None:
self._payment_repo: PaymentRepository = payment_repo
self._points_repo: PointsRepository = points_repo
self._client: CreemClient = client
async def get_packages_with_prices(self) -> list[PackageWithPrice]:
"""Get all packages with dynamic prices from CREEM API."""
mappings = _load_creem_product_mappings()
products = await self._client.get_products()
product_by_id: dict[str, CreemProduct] = {p.product_id: p for p in products}
result: list[PackageWithPrice] = []
for code, mapping in mappings.items():
if not mapping.enabled:
continue
product = product_by_id.get(mapping.creem_product_id)
if product is None:
logger.warning(
"CREEM product not found: code=%s product_id=%s",
code,
mapping.creem_product_id,
)
continue
result.append(
PackageWithPrice(
product_code=code,
creem_product_id=mapping.creem_product_id,
credits=mapping.credits,
type=mapping.type,
sort_order=mapping.sort_order,
price_cents=product.price_cents,
currency=product.currency,
)
)
result.sort(key=lambda p: p.sort_order)
return result
async def create_checkout(
self,
*,
user_id: UUID,
user_email: str,
product_code: str,
) -> CreateCheckoutResult:
"""Create a CREEM checkout session."""
mappings = _load_creem_product_mappings()
mapping = mappings.get(product_code)
if mapping is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PAYMENT_PRODUCT_NOT_FOUND",
detail=f"Product not found: {product_code}",
),
)
is_starter = mapping.type == "starter"
normalized_email = user_email.strip().lower()
email_hash = (
self._build_email_hash(normalized_email) if normalized_email else None
)
if is_starter:
if not email_hash:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="PAYMENT_STARTER_PACK_INELIGIBLE",
detail="Email required for starter pack purchase",
),
)
claim = await self._payment_repo.get_register_bonus_claim(
email_hash=email_hash
)
if claim is not None and claim.has_purchased_starter_pack:
raise ApiProblemError(
status_code=409,
detail=problem_payload(
code="PAYMENT_STARTER_PACK_INELIGIBLE",
detail="Starter pack already purchased for this email",
),
)
product = await self._client.get_product(mapping.creem_product_id)
if product is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="PAYMENT_PRODUCT_NOT_FOUND",
detail=f"CREEM product not found: {mapping.creem_product_id}",
),
)
success_url = config.creem.success_url
checkout = await self._client.create_checkout(
product_id=mapping.creem_product_id,
success_url=success_url,
customer_email=normalized_email or None,
metadata={
"user_id": str(user_id),
"product_code": product_code,
},
)
transaction = CreemTransaction(
id=uuid4(),
user_id=user_id,
product_code=product_code,
creem_product_id=mapping.creem_product_id,
checkout_id=checkout.checkout_id,
status="pending",
credits=mapping.credits,
amount_cents=product.price_cents,
currency=product.currency,
creem_payload={"checkout_url": checkout.checkout_url},
)
await self._payment_repo.insert_creem_transaction(transaction=transaction)
await self._payment_repo.commit()
logger.info(
"CREEM checkout created: user_id=%s product_code=%s checkout_id=%s",
user_id,
product_code,
checkout.checkout_id,
)
return CreateCheckoutResult(
checkout_id=checkout.checkout_id,
checkout_url=checkout.checkout_url,
)
async def handle_webhook(
self,
*,
payload: bytes,
signature: str,
) -> None:
"""Handle CREEM webhook notification."""
settings = config.creem
secret = settings.webhook_secret
if secret is None:
logger.error("CREEM webhook_secret not configured")
return
secret_value = secret.get_secret_value()
if not CreemClient.verify_webhook_signature(payload, signature, secret_value):
logger.warning("CREEM webhook signature verification failed")
return
try:
event: Any = json.loads(payload)
except json.JSONDecodeError:
logger.warning("CREEM webhook payload is not valid JSON")
return
event_type = event.get("eventType", "")
obj = event.get("object", {})
if event_type == "checkout.completed":
await self._handle_checkout_completed(obj)
async def _handle_checkout_completed(self, obj: dict[str, Any]) -> None:
# CREEM webhook structure: checkout_id is in "id", order_id in "order.id", customer_id in "customer.id"
checkout_id = obj.get("id", "")
order_obj = obj.get("order", {})
order_id = order_obj.get("id") if isinstance(order_obj, dict) else None
customer_obj = obj.get("customer", {})
customer_id = customer_obj.get("id") if isinstance(customer_obj, dict) else None
customer_email = (
customer_obj.get("email", "") if isinstance(customer_obj, dict) else ""
)
txn = await self._payment_repo.get_creem_transaction_by_checkout_id(
checkout_id=checkout_id
)
if txn is None:
logger.warning(
"CREEM checkout.completed for unknown checkout_id: %s",
checkout_id,
)
return
if txn.status == "completed":
logger.info(
"CREEM checkout already completed: checkout_id=%s",
checkout_id,
)
return
user_id = txn.user_id
credits = txn.credits
account = await self._payment_repo.get_or_create_user_points_for_update(
user_id=user_id
)
balance = int(account.balance)
new_balance = balance + credits
account.balance = new_balance
account.lifetime_earned = int(account.lifetime_earned) + credits
account.version = int(account.version) + 1
event_id = f"payment.creem:{checkout_id}"
metadata_obj = PurchaseLedgerMetadata(
operator_type=PointsOperatorType.SYSTEM,
run_id=event_id,
ext={
"source": "creem",
"platform": "web",
"product_code": txn.product_code,
"transaction_id": checkout_id,
"creem_product_id": txn.creem_product_id,
"order_id": order_id or "",
"customer_id": customer_id or "",
"creem_transaction_id": str(txn.id),
},
)
ledger_command = ApplyPointsChangeCommand(
user_id=user_id,
change_type=PointsChangeType.PURCHASE,
biz_type=PointsBizType.PAYMENT,
biz_id=txn.id,
event_id=event_id,
amount=credits,
direction=1,
operator_id=None,
metadata=metadata_obj,
)
await self._points_repo.append_ledger(
command=ledger_command,
balance_after=new_balance,
)
txn.order_id = order_id
txn.customer_id = customer_id
txn.status = "completed"
txn.ledger_event_id = event_id
txn.creem_payload = obj
paid_at = datetime.now(timezone.utc)
logger.info(
"CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d",
user_id,
checkout_id,
credits,
new_balance,
)
await grant_invite_rewards_for_creem_payment(
repository=self._points_repo,
invitee_user_id=user_id,
invitee_email=str(customer_email),
creem_transaction_id=txn.id,
paid_at=paid_at,
)
mappings = _load_creem_product_mappings()
mapping = mappings.get(txn.product_code)
if mapping and mapping.type == "starter":
normalized_email = str(customer_email).strip().lower()
if normalized_email:
email_hash = self._build_email_hash(normalized_email)
_ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack(
email_hash=email_hash,
user_email_snapshot=normalized_email,
first_user_id_snapshot=user_id,
)
await self._payment_repo.commit()
@staticmethod
def _build_email_hash(normalized_email: str) -> str:
key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
digest = hmac.new(
key=key.encode("utf-8"),
msg=normalized_email.encode("utf-8"),
digestmod=hashlib.sha256,
)
return digest.hexdigest()