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,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
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.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
|
||||
metadata = obj.get("metadata", {})
|
||||
|
||||
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
|
||||
|
||||
logger.info(
|
||||
"CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d",
|
||||
user_id,
|
||||
checkout_id,
|
||||
credits,
|
||||
new_balance,
|
||||
)
|
||||
|
||||
mappings = _load_creem_product_mappings()
|
||||
mapping = mappings.get(txn.product_code)
|
||||
if mapping and mapping.type == "starter":
|
||||
user_email = obj.get("customer", {}).get("email", "")
|
||||
normalized_email = user_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()
|
||||
Reference in New Issue
Block a user