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:
zl-q
2026-05-11 18:38:21 +08:00
parent 3ff33640f4
commit f07e307e82
25 changed files with 989 additions and 45 deletions
@@ -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")