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,68 @@
|
||||
"""Create creem_transactions table for CREEM payment integration.
|
||||
|
||||
Revision ID: 20260511_0001
|
||||
Revises: 20260428_0004
|
||||
Create Date: 2026-05-11 00:01:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260511_0001"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260428_0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"creem_transactions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("product_code", sa.String(length=32), nullable=False),
|
||||
sa.Column("creem_product_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("checkout_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("order_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("customer_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("credits", sa.BigInteger(), nullable=False),
|
||||
sa.Column("amount_cents", sa.BigInteger(), nullable=False),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False),
|
||||
sa.Column("creem_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
|
||||
sa.Column("ledger_event_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.CheckConstraint("status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"),
|
||||
)
|
||||
op.create_index("ix_creem_transactions_user_created_at", "creem_transactions", ["user_id", sa.text("created_at DESC")])
|
||||
op.create_index("ix_creem_transactions_status_updated_at", "creem_transactions", ["status", sa.text("updated_at DESC")])
|
||||
_enable_service_only_rls("creem_transactions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_drop_service_only_rls("creem_transactions")
|
||||
op.drop_table("creem_transactions")
|
||||
|
||||
|
||||
def _enable_service_only_rls(table_name: str) -> None:
|
||||
for role in ["anon", "authenticated"]:
|
||||
for action in ["select", "insert", "update", "delete"]:
|
||||
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
|
||||
op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY")
|
||||
for role in ["anon", "authenticated"]:
|
||||
op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)")
|
||||
op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)")
|
||||
op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)")
|
||||
op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)")
|
||||
|
||||
|
||||
def _drop_service_only_rls(table_name: str) -> None:
|
||||
for role in ["anon", "authenticated"]:
|
||||
for action in ["select", "insert", "update", "delete"]:
|
||||
op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}")
|
||||
op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY")
|
||||
@@ -232,6 +232,13 @@ class AppleIapSettings(BaseModel):
|
||||
server_api_private_key: SecretStr | None = None
|
||||
|
||||
|
||||
class CreemSettings(BaseModel):
|
||||
api_key: SecretStr | None = None
|
||||
webhook_secret: SecretStr | None = None
|
||||
base_url: str = "https://test-api.creem.io"
|
||||
success_url: str = ""
|
||||
|
||||
|
||||
def _resolve_env_files() -> list[str]:
|
||||
"""Resolve env files in order: .env.local overrides .env"""
|
||||
current = Path(__file__).resolve()
|
||||
@@ -280,6 +287,7 @@ class Settings(BaseSettings):
|
||||
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
|
||||
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
|
||||
apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings)
|
||||
creem: CreemSettings = Field(default_factory=CreemSettings)
|
||||
feedback_report: FeedbackReportSettings = Field(
|
||||
default_factory=FeedbackReportSettings
|
||||
)
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
product_mappings:
|
||||
new_user_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.new_user_pack
|
||||
creem_product_id: prod_2x9LzVlR3ot1HLgbIZALPd
|
||||
credits: 60
|
||||
type: starter
|
||||
sort_order: 0
|
||||
enabled: true
|
||||
starter_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.starter_pack
|
||||
creem_product_id: prod_697ay0pXFXrBYEVC7HS0MR
|
||||
credits: 100
|
||||
type: regular
|
||||
sort_order: 10
|
||||
enabled: true
|
||||
popular_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.popular_pack
|
||||
creem_product_id: prod_5ivxlPnZWN6dIhnOxctThy
|
||||
credits: 210
|
||||
type: regular
|
||||
sort_order: 20
|
||||
enabled: true
|
||||
premium_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.premium_pack
|
||||
creem_product_id: prod_2L13k70jlpPYkdHhexHP2s
|
||||
credits: 415
|
||||
type: regular
|
||||
sort_order: 30
|
||||
|
||||
@@ -14,6 +14,9 @@ engine: AsyncEngine = create_async_engine(
|
||||
config.database_url,
|
||||
echo=config.runtime.sql_log_queries,
|
||||
pool_pre_ping=True,
|
||||
pool_size=3,
|
||||
max_overflow=0,
|
||||
pool_timeout=10,
|
||||
)
|
||||
|
||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Index,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class CreemTransactionStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
REFUNDED = "refunded"
|
||||
|
||||
|
||||
class CreemTransaction(TimestampMixin, Base):
|
||||
__tablename__ = "creem_transactions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status in ('pending', 'completed', 'failed', 'refunded')",
|
||||
name="ck_creem_transactions_status",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"checkout_id", name="uq_creem_transactions_checkout_id"
|
||||
),
|
||||
Index(
|
||||
"ix_creem_transactions_user_created_at",
|
||||
"user_id",
|
||||
text("created_at DESC"),
|
||||
),
|
||||
Index(
|
||||
"ix_creem_transactions_status_updated_at",
|
||||
"status",
|
||||
text("updated_at DESC"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
nullable=False,
|
||||
)
|
||||
product_code: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
creem_product_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
checkout_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
customer_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
credits: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
creem_payload: Mapped[dict[str, object]] = mapped_column(
|
||||
"creem_payload",
|
||||
JSONB(),
|
||||
nullable=False,
|
||||
server_default=text("'{}'::jsonb"),
|
||||
default=dict,
|
||||
)
|
||||
ledger_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.db import get_db
|
||||
from v1.payments.apple_verifier import AppleJwsVerifier
|
||||
from v1.payments.creem_client import CreemClient
|
||||
from v1.payments.creem_service import CreemService
|
||||
from v1.payments.repository import PaymentRepository
|
||||
from v1.payments.service import PaymentService
|
||||
from v1.points.repository import PointsRepository
|
||||
@@ -19,3 +21,14 @@ def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentServi
|
||||
points_repo=points_repo,
|
||||
verifier=verifier,
|
||||
)
|
||||
|
||||
|
||||
def get_creem_service(session: AsyncSession = Depends(get_db)) -> CreemService:
|
||||
payment_repo = PaymentRepository(session)
|
||||
points_repo = PointsRepository(session)
|
||||
client = CreemClient()
|
||||
return CreemService(
|
||||
payment_repo=payment_repo,
|
||||
points_repo=points_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.apple_iap_transaction import AppleIapTransaction
|
||||
from models.creem_transaction import CreemTransaction
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from models.user_points import UserPoints
|
||||
|
||||
@@ -84,5 +85,17 @@ class PaymentRepository:
|
||||
raise RuntimeError("Failed to upsert register bonus claim")
|
||||
return claim
|
||||
|
||||
async def get_creem_transaction_by_checkout_id(
|
||||
self, *, checkout_id: str
|
||||
) -> CreemTransaction | None:
|
||||
stmt = select(CreemTransaction).where(
|
||||
CreemTransaction.checkout_id == checkout_id
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def insert_creem_transaction(self, *, transaction: CreemTransaction) -> None:
|
||||
self._session.add(transaction)
|
||||
await self._session.flush()
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self._session.commit()
|
||||
|
||||
@@ -3,16 +3,19 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.payments.dependencies import get_payment_service
|
||||
from v1.payments.dependencies import get_creem_service, get_payment_service
|
||||
from v1.payments.schemas import (
|
||||
AppleServerNotificationRequest,
|
||||
CreateCheckoutRequest,
|
||||
CreateCheckoutResponse,
|
||||
VerifyTransactionRequest,
|
||||
VerifyTransactionResponse,
|
||||
)
|
||||
from v1.payments.service import PaymentService
|
||||
from v1.payments.creem_service import CreemService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -43,3 +46,34 @@ async def handle_apple_server_notification(
|
||||
) -> Response:
|
||||
await service.handle_server_notification(signed_payload=request.signed_payload)
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/creem/checkouts",
|
||||
response_model=CreateCheckoutResponse,
|
||||
)
|
||||
async def create_creem_checkout(
|
||||
request: CreateCheckoutRequest,
|
||||
service: Annotated[CreemService, Depends(get_creem_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> CreateCheckoutResponse:
|
||||
result = await service.create_checkout(
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email or "",
|
||||
product_code=request.product_code,
|
||||
)
|
||||
return CreateCheckoutResponse(
|
||||
checkoutId=result.checkout_id,
|
||||
checkoutUrl=result.checkout_url,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/creem/webhook", status_code=200)
|
||||
async def handle_creem_webhook(
|
||||
http_request: Request,
|
||||
service: Annotated[CreemService, Depends(get_creem_service)],
|
||||
) -> Response:
|
||||
signature = http_request.headers.get("creem-signature", "")
|
||||
payload = await http_request.body()
|
||||
await service.handle_webhook(payload=payload, signature=signature)
|
||||
return Response(status_code=200)
|
||||
|
||||
@@ -45,3 +45,16 @@ class AppleServerNotificationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
signed_payload: str = Field(alias="signedPayload", default="")
|
||||
|
||||
|
||||
class CreateCheckoutRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
product_code: str = Field(alias="productCode", min_length=1, max_length=32)
|
||||
|
||||
|
||||
class CreateCheckoutResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
checkout_id: str = Field(alias="checkoutId")
|
||||
checkout_url: str = Field(alias="checkoutUrl")
|
||||
|
||||
@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass(frozen=True)
|
||||
class ProductMapping:
|
||||
app_store_product_id: str
|
||||
creem_product_id: str | None
|
||||
credits: int
|
||||
type: str
|
||||
sort_order: int = 0
|
||||
@@ -58,7 +59,8 @@ def _load_product_mappings() -> dict[str, ProductMapping]:
|
||||
product_mappings: Any = raw.get("product_mappings", {})
|
||||
for code, entry in product_mappings.items():
|
||||
mappings[str(code)] = ProductMapping(
|
||||
app_store_product_id=str(entry["app_store_product_id"]),
|
||||
app_store_product_id=str(entry.get("app_store_product_id", "")),
|
||||
creem_product_id=str(entry["creem_product_id"]) if entry.get("creem_product_id") else None,
|
||||
credits=int(entry["credits"]),
|
||||
type=str(entry["type"]),
|
||||
sort_order=int(entry.get("sort_order", 0)),
|
||||
|
||||
@@ -3,10 +3,18 @@ from __future__ import annotations
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config.settings import config
|
||||
from core.db import get_db
|
||||
from v1.payments.creem_client import CreemClient
|
||||
from v1.points.repository import PointsRepository
|
||||
from v1.points.service import PointsService
|
||||
|
||||
|
||||
def get_points_service(session: AsyncSession = Depends(get_db)) -> PointsService:
|
||||
return PointsService(repository=PointsRepository(session))
|
||||
creem_client: CreemClient | None = None
|
||||
if config.creem.api_key:
|
||||
creem_client = CreemClient()
|
||||
return PointsService(
|
||||
repository=PointsRepository(session),
|
||||
creem_client=creem_client,
|
||||
)
|
||||
|
||||
@@ -67,11 +67,14 @@ async def get_available_packages(
|
||||
PackageInfo(
|
||||
productCode=pkg.product_code,
|
||||
appStoreProductId=pkg.app_store_product_id,
|
||||
creemProductId=pkg.creem_product_id,
|
||||
type=pkg.type,
|
||||
credits=pkg.credits,
|
||||
isStarter=pkg.is_starter,
|
||||
starterEligible=pkg.starter_eligible,
|
||||
sortOrder=pkg.sort_order,
|
||||
priceCents=pkg.price_cents,
|
||||
currency=pkg.currency,
|
||||
)
|
||||
for pkg in result.packages
|
||||
],
|
||||
|
||||
@@ -19,14 +19,19 @@ class PackageInfo(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
|
||||
app_store_product_id: str = Field(
|
||||
alias="appStoreProductId", min_length=1, max_length=256
|
||||
app_store_product_id: str | None = Field(
|
||||
alias="appStoreProductId", default=None, min_length=1, max_length=256
|
||||
)
|
||||
creem_product_id: str | None = Field(
|
||||
alias="creemProductId", default=None, min_length=1, max_length=256
|
||||
)
|
||||
type: Literal["starter", "regular"]
|
||||
credits: int = Field(ge=1)
|
||||
is_starter: bool = Field(alias="isStarter")
|
||||
starter_eligible: bool = Field(alias="starterEligible")
|
||||
sort_order: int = Field(alias="sortOrder", ge=0)
|
||||
price_cents: int | None = Field(alias="priceCents", default=None, ge=0)
|
||||
currency: str | None = Field(alias="currency", default=None, min_length=3, max_length=8)
|
||||
|
||||
|
||||
class PackagesResponse(BaseModel):
|
||||
|
||||
@@ -20,6 +20,8 @@ from schemas.domain.points import (
|
||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||
from schemas.domain.points import ApplyPointsChangeCommand
|
||||
from v1.payments.service import _load_product_mappings
|
||||
from v1.payments.creem_service import _load_creem_product_mappings
|
||||
from v1.payments.creem_client import CreemClient
|
||||
from v1.points.repository import PointsRepository
|
||||
from v1.points.schemas import LedgerItem
|
||||
|
||||
@@ -65,12 +67,15 @@ class RegisterBonusResult:
|
||||
@dataclass(frozen=True)
|
||||
class PackageInfoResult:
|
||||
product_code: str
|
||||
app_store_product_id: str
|
||||
app_store_product_id: str | None
|
||||
creem_product_id: str | None
|
||||
type: Literal["starter", "regular"]
|
||||
credits: int
|
||||
sort_order: int
|
||||
is_starter: bool
|
||||
starter_eligible: bool
|
||||
price_cents: int | None = None
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -79,8 +84,13 @@ class PackagesResult:
|
||||
|
||||
|
||||
class PointsService:
|
||||
def __init__(self, repository: PointsRepository) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
repository: PointsRepository,
|
||||
creem_client: CreemClient | None = None,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._creem_client = creem_client
|
||||
|
||||
async def grant_register_bonus_if_eligible(
|
||||
self,
|
||||
@@ -453,6 +463,17 @@ class PointsService:
|
||||
)
|
||||
|
||||
product_mappings = _load_product_mappings()
|
||||
creem_mappings = _load_creem_product_mappings()
|
||||
|
||||
creem_prices: dict[str, tuple[int, str]] = {}
|
||||
if self._creem_client:
|
||||
try:
|
||||
products = await self._creem_client.get_products()
|
||||
creem_prices = {
|
||||
p.product_id: (p.price_cents, p.currency) for p in products
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
packages: list[PackageInfoResult] = []
|
||||
for product_code, mapping in product_mappings.items():
|
||||
@@ -464,15 +485,25 @@ class PointsService:
|
||||
if pkg_type == "starter" and has_starter:
|
||||
continue
|
||||
|
||||
creem_mapping = creem_mappings.get(product_code)
|
||||
creem_product_id = creem_mapping.creem_product_id if creem_mapping else None
|
||||
price_cents: int | None = None
|
||||
currency: str | None = None
|
||||
if creem_product_id and creem_product_id in creem_prices:
|
||||
price_cents, currency = creem_prices[creem_product_id]
|
||||
|
||||
packages.append(
|
||||
PackageInfoResult(
|
||||
product_code=product_code,
|
||||
app_store_product_id=mapping.app_store_product_id,
|
||||
creem_product_id=creem_product_id,
|
||||
type=pkg_type,
|
||||
credits=mapping.credits,
|
||||
sort_order=mapping.sort_order,
|
||||
is_starter=pkg_type == "starter",
|
||||
starter_eligible=(pkg_type == "starter" and not has_starter),
|
||||
price_cents=price_cents,
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user