feat: 实现 iOS Apple Pay 内购支付功能
前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
"""add apple_iap_transactions table and update points_ledger constraints
|
||||
|
||||
Revision ID: 20260427_0001
|
||||
Revises: 20260417_0001
|
||||
Create Date: 2026-04-27 12:00:00
|
||||
|
||||
Changes:
|
||||
1. Create apple_iap_transactions table for Apple IAP payment tracking
|
||||
2. Update points_ledger check constraints:
|
||||
- Remove 'grant' from change_type (merged into 'adjust')
|
||||
- Add 'purchase' and 'refund' to change_type
|
||||
- Add 'payment' to biz_type
|
||||
- Update biz_binding constraint for new types
|
||||
- Update direction_by_change_type constraint
|
||||
- Add metadata shape constraints for purchase/refund
|
||||
- Update adjust metadata constraint (ticket_id -> reason)
|
||||
3. Update points_audit_ledger check constraints similarly
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260427_0001"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260417_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"apple_iap_transactions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("product_code", sa.String(32), nullable=False),
|
||||
sa.Column("app_store_product_id", sa.String(128), nullable=False),
|
||||
sa.Column("transaction_id", sa.String(64), nullable=False),
|
||||
sa.Column("original_transaction_id", sa.String(64), nullable=True),
|
||||
sa.Column("web_order_line_item_id", sa.String(64), nullable=True),
|
||||
sa.Column("environment", sa.String(16), nullable=False),
|
||||
sa.Column("bundle_id", sa.String(128), nullable=False),
|
||||
sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("purchase_date", sa.Text, nullable=False),
|
||||
sa.Column("revocation_date", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(24), nullable=False),
|
||||
sa.Column("credits", sa.BigInteger, nullable=False),
|
||||
sa.Column("currency", sa.String(8), nullable=True),
|
||||
sa.Column("price_milliunits", sa.BigInteger, nullable=True),
|
||||
sa.Column("ledger_event_id", sa.String(64), nullable=True),
|
||||
sa.Column("signed_transaction_info", sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
"apple_payload",
|
||||
postgresql.JSONB(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("failure_code", sa.String(64), 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(
|
||||
"environment in ('Sandbox', 'Production')",
|
||||
name="ck_apple_iap_transactions_environment",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
|
||||
name="ck_apple_iap_transactions_status",
|
||||
),
|
||||
sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"),
|
||||
sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"),
|
||||
)
|
||||
|
||||
op.create_index(
|
||||
"ix_apple_iap_transactions_user_created_at",
|
||||
"apple_iap_transactions",
|
||||
["user_id", sa.text("created_at DESC")],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_apple_iap_transactions_status_updated_at",
|
||||
"apple_iap_transactions",
|
||||
["status", sa.text("updated_at DESC")],
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE apple_iap_transactions ENABLE ROW LEVEL SECURITY")
|
||||
|
||||
op.execute(
|
||||
"CREATE POLICY anon_select_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR SELECT TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (true)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_update_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR UPDATE TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_delete_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR DELETE TO anon USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_select_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR SELECT TO authenticated USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (true)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_update_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR UPDATE TO authenticated USING (false)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_delete_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR DELETE TO authenticated USING (false)"
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_change_type",
|
||||
"points_ledger",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_type",
|
||||
"points_ledger",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_binding",
|
||||
"points_ledger",
|
||||
"((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or "
|
||||
"(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or "
|
||||
"(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_direction_by_change_type",
|
||||
"points_ledger",
|
||||
"((change_type in ('register', 'purchase') and direction = 1) or "
|
||||
"(change_type in ('consume', 'refund') and direction = -1) or "
|
||||
"(change_type = 'adjust' and direction in (1, -1)))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_adjust_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'adjust' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'reason') and "
|
||||
"coalesce(metadata #>> '{ext,reason}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_payment_shape",
|
||||
"points_ledger",
|
||||
"(change_type not in ('purchase', 'refund') or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and "
|
||||
"(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and "
|
||||
"coalesce(metadata #>> '{ext,source}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,platform}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,product_code}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_refund_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'refund' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and "
|
||||
"coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_change_type",
|
||||
"points_audit_ledger",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_biz_type",
|
||||
"points_audit_ledger",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_points_audit_ledger_biz_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_biz_type",
|
||||
"points_audit_ledger",
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_audit_ledger_change_type", "points_audit_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_audit_ledger_change_type",
|
||||
"points_audit_ledger",
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_refund_shape", "points_ledger", type_="check")
|
||||
op.drop_constraint("ck_points_ledger_metadata_payment_shape", "points_ledger", type_="check")
|
||||
|
||||
op.drop_constraint("ck_points_ledger_metadata_adjust_shape", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_metadata_adjust_shape",
|
||||
"points_ledger",
|
||||
"(change_type <> 'adjust' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
|
||||
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_direction_by_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_direction_by_change_type",
|
||||
"points_ledger",
|
||||
"((change_type in ('register', 'grant') and direction = 1) or "
|
||||
"(change_type = 'consume' and direction = -1) or "
|
||||
"(change_type = 'adjust' and direction in (1, -1)))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_binding", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_binding",
|
||||
"points_ledger",
|
||||
"((change_type = 'register' and biz_type is null and biz_id is null) or "
|
||||
"(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_biz_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_biz_type",
|
||||
"points_ledger",
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
)
|
||||
|
||||
op.drop_constraint("ck_points_ledger_change_type", "points_ledger", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_points_ledger_change_type",
|
||||
"points_ledger",
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
)
|
||||
|
||||
op.drop_index("ix_apple_iap_transactions_status_updated_at", table_name="apple_iap_transactions")
|
||||
op.drop_index("ix_apple_iap_transactions_user_created_at", table_name="apple_iap_transactions")
|
||||
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_delete_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_update_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS authenticated_select_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_delete_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_update_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions")
|
||||
op.execute("DROP POLICY IF EXISTS anon_select_apple_iap_transactions ON apple_iap_transactions")
|
||||
|
||||
op.execute("ALTER TABLE apple_iap_transactions DISABLE ROW LEVEL SECURITY")
|
||||
|
||||
op.drop_table("apple_iap_transactions")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""fix apple_iap_transactions RLS policies for INSERT
|
||||
|
||||
Revision ID: 20260427_0002
|
||||
Revises: 20260427_0001
|
||||
Create Date: 2026-04-27 18:00:00
|
||||
|
||||
Changes:
|
||||
1. Fix anon_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
|
||||
2. Fix authenticated_insert_apple_iap_transactions: WITH CHECK (true) -> WITH CHECK (false)
|
||||
|
||||
Rationale:
|
||||
Apple IAP transactions should only be created by backend service_role,
|
||||
not by client anon/authenticated users. The original policies allowed
|
||||
unrestricted INSERT which bypasses RLS security.
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260427_0002"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260427_0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (false)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (false)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS authenticated_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY authenticated_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO authenticated WITH CHECK (true)"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"DROP POLICY IF EXISTS anon_insert_apple_iap_transactions ON apple_iap_transactions"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE POLICY anon_insert_apple_iap_transactions ON apple_iap_transactions "
|
||||
"FOR INSERT TO anon WITH CHECK (true)"
|
||||
)
|
||||
@@ -356,7 +356,7 @@ class AgentScopeRunner:
|
||||
) -> TrackingChatModel:
|
||||
generate_kwargs: dict[str, Any] = {
|
||||
"timeout": stage_config.llm_config.timeout_seconds,
|
||||
"extra_body": {"enable_thinking": False},
|
||||
"extra_body": {"thinking": {"type": "disabled"}},
|
||||
}
|
||||
if stage_config.llm_config.temperature is not None:
|
||||
generate_kwargs["temperature"] = stage_config.llm_config.temperature
|
||||
|
||||
@@ -228,6 +228,18 @@ class PointsPolicySettings(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class AppleIapSettings(BaseModel):
|
||||
bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1)
|
||||
root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer"
|
||||
jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys"
|
||||
server_api_issuer_id: str | None = None
|
||||
server_api_key_id: str | None = None
|
||||
server_api_private_key: SecretStr | None = None
|
||||
sandbox_tester_email: str | None = None
|
||||
sandbox_tester_password: SecretStr | None = None
|
||||
server_notifications_url: str | None = None
|
||||
|
||||
|
||||
def _resolve_env_file() -> str:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
@@ -271,6 +283,7 @@ class Settings(BaseSettings):
|
||||
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
|
||||
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
|
||||
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
|
||||
apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings)
|
||||
feedback_report: FeedbackReportSettings = Field(
|
||||
default_factory=FeedbackReportSettings
|
||||
)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
agents:
|
||||
- agent_type: router
|
||||
llm_model_code: qwen3.5-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: null
|
||||
timeout_seconds: 30
|
||||
context_messages:
|
||||
mode: day
|
||||
count: 2
|
||||
enabled_tools: []
|
||||
- agent_type: router
|
||||
llm_model_code: qwen3.5-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: null
|
||||
timeout_seconds: 30
|
||||
context_messages:
|
||||
mode: day
|
||||
count: 2
|
||||
enabled_tools: []
|
||||
|
||||
- agent_type: worker
|
||||
llm_model_code: deepseek-chat
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: 2048
|
||||
timeout_seconds: 120
|
||||
context_messages:
|
||||
mode: number
|
||||
count: 20
|
||||
enabled_tools: []
|
||||
- agent_type: worker
|
||||
llm_model_code: deepseek-v4-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
max_tokens: 2048
|
||||
timeout_seconds: 120
|
||||
context_messages:
|
||||
mode: number
|
||||
count: 20
|
||||
enabled_tools: []
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
product_mappings:
|
||||
new_user_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.new_user_pack
|
||||
credits: 60
|
||||
type: starter
|
||||
basic_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.basic_pack
|
||||
credits: 100
|
||||
type: regular
|
||||
popular_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.popular_pack
|
||||
credits: 210
|
||||
type: regular
|
||||
premium_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.premium_pack
|
||||
credits: 415
|
||||
type: regular
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from .agent_chat_message import AgentChatMessage
|
||||
from .agent_chat_session import AgentChatSession
|
||||
from .anonymous_session_snapshot import AnonymousSessionSnapshot
|
||||
from .apple_iap_transaction import AppleIapTransaction
|
||||
from .auth_user import AuthUser
|
||||
from .invite_code import InviteCode
|
||||
from .llm import Llm
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"AgentChatMessage",
|
||||
"AgentChatSession",
|
||||
"AnonymousSessionSnapshot",
|
||||
"AppleIapTransaction",
|
||||
"AuthUser",
|
||||
"InviteCode",
|
||||
"Llm",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Index,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class AppleIapTransaction(TimestampMixin, Base):
|
||||
__tablename__ = "apple_iap_transactions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"environment in ('Sandbox', 'Production')",
|
||||
name="ck_apple_iap_transactions_environment",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')",
|
||||
name="ck_apple_iap_transactions_status",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"transaction_id", name="uq_apple_iap_transactions_transaction_id"
|
||||
),
|
||||
UniqueConstraint(
|
||||
"ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"
|
||||
),
|
||||
Index(
|
||||
"ix_apple_iap_transactions_user_created_at",
|
||||
"user_id",
|
||||
text("created_at DESC"),
|
||||
),
|
||||
Index(
|
||||
"ix_apple_iap_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)
|
||||
app_store_product_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
transaction_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
original_transaction_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
web_order_line_item_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
environment: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
bundle_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
app_account_token: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True
|
||||
)
|
||||
purchase_date: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
)
|
||||
revocation_date: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
credits: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
currency: Mapped[str | None] = mapped_column(String(8), nullable=True)
|
||||
price_milliunits: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
ledger_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
signed_transaction_info: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
apple_payload_json: Mapped[dict[str, object]] = mapped_column(
|
||||
"apple_payload",
|
||||
JSON().with_variant(JSONB, "postgresql"),
|
||||
nullable=False,
|
||||
server_default=text("'{}'::jsonb"),
|
||||
default=dict,
|
||||
)
|
||||
failure_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -36,11 +36,11 @@ class PointsAuditLedger(TimestampMixin, Base):
|
||||
name="ck_points_audit_ledger_balance_after_non_negative",
|
||||
),
|
||||
CheckConstraint(
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
name="ck_points_audit_ledger_change_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
name="ck_points_audit_ledger_biz_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
|
||||
@@ -29,21 +29,22 @@ class PointsLedger(TimestampMixin, Base):
|
||||
"balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"
|
||||
),
|
||||
CheckConstraint(
|
||||
"change_type in ('register', 'consume', 'grant', 'adjust')",
|
||||
"change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')",
|
||||
name="ck_points_ledger_change_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"biz_type is null or biz_type = 'chat'",
|
||||
"biz_type is null or biz_type in ('chat', 'payment')",
|
||||
name="ck_points_ledger_biz_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"((change_type = 'register' and biz_type is null and biz_id is null) or "
|
||||
"(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))",
|
||||
"((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or "
|
||||
"(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or "
|
||||
"(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))",
|
||||
name="ck_points_ledger_biz_binding",
|
||||
),
|
||||
CheckConstraint(
|
||||
"((change_type in ('register', 'grant') and direction = 1) or "
|
||||
"(change_type = 'consume' and direction = -1) or "
|
||||
"((change_type in ('register', 'purchase') and direction = 1) or "
|
||||
"(change_type in ('consume', 'refund') and direction = -1) or "
|
||||
"(change_type = 'adjust' and direction in (1, -1)))",
|
||||
name="ck_points_ledger_direction_by_change_type",
|
||||
),
|
||||
@@ -72,10 +73,26 @@ class PointsLedger(TimestampMixin, Base):
|
||||
),
|
||||
CheckConstraint(
|
||||
"(change_type <> 'adjust' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and "
|
||||
"coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))",
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'reason') and "
|
||||
"coalesce(metadata #>> '{ext,reason}', '') <> ''))",
|
||||
name="ck_points_ledger_metadata_adjust_shape",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(change_type not in ('purchase', 'refund') or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and "
|
||||
"(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and "
|
||||
"coalesce(metadata #>> '{ext,source}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,platform}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,product_code}', '') <> '' and "
|
||||
"coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))",
|
||||
name="ck_points_ledger_metadata_payment_shape",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(change_type <> 'refund' or ("
|
||||
"(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and "
|
||||
"coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))",
|
||||
name="ck_points_ledger_metadata_refund_shape",
|
||||
),
|
||||
UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"),
|
||||
Index("ix_points_ledger_user_created_at", "user_id", text("created_at DESC")),
|
||||
Index("ix_points_ledger_biz_type_biz_id", "biz_type", "biz_id"),
|
||||
|
||||
@@ -43,26 +43,26 @@ class ConsumeLedgerMetadata(PointsLedgerMetadataBase):
|
||||
charge: PointsChargeSnapshot
|
||||
|
||||
|
||||
class GrantLedgerMetadata(PointsLedgerMetadataBase):
|
||||
charge: PointsChargeSnapshot | None = None
|
||||
|
||||
|
||||
class AdjustLedgerMetadata(PointsLedgerMetadataBase):
|
||||
charge: PointsChargeSnapshot | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_ticket(self) -> "AdjustLedgerMetadata":
|
||||
ticket_id = self.ext.get("ticket_id")
|
||||
if not isinstance(ticket_id, str) or not ticket_id.strip():
|
||||
raise ValueError("ext.ticket_id is required for adjust")
|
||||
def validate_reason(self) -> "AdjustLedgerMetadata":
|
||||
reason = self.ext.get("reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError("ext.reason is required for adjust")
|
||||
return self
|
||||
|
||||
|
||||
class PurchaseLedgerMetadata(PointsLedgerMetadataBase):
|
||||
pass
|
||||
|
||||
|
||||
PointsLedgerMetadata = (
|
||||
RegisterLedgerMetadata
|
||||
| ConsumeLedgerMetadata
|
||||
| GrantLedgerMetadata
|
||||
| AdjustLedgerMetadata
|
||||
| PurchaseLedgerMetadata
|
||||
)
|
||||
|
||||
|
||||
@@ -75,8 +75,6 @@ def parse_points_ledger_metadata(
|
||||
return RegisterLedgerMetadata.model_validate(payload)
|
||||
if change_type == PointsChangeType.CONSUME:
|
||||
return ConsumeLedgerMetadata.model_validate(payload)
|
||||
if change_type == PointsChangeType.GRANT:
|
||||
return GrantLedgerMetadata.model_validate(payload)
|
||||
return AdjustLedgerMetadata.model_validate(payload)
|
||||
|
||||
|
||||
@@ -114,17 +112,29 @@ class ApplyPointsChangeCommand(BaseModel):
|
||||
raise ValueError("consume must use direction=-1 and chat binding")
|
||||
return self
|
||||
|
||||
if self.change_type == PointsChangeType.GRANT:
|
||||
if (
|
||||
self.direction != 1
|
||||
or self.biz_type != PointsBizType.CHAT
|
||||
or self.biz_id is None
|
||||
):
|
||||
raise ValueError("grant must use direction=1 and chat binding")
|
||||
if self.change_type == PointsChangeType.ADJUST:
|
||||
if self.biz_type is not None or self.biz_id is not None:
|
||||
raise ValueError("adjust must not have biz binding")
|
||||
return self
|
||||
|
||||
if self.change_type == PointsChangeType.PURCHASE:
|
||||
if (
|
||||
self.direction != 1
|
||||
or self.biz_type != PointsBizType.PAYMENT
|
||||
or self.biz_id is None
|
||||
):
|
||||
raise ValueError("purchase must use direction=1 and payment binding")
|
||||
return self
|
||||
|
||||
if self.change_type == PointsChangeType.REFUND:
|
||||
if (
|
||||
self.direction != -1
|
||||
or self.biz_type != PointsBizType.PAYMENT
|
||||
or self.biz_id is None
|
||||
):
|
||||
raise ValueError("refund must use direction=-1 and payment binding")
|
||||
return self
|
||||
|
||||
if self.biz_type != PointsBizType.CHAT or self.biz_id is None:
|
||||
raise ValueError("adjust must use chat binding")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -69,12 +69,14 @@ class SessionType(str, Enum):
|
||||
class PointsChangeType(str, Enum):
|
||||
REGISTER = "register"
|
||||
CONSUME = "consume"
|
||||
GRANT = "grant"
|
||||
ADJUST = "adjust"
|
||||
PURCHASE = "purchase"
|
||||
REFUND = "refund"
|
||||
|
||||
|
||||
class PointsBizType(str, Enum):
|
||||
CHAT = "chat"
|
||||
PAYMENT = "payment"
|
||||
|
||||
|
||||
class PointsOperatorType(str, Enum):
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.x509 import load_der_x509_certificate
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey)
|
||||
|
||||
_APPLE_ROOT_CA_G3_FINGERPRINT = (
|
||||
"0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTransaction:
|
||||
transaction_id: str
|
||||
original_transaction_id: str
|
||||
web_order_line_item_id: str | None
|
||||
bundle_id: str
|
||||
product_id: str
|
||||
purchase_date: int
|
||||
revocation_date: int | None
|
||||
environment: str
|
||||
app_account_token: str | None
|
||||
raw_payload: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationError:
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
class AppleJwsVerifier:
|
||||
def verify_signed_transaction(
|
||||
self,
|
||||
signed_transaction_info: str,
|
||||
*,
|
||||
expected_bundle_id: str,
|
||||
expected_product_id: str,
|
||||
expected_environment: str,
|
||||
) -> VerifiedTransaction | VerificationError:
|
||||
try:
|
||||
unverified_header = jwt.get_unverified_header(signed_transaction_info)
|
||||
except jwt.exceptions.DecodeError:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="Failed to decode JWS header",
|
||||
)
|
||||
|
||||
x5c_raw = unverified_header.get("x5c")
|
||||
if not x5c_raw or not isinstance(x5c_raw, list) or len(x5c_raw) < 3:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="JWS x5c chain missing or incomplete",
|
||||
)
|
||||
|
||||
x5c: list[str] = x5c_raw
|
||||
|
||||
root_der = base64.b64decode(x5c[-1])
|
||||
root_fingerprint = hashlib.sha1(root_der).hexdigest().lower()
|
||||
if root_fingerprint != _APPLE_ROOT_CA_G3_FINGERPRINT:
|
||||
logger.warning(
|
||||
"Apple root cert fingerprint mismatch: expected=%s got=%s",
|
||||
_APPLE_ROOT_CA_G3_FINGERPRINT,
|
||||
root_fingerprint,
|
||||
)
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="Apple root certificate fingerprint mismatch",
|
||||
)
|
||||
|
||||
chain_error = self._verify_cert_chain_issuer_subject(x5c)
|
||||
if chain_error is not None:
|
||||
return chain_error
|
||||
|
||||
cert_der = base64.b64decode(x5c[0])
|
||||
cert = load_der_x509_certificate(cert_der)
|
||||
public_key = cert.public_key()
|
||||
if not isinstance(public_key, _ALLOWED_KEY_TYPES):
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="Unsupported certificate key type",
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
signed_transaction_info,
|
||||
public_key,
|
||||
algorithms=["ES256"],
|
||||
options={
|
||||
"verify_exp": False,
|
||||
"verify_aud": False,
|
||||
"verify_iss": False,
|
||||
"verify_sub": False,
|
||||
},
|
||||
)
|
||||
except jwt.exceptions.InvalidSignatureError:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="JWS signature verification failed",
|
||||
)
|
||||
except jwt.exceptions.DecodeError:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="JWS payload decode failed",
|
||||
)
|
||||
|
||||
bundle_id: str = str(payload.get("bundleId", ""))
|
||||
if bundle_id != expected_bundle_id:
|
||||
return VerificationError(
|
||||
code="PAYMENT_PRODUCT_MISMATCH",
|
||||
detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}",
|
||||
)
|
||||
|
||||
product_id: str = str(payload.get("productId", ""))
|
||||
if product_id != expected_product_id:
|
||||
return VerificationError(
|
||||
code="PAYMENT_PRODUCT_MISMATCH",
|
||||
detail=f"productId mismatch: expected={expected_product_id} got={product_id}",
|
||||
)
|
||||
|
||||
environment: str = str(payload.get("environment", ""))
|
||||
if environment not in ("Sandbox", "Production"):
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail=f"Invalid environment: {environment}",
|
||||
)
|
||||
|
||||
if environment != expected_environment:
|
||||
return VerificationError(
|
||||
code="PAYMENT_ENVIRONMENT_MISMATCH",
|
||||
detail=f"Environment mismatch: expected={expected_environment} got={environment}",
|
||||
)
|
||||
|
||||
revocation_date_raw = payload.get("revocationDate")
|
||||
revocation_date: int | None = (
|
||||
int(revocation_date_raw) if revocation_date_raw is not None else None
|
||||
)
|
||||
if revocation_date is not None and revocation_date > 0:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_REVOKED",
|
||||
detail="Transaction has been revoked",
|
||||
)
|
||||
|
||||
transaction_id = str(payload.get("transactionId", ""))
|
||||
original_transaction_id = str(payload.get("originalTransactionId", ""))
|
||||
web_order_line_item_id_raw = payload.get("webOrderLineItemId")
|
||||
purchase_date = int(payload.get("purchaseDate", 0))
|
||||
app_account_token_raw = payload.get("appAccountToken")
|
||||
|
||||
if not transaction_id:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="Missing transactionId in payload",
|
||||
)
|
||||
|
||||
return VerifiedTransaction(
|
||||
transaction_id=transaction_id,
|
||||
original_transaction_id=original_transaction_id,
|
||||
web_order_line_item_id=(
|
||||
str(web_order_line_item_id_raw) if web_order_line_item_id_raw else None
|
||||
),
|
||||
bundle_id=bundle_id,
|
||||
product_id=product_id,
|
||||
purchase_date=purchase_date,
|
||||
revocation_date=revocation_date,
|
||||
environment=environment,
|
||||
app_account_token=(
|
||||
str(app_account_token_raw) if app_account_token_raw else None
|
||||
),
|
||||
raw_payload=payload,
|
||||
)
|
||||
|
||||
def _verify_cert_chain_issuer_subject(
|
||||
self, x5c: list[str]
|
||||
) -> VerificationError | None:
|
||||
certs = []
|
||||
for i, b64_der in enumerate(x5c):
|
||||
der = base64.b64decode(b64_der)
|
||||
certs.append(load_der_x509_certificate(der))
|
||||
|
||||
for i in range(len(certs) - 1):
|
||||
child = certs[i]
|
||||
parent = certs[i + 1]
|
||||
if child.issuer != parent.subject:
|
||||
return VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail=f"Certificate chain issuer/subject mismatch at index {i}",
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.db import get_db
|
||||
from v1.payments.apple_verifier import AppleJwsVerifier
|
||||
from v1.payments.repository import PaymentRepository
|
||||
from v1.payments.service import PaymentService
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
|
||||
def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentService:
|
||||
payment_repo = PaymentRepository(session)
|
||||
points_repo = PointsRepository(session)
|
||||
verifier = AppleJwsVerifier()
|
||||
return PaymentService(
|
||||
payment_repo=payment_repo,
|
||||
points_repo=points_repo,
|
||||
verifier=verifier,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.apple_iap_transaction import AppleIapTransaction
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from models.user_points import UserPoints
|
||||
|
||||
|
||||
class PaymentRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session: AsyncSession = session
|
||||
|
||||
async def get_or_create_user_points_for_update(
|
||||
self, *, user_id: UUID
|
||||
) -> UserPoints:
|
||||
insert_stmt = (
|
||||
insert(UserPoints)
|
||||
.values(user_id=user_id)
|
||||
.on_conflict_do_nothing(index_elements=[UserPoints.user_id])
|
||||
)
|
||||
_ = await self._session.execute(insert_stmt)
|
||||
|
||||
stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update()
|
||||
return (await self._session.execute(stmt)).scalar_one()
|
||||
|
||||
async def get_user_points_for_update(self, *, user_id: UUID) -> UserPoints | None:
|
||||
stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update()
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def get_transaction_by_transaction_id(
|
||||
self, *, transaction_id: str
|
||||
) -> AppleIapTransaction | None:
|
||||
stmt = select(AppleIapTransaction).where(
|
||||
AppleIapTransaction.transaction_id == transaction_id
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
|
||||
self._session.add(transaction)
|
||||
await self._session.flush()
|
||||
|
||||
async def get_register_bonus_claim(
|
||||
self, *, email_hash: str
|
||||
) -> RegisterBonusClaims | None:
|
||||
stmt = (
|
||||
select(RegisterBonusClaims)
|
||||
.where(RegisterBonusClaims.email_hash == email_hash)
|
||||
.limit(1)
|
||||
)
|
||||
return (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def upsert_register_bonus_claim_for_starter_pack(
|
||||
self,
|
||||
*,
|
||||
email_hash: str,
|
||||
user_email_snapshot: str,
|
||||
first_user_id_snapshot: UUID,
|
||||
) -> RegisterBonusClaims:
|
||||
claim = await self.get_register_bonus_claim(email_hash=email_hash)
|
||||
if claim is not None:
|
||||
claim.has_purchased_starter_pack = True
|
||||
await self._session.flush()
|
||||
return claim
|
||||
|
||||
insert_stmt = (
|
||||
insert(RegisterBonusClaims)
|
||||
.values(
|
||||
email_hash=email_hash,
|
||||
user_email_snapshot=user_email_snapshot,
|
||||
first_user_id_snapshot=first_user_id_snapshot,
|
||||
grant_event_id=f"starter_pack_purchase:{email_hash[:16]}",
|
||||
has_purchased_starter_pack=True,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash])
|
||||
)
|
||||
_ = await self._session.execute(insert_stmt)
|
||||
claim = await self.get_register_bonus_claim(email_hash=email_hash)
|
||||
if claim is None:
|
||||
raise RuntimeError("Failed to upsert register bonus claim")
|
||||
return claim
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.payments.dependencies import get_payment_service
|
||||
from v1.payments.schemas import (
|
||||
AppleServerNotificationRequest,
|
||||
VerifyTransactionRequest,
|
||||
VerifyTransactionResponse,
|
||||
)
|
||||
from v1.payments.service import PaymentService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/payments", tags=["payments"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/apple/transactions/verify",
|
||||
response_model=VerifyTransactionResponse,
|
||||
)
|
||||
async def verify_apple_transaction(
|
||||
request: VerifyTransactionRequest,
|
||||
service: Annotated[PaymentService, Depends(get_payment_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> VerifyTransactionResponse:
|
||||
return await service.verify_and_grant(
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email or "",
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/apple/notifications", status_code=200)
|
||||
async def handle_apple_server_notification(
|
||||
request: AppleServerNotificationRequest,
|
||||
service: Annotated[PaymentService, Depends(get_payment_service)],
|
||||
) -> Response:
|
||||
await service.handle_server_notification(signed_payload=request.signed_payload)
|
||||
return Response(status_code=200)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class VerifyTransactionRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
product_code: str = Field(alias="productCode", min_length=1, max_length=32)
|
||||
app_store_product_id: str = Field(
|
||||
alias="appStoreProductId", min_length=1, max_length=128
|
||||
)
|
||||
transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64)
|
||||
signed_transaction_info: str = Field(
|
||||
alias="signedTransactionInfo", min_length=1
|
||||
)
|
||||
app_account_token: UUID | None = Field(
|
||||
alias="appAccountToken", default=None
|
||||
)
|
||||
|
||||
|
||||
class VerifyTransactionResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
status: Literal["granted", "already_granted"]
|
||||
product_code: str = Field(alias="productCode")
|
||||
transaction_id: str = Field(alias="transactionId")
|
||||
credits_added: int = Field(alias="creditsAdded", ge=0)
|
||||
new_balance: int = Field(alias="newBalance", ge=0)
|
||||
ledger_event_id: str = Field(alias="ledgerEventId")
|
||||
|
||||
|
||||
class AppleNotificationPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
notification_type: str = Field(alias="notificationType", default="")
|
||||
subtype: str | None = Field(alias="subtype", default=None)
|
||||
signed_payload: str = Field(alias="signedPayload", default="")
|
||||
|
||||
|
||||
class AppleServerNotificationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
signed_payload: str = Field(alias="signedPayload", default="")
|
||||
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
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.apple_iap_transaction import AppleIapTransaction
|
||||
from schemas.domain.points import (
|
||||
ApplyPointsChangeCommand,
|
||||
PurchaseLedgerMetadata,
|
||||
)
|
||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||
from v1.payments.apple_verifier import (
|
||||
AppleJwsVerifier,
|
||||
VerificationError,
|
||||
VerifiedTransaction,
|
||||
)
|
||||
from v1.payments.repository import PaymentRepository
|
||||
from v1.payments.schemas import VerifyTransactionRequest, VerifyTransactionResponse
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductMapping:
|
||||
app_store_product_id: str
|
||||
credits: int
|
||||
type: str
|
||||
|
||||
|
||||
_product_mappings_cache: dict[str, ProductMapping] | None = None
|
||||
|
||||
|
||||
def _load_product_mappings() -> dict[str, ProductMapping]:
|
||||
global _product_mappings_cache
|
||||
if _product_mappings_cache is not None:
|
||||
return _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, 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"]),
|
||||
credits=int(entry["credits"]),
|
||||
type=str(entry["type"]),
|
||||
)
|
||||
|
||||
_product_mappings_cache = mappings
|
||||
return mappings
|
||||
|
||||
|
||||
def clear_product_mappings_cache() -> None:
|
||||
global _product_mappings_cache
|
||||
_product_mappings_cache = None
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
payment_repo: PaymentRepository,
|
||||
points_repo: PointsRepository,
|
||||
verifier: AppleJwsVerifier,
|
||||
) -> None:
|
||||
self._payment_repo: PaymentRepository = payment_repo
|
||||
self._points_repo: PointsRepository = points_repo
|
||||
self._verifier: AppleJwsVerifier = verifier
|
||||
|
||||
async def verify_and_grant(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
user_email: str,
|
||||
request: VerifyTransactionRequest,
|
||||
) -> VerifyTransactionResponse:
|
||||
mappings = _load_product_mappings()
|
||||
product_mapping = mappings.get(request.product_code)
|
||||
if product_mapping is None:
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="PAYMENT_PRODUCT_NOT_FOUND",
|
||||
detail=f"Product not found: {request.product_code}",
|
||||
),
|
||||
)
|
||||
|
||||
if request.app_store_product_id != product_mapping.app_store_product_id:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="PAYMENT_PRODUCT_MISMATCH",
|
||||
detail="appStoreProductId does not match backend mapping",
|
||||
),
|
||||
)
|
||||
|
||||
expected_bundle_id = config.apple_iap.bundle_id
|
||||
expected_environment = "Sandbox" if config.runtime.environment != "prod" else "Production"
|
||||
result = self._verifier.verify_signed_transaction(
|
||||
request.signed_transaction_info,
|
||||
expected_bundle_id=expected_bundle_id,
|
||||
expected_product_id=product_mapping.app_store_product_id,
|
||||
expected_environment=expected_environment,
|
||||
)
|
||||
|
||||
if isinstance(result, VerificationError):
|
||||
status_code = 422
|
||||
if result.code == "PAYMENT_TRANSACTION_REVOKED":
|
||||
status_code = 409
|
||||
elif result.code == "PAYMENT_PRODUCT_MISMATCH":
|
||||
status_code = 422
|
||||
raise ApiProblemError(
|
||||
status_code=status_code,
|
||||
detail=problem_payload(code=result.code, detail=result.detail),
|
||||
)
|
||||
|
||||
verified: VerifiedTransaction = result
|
||||
|
||||
if str(verified.transaction_id) != request.transaction_id:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="transactionId does not match verified payload",
|
||||
),
|
||||
)
|
||||
|
||||
existing = await self._payment_repo.get_transaction_by_transaction_id(
|
||||
transaction_id=verified.transaction_id
|
||||
)
|
||||
|
||||
if existing is not None:
|
||||
if existing.user_id == user_id and existing.status == "granted":
|
||||
account = await self._payment_repo.get_or_create_user_points_for_update(
|
||||
user_id=user_id
|
||||
)
|
||||
return VerifyTransactionResponse(
|
||||
status="already_granted",
|
||||
productCode=request.product_code,
|
||||
transactionId=verified.transaction_id,
|
||||
creditsAdded=0,
|
||||
newBalance=int(account.balance),
|
||||
ledgerEventId=existing.ledger_event_id or "",
|
||||
)
|
||||
if existing.user_id != user_id:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="PAYMENT_TRANSACTION_CONFLICT",
|
||||
detail="Transaction belongs to another user",
|
||||
),
|
||||
)
|
||||
if existing.status in ("refunded", "refunded_insufficient", "revoked"):
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="PAYMENT_TRANSACTION_REVOKED",
|
||||
detail="Transaction has been refunded or revoked",
|
||||
),
|
||||
)
|
||||
|
||||
is_starter = product_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",
|
||||
),
|
||||
)
|
||||
|
||||
transaction_record = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code=request.product_code,
|
||||
app_store_product_id=product_mapping.app_store_product_id,
|
||||
transaction_id=verified.transaction_id,
|
||||
original_transaction_id=verified.original_transaction_id,
|
||||
web_order_line_item_id=verified.web_order_line_item_id,
|
||||
environment=verified.environment,
|
||||
bundle_id=verified.bundle_id,
|
||||
app_account_token=request.app_account_token,
|
||||
purchase_date=str(verified.purchase_date),
|
||||
revocation_date=(
|
||||
str(verified.revocation_date) if verified.revocation_date else None
|
||||
),
|
||||
status="verified",
|
||||
credits=product_mapping.credits,
|
||||
currency=None,
|
||||
price_milliunits=None,
|
||||
signed_transaction_info=request.signed_transaction_info,
|
||||
apple_payload_json=verified.raw_payload,
|
||||
)
|
||||
|
||||
await self._payment_repo.insert_transaction(transaction=transaction_record)
|
||||
|
||||
account = await self._payment_repo.get_or_create_user_points_for_update(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
credits = product_mapping.credits
|
||||
event_id = f"payment.apple_iap:{verified.transaction_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
|
||||
|
||||
metadata = PurchaseLedgerMetadata(
|
||||
operator_type=PointsOperatorType.SYSTEM,
|
||||
run_id=event_id,
|
||||
ext={
|
||||
"source": "apple_iap",
|
||||
"platform": "ios",
|
||||
"product_code": request.product_code,
|
||||
"app_store_product_id": product_mapping.app_store_product_id,
|
||||
"transaction_id": verified.transaction_id,
|
||||
"original_transaction_id": verified.original_transaction_id,
|
||||
"environment": verified.environment,
|
||||
"apple_iap_transaction_id": str(transaction_record.id),
|
||||
},
|
||||
)
|
||||
|
||||
ledger_command = ApplyPointsChangeCommand(
|
||||
user_id=user_id,
|
||||
change_type=PointsChangeType.PURCHASE,
|
||||
biz_type=PointsBizType.PAYMENT,
|
||||
biz_id=transaction_record.id,
|
||||
event_id=event_id,
|
||||
amount=credits,
|
||||
direction=1,
|
||||
operator_id=None,
|
||||
metadata=metadata,
|
||||
)
|
||||
await self._points_repo.append_ledger(
|
||||
command=ledger_command,
|
||||
balance_after=new_balance,
|
||||
)
|
||||
|
||||
transaction_record.status = "granted"
|
||||
transaction_record.ledger_event_id = event_id
|
||||
|
||||
if is_starter and email_hash and 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,
|
||||
)
|
||||
|
||||
return VerifyTransactionResponse(
|
||||
status="granted",
|
||||
productCode=request.product_code,
|
||||
transactionId=verified.transaction_id,
|
||||
creditsAdded=credits,
|
||||
newBalance=new_balance,
|
||||
ledgerEventId=event_id,
|
||||
)
|
||||
|
||||
@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()
|
||||
|
||||
async def process_refund(
|
||||
self,
|
||||
*,
|
||||
transaction_id: str,
|
||||
refund_reason: str = "CUSTOMER_REQUEST",
|
||||
) -> None:
|
||||
txn = await self._payment_repo.get_transaction_by_transaction_id(
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
if txn is None:
|
||||
logger.warning("Refund requested for unknown transaction: %s", transaction_id)
|
||||
return
|
||||
|
||||
if txn.status not in ("granted",):
|
||||
logger.info(
|
||||
"Refund skipped: transaction %s status=%s",
|
||||
transaction_id,
|
||||
txn.status,
|
||||
)
|
||||
return
|
||||
|
||||
user_id = txn.user_id
|
||||
credits = txn.credits
|
||||
|
||||
account = await self._payment_repo.get_user_points_for_update(user_id=user_id)
|
||||
if account is None:
|
||||
logger.warning(
|
||||
"Refund failed: no user_points for user %s on transaction %s",
|
||||
user_id,
|
||||
transaction_id,
|
||||
)
|
||||
txn.status = "failed"
|
||||
txn.failure_code = "USER_POINTS_NOT_FOUND"
|
||||
return
|
||||
|
||||
balance = int(account.balance)
|
||||
|
||||
if balance < credits:
|
||||
refund_amount = balance
|
||||
txn.status = "refunded_insufficient"
|
||||
txn.failure_code = "INSUFFICIENT_BALANCE"
|
||||
logger.warning(
|
||||
"Refund insufficient balance: user=%s credits=%d balance=%d txn=%s",
|
||||
user_id,
|
||||
credits,
|
||||
balance,
|
||||
transaction_id,
|
||||
)
|
||||
else:
|
||||
refund_amount = credits
|
||||
txn.status = "refunded"
|
||||
|
||||
new_balance = balance - refund_amount
|
||||
account.balance = new_balance
|
||||
account.lifetime_earned = int(account.lifetime_earned) - refund_amount
|
||||
account.version = int(account.version) + 1
|
||||
|
||||
refund_event_id = f"refund.apple_iap:{transaction_id}"
|
||||
original_event_id = txn.ledger_event_id or f"payment.apple_iap:{transaction_id}"
|
||||
|
||||
metadata = PurchaseLedgerMetadata(
|
||||
operator_type=PointsOperatorType.SYSTEM,
|
||||
run_id=refund_event_id,
|
||||
ext={
|
||||
"source": "apple_iap",
|
||||
"platform": "ios",
|
||||
"product_code": txn.product_code,
|
||||
"app_store_product_id": txn.app_store_product_id,
|
||||
"transaction_id": transaction_id,
|
||||
"original_transaction_id": txn.original_transaction_id or "",
|
||||
"environment": txn.environment,
|
||||
"apple_iap_transaction_id": str(txn.id),
|
||||
"original_event_id": original_event_id,
|
||||
"refund_reason": refund_reason,
|
||||
"overdue_amount": credits - refund_amount,
|
||||
},
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
ledger_command = ApplyPointsChangeCommand(
|
||||
user_id=user_id,
|
||||
change_type=PointsChangeType.REFUND,
|
||||
biz_type=PointsBizType.PAYMENT,
|
||||
biz_id=txn.id,
|
||||
event_id=refund_event_id,
|
||||
amount=refund_amount,
|
||||
direction=-1,
|
||||
operator_id=None,
|
||||
metadata=metadata,
|
||||
)
|
||||
await self._points_repo.append_ledger(
|
||||
command=ledger_command,
|
||||
balance_after=new_balance,
|
||||
)
|
||||
|
||||
txn.ledger_event_id = refund_event_id
|
||||
|
||||
logger.info(
|
||||
"Refund processed: txn=%s user=%s refund_amount=%d new_balance=%d status=%s",
|
||||
transaction_id,
|
||||
user_id,
|
||||
refund_amount,
|
||||
new_balance,
|
||||
txn.status,
|
||||
)
|
||||
|
||||
async def handle_server_notification(self, *, signed_payload: str) -> None:
|
||||
if not signed_payload:
|
||||
logger.warning("Empty Apple server notification payload")
|
||||
return
|
||||
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
|
||||
parts = signed_payload.split(".")
|
||||
if len(parts) < 2:
|
||||
logger.warning("Malformed Apple notification signed_payload")
|
||||
return
|
||||
|
||||
payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
import base64
|
||||
|
||||
decoded = base64.urlsafe_b64decode(payload_bytes)
|
||||
import json
|
||||
|
||||
notification_data: Any = json.loads(decoded)
|
||||
except Exception:
|
||||
logger.exception("Failed to decode Apple server notification payload")
|
||||
return
|
||||
|
||||
notification_type = str(notification_data.get("notificationType", ""))
|
||||
subtype = notification_data.get("subtype")
|
||||
|
||||
signed_transaction = notification_data.get("data", {}).get(
|
||||
"signedTransactionInfo", ""
|
||||
)
|
||||
|
||||
transaction_id: str | None = None
|
||||
if signed_transaction:
|
||||
try:
|
||||
txn_parts = signed_transaction.split(".")
|
||||
if len(txn_parts) >= 2:
|
||||
txn_payload_bytes = txn_parts[1] + "=" * (-len(txn_parts[1]) % 4)
|
||||
txn_decoded = base64.urlsafe_b64decode(txn_payload_bytes)
|
||||
txn_data: Any = json.loads(txn_decoded)
|
||||
transaction_id = str(txn_data.get("transactionId", ""))
|
||||
except Exception:
|
||||
logger.exception("Failed to decode signed transaction from notification")
|
||||
|
||||
logger.info(
|
||||
"Apple notification received: type=%s subtype=%s transaction_id=%s",
|
||||
notification_type,
|
||||
subtype,
|
||||
transaction_id,
|
||||
)
|
||||
|
||||
refund_types = {"REFUND", "REVOKE", "DID_FAIL_TO_RENEW"}
|
||||
if notification_type in refund_types and transaction_id:
|
||||
refund_reason = notification_type
|
||||
if subtype:
|
||||
refund_reason = f"{notification_type}:{subtype}"
|
||||
await self.process_refund(
|
||||
transaction_id=transaction_id,
|
||||
refund_reason=refund_reason,
|
||||
)
|
||||
return
|
||||
|
||||
if notification_type == "DID_RENEW" and transaction_id:
|
||||
logger.info(
|
||||
"Apple DID_RENEW for transaction %s, no action needed",
|
||||
transaction_id,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Apple notification type=%s not handled, skipped",
|
||||
notification_type,
|
||||
)
|
||||
@@ -44,6 +44,7 @@ async def get_available_packages(
|
||||
packages=[
|
||||
PackageInfo(
|
||||
productCode=pkg.product_code,
|
||||
appStoreProductId=pkg.app_store_product_id,
|
||||
type=pkg.type.value,
|
||||
price=pkg.price,
|
||||
credits=pkg.credits,
|
||||
|
||||
@@ -19,6 +19,9 @@ 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
|
||||
)
|
||||
type: Literal["starter", "regular"]
|
||||
price: float = Field(ge=0)
|
||||
credits: int = Field(ge=1)
|
||||
|
||||
@@ -23,6 +23,7 @@ from schemas.domain.points import (
|
||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||
from schemas.domain.points import ApplyPointsChangeCommand
|
||||
from schemas.shared.user import parse_profile_settings
|
||||
from v1.payments.service import _load_product_mappings
|
||||
from v1.points.repository import PointsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -67,6 +68,7 @@ class RegisterBonusResult:
|
||||
@dataclass(frozen=True)
|
||||
class PackageInfoResult:
|
||||
product_code: str
|
||||
app_store_product_id: str
|
||||
type: PackageType
|
||||
price: float
|
||||
credits: int
|
||||
@@ -461,6 +463,8 @@ class PointsService:
|
||||
email_hash=email_hash
|
||||
)
|
||||
|
||||
product_mappings = _load_product_mappings()
|
||||
|
||||
packages: list[PackageInfoResult] = []
|
||||
for pkg in pkg_config.packages:
|
||||
if not pkg.enabled:
|
||||
@@ -468,9 +472,13 @@ class PointsService:
|
||||
if pkg.type == PackageType.STARTER and has_starter:
|
||||
continue
|
||||
|
||||
mapping = product_mappings.get(pkg.product_code)
|
||||
app_store_product_id = mapping.app_store_product_id if mapping else ""
|
||||
|
||||
packages.append(
|
||||
PackageInfoResult(
|
||||
product_code=pkg.product_code,
|
||||
app_store_product_id=app_store_product_id,
|
||||
type=pkg.type,
|
||||
price=pkg.price,
|
||||
credits=pkg.credits,
|
||||
|
||||
@@ -7,6 +7,7 @@ from v1.auth.router import router as auth_router
|
||||
from v1.feedback.router import router as feedback_router
|
||||
from v1.invite.router import router as invite_router
|
||||
from v1.notifications.router import router as notifications_router
|
||||
from v1.payments.router import router as payments_router
|
||||
from v1.points.router import router as points_router
|
||||
from v1.users.router import router as users_router
|
||||
|
||||
@@ -17,5 +18,6 @@ router.include_router(agent_router)
|
||||
router.include_router(feedback_router)
|
||||
router.include_router(invite_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(payments_router)
|
||||
router.include_router(points_router)
|
||||
router.include_router(users_router)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Integration tests for Apple IAP payment verify flow.
|
||||
|
||||
Prerequisite: backend must be running via `./infra/scripts/app.sh restart`.
|
||||
These tests hit the live HTTP API against the test database.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_endpoint_returns_401_without_auth() -> None:
|
||||
import httpx
|
||||
|
||||
base_url = "http://localhost:8000"
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=5) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/payments/apple/transactions/verify",
|
||||
json={
|
||||
"productCode": "basic_pack",
|
||||
"appStoreProductId": "com.meeyao.qianwen.basic_pack",
|
||||
"transactionId": "0000000000000001",
|
||||
"signedTransactionInfo": "fake_jws",
|
||||
},
|
||||
)
|
||||
assert response.status_code in (401, 403)
|
||||
except httpx.ConnectError:
|
||||
pytest.skip("Backend not running, skipping integration test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifications_endpoint_returns_200() -> None:
|
||||
import httpx
|
||||
|
||||
base_url = "http://localhost:8000"
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=5) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/payments/apple/notifications",
|
||||
json={"signedPayload": ""},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
except httpx.ConnectError:
|
||||
pytest.skip("Backend not running, skipping integration test")
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from v1.payments.apple_verifier import (
|
||||
AppleJwsVerifier,
|
||||
VerificationError,
|
||||
VerifiedTransaction,
|
||||
)
|
||||
|
||||
|
||||
def _make_jws_parts(header: dict[str, object], payload: dict[str, object]) -> tuple[str, str]:
|
||||
h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode()
|
||||
p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
|
||||
return h, p
|
||||
|
||||
|
||||
class TestAppleJwsVerifierInvalidInput:
|
||||
def test_invalid_header_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
result = verifier.verify_signed_transaction(
|
||||
"not-a-jws",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert result.code == "PAYMENT_TRANSACTION_INVALID"
|
||||
assert "decode" in result.detail.lower() or "header" in result.detail.lower()
|
||||
|
||||
def test_missing_x5c_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
h, p = _make_jws_parts({"alg": "ES256"}, {"bundleId": "test"})
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "x5c" in result.detail
|
||||
|
||||
def test_short_x5c_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
h, p = _make_jws_parts({"alg": "ES256", "x5c": ["one"]}, {"bundleId": "test"})
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "x5c" in result.detail
|
||||
|
||||
def test_issuer_subject_mismatch_returns_error(self) -> None:
|
||||
verifier = AppleJwsVerifier()
|
||||
leaf_cert_b64 = base64.b64encode(b"fake_leaf_cert").decode()
|
||||
intermediate_cert_b64 = base64.b64encode(b"fake_intermediate_cert").decode()
|
||||
root_cert_b64 = base64.b64encode(b"fake_root_cert").decode()
|
||||
h, p = _make_jws_parts(
|
||||
{"alg": "ES256", "x5c": [leaf_cert_b64, intermediate_cert_b64, root_cert_b64]},
|
||||
{"bundleId": "com.meeyao.qianwen"},
|
||||
)
|
||||
result = verifier.verify_signed_transaction(
|
||||
f"{h}.{p}.fake",
|
||||
expected_bundle_id="com.meeyao.qianwen",
|
||||
expected_product_id="com.meeyao.qianwen.basic_pack",
|
||||
)
|
||||
assert isinstance(result, VerificationError)
|
||||
assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail
|
||||
@@ -0,0 +1,617 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.http.errors import ApiProblemError
|
||||
from models.apple_iap_transaction import AppleIapTransaction
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from schemas.domain.points import ApplyPointsChangeCommand
|
||||
from v1.payments.apple_verifier import VerificationError, VerifiedTransaction
|
||||
from v1.payments.schemas import VerifyTransactionRequest
|
||||
from v1.payments.service import PaymentService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAccount:
|
||||
balance: int = 0
|
||||
frozen_balance: int = 0
|
||||
lifetime_earned: int = 0
|
||||
lifetime_spent: int = 0
|
||||
version: int = 0
|
||||
|
||||
|
||||
class _FakePaymentRepository:
|
||||
def __init__(self, *, existing_transaction: AppleIapTransaction | None = None) -> None:
|
||||
self.account = _FakeAccount()
|
||||
self.existing_transaction = existing_transaction
|
||||
self.inserted_transactions: list[AppleIapTransaction] = []
|
||||
self.claim: RegisterBonusClaims | None = None
|
||||
self.claim_starter_pack_called: bool = False
|
||||
|
||||
async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount:
|
||||
return self.account
|
||||
|
||||
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
|
||||
return self.existing_transaction
|
||||
|
||||
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
|
||||
self.inserted_transactions.append(transaction)
|
||||
|
||||
async def get_register_bonus_claim(self, *, email_hash: str) -> RegisterBonusClaims | None:
|
||||
return self.claim
|
||||
|
||||
async def upsert_register_bonus_claim_for_starter_pack(
|
||||
self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID
|
||||
) -> RegisterBonusClaims:
|
||||
self.claim_starter_pack_called = True
|
||||
if self.claim is None:
|
||||
self.claim = RegisterBonusClaims(
|
||||
email_hash=email_hash,
|
||||
user_email_snapshot=user_email_snapshot,
|
||||
first_user_id_snapshot=first_user_id_snapshot,
|
||||
grant_event_id="starter_pack_purchase:test",
|
||||
has_purchased_starter_pack=True,
|
||||
)
|
||||
else:
|
||||
self.claim.has_purchased_starter_pack = True
|
||||
return self.claim
|
||||
|
||||
|
||||
class _FakePointsRepository:
|
||||
def __init__(self) -> None:
|
||||
self.appended_ledger: list[ApplyPointsChangeCommand] = []
|
||||
|
||||
async def append_ledger(self, *, command: ApplyPointsChangeCommand, balance_after: int) -> None:
|
||||
self.appended_ledger.append(command)
|
||||
|
||||
|
||||
class _FakeVerifier:
|
||||
def __init__(self, *, result: VerifiedTransaction | VerificationError) -> None:
|
||||
self._result = result
|
||||
|
||||
def verify_signed_transaction(
|
||||
self,
|
||||
signed_transaction_info: str,
|
||||
*,
|
||||
expected_bundle_id: str,
|
||||
expected_product_id: str,
|
||||
) -> VerifiedTransaction | VerificationError:
|
||||
return self._result
|
||||
|
||||
|
||||
def _make_verified_transaction(
|
||||
*,
|
||||
transaction_id: str = "2000000123456789",
|
||||
product_id: str = "com.meeyao.qianwen.basic_pack",
|
||||
environment: str = "Sandbox",
|
||||
) -> VerifiedTransaction:
|
||||
return VerifiedTransaction(
|
||||
transaction_id=transaction_id,
|
||||
original_transaction_id=transaction_id,
|
||||
web_order_line_item_id=None,
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
product_id=product_id,
|
||||
purchase_date=1700000000000,
|
||||
revocation_date=None,
|
||||
environment=environment,
|
||||
app_account_token=None,
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
|
||||
class TestPaymentServiceProductNotFound:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_product_not_found(self) -> None:
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepository(),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="nonexistent_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.nonexistent",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert exc_info.value.code == "PAYMENT_PRODUCT_NOT_FOUND"
|
||||
|
||||
|
||||
class TestPaymentServiceProductMismatch:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_product_mismatch_when_ids_differ(self) -> None:
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepository(),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.wrong_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert exc_info.value.code == "PAYMENT_PRODUCT_MISMATCH"
|
||||
|
||||
|
||||
class TestPaymentServiceVerificationFailed:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_verifier_returns_error(self) -> None:
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepository(),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(
|
||||
result=VerificationError(
|
||||
code="PAYMENT_TRANSACTION_INVALID",
|
||||
detail="bad signature",
|
||||
)
|
||||
),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert exc_info.value.code == "PAYMENT_TRANSACTION_INVALID"
|
||||
|
||||
|
||||
class TestPaymentServiceAlreadyGranted:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_already_granted_for_same_user(self) -> None:
|
||||
user_id = uuid4()
|
||||
existing = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000123456789",
|
||||
original_transaction_id="2000000123456789",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="granted",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
ledger_event_id="payment.apple_iap:2000000123456789",
|
||||
)
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepository(existing_transaction=existing),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
result = await service.verify_and_grant(
|
||||
user_id=user_id,
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert result.status == "already_granted"
|
||||
assert result.credits_added == 0
|
||||
|
||||
|
||||
class TestPaymentServiceTransactionConflict:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_conflict_for_different_user(self) -> None:
|
||||
existing = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=uuid4(),
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000123456789",
|
||||
original_transaction_id="2000000123456789",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="granted",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
ledger_event_id="payment.apple_iap:2000000123456789",
|
||||
)
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepository(existing_transaction=existing),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert exc_info.value.code == "PAYMENT_TRANSACTION_CONFLICT"
|
||||
|
||||
|
||||
class TestPaymentServiceSuccessfulGrant:
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_credits_for_new_transaction(self) -> None:
|
||||
payment_repo = _FakePaymentRepository()
|
||||
points_repo = _FakePointsRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=payment_repo,
|
||||
points_repo=points_repo,
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="basic_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.basic_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
result = await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert result.status == "granted"
|
||||
assert result.credits_added == 100
|
||||
assert result.new_balance == 100
|
||||
assert result.ledger_event_id == "payment.apple_iap:2000000123456789"
|
||||
assert len(points_repo.appended_ledger) == 1
|
||||
assert len(payment_repo.inserted_transactions) == 1
|
||||
|
||||
|
||||
class TestPaymentServiceStarterPackIneligible:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_starter_pack_already_purchased(self) -> None:
|
||||
claim = RegisterBonusClaims(
|
||||
email_hash="fake_hash",
|
||||
user_email_snapshot="test@example.com",
|
||||
first_user_id_snapshot=uuid4(),
|
||||
grant_event_id="register.bonus:test",
|
||||
has_purchased_starter_pack=True,
|
||||
)
|
||||
payment_repo = _FakePaymentRepository()
|
||||
payment_repo.claim = claim
|
||||
service = PaymentService(
|
||||
payment_repo=payment_repo,
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(
|
||||
result=_make_verified_transaction(
|
||||
product_id="com.meeyao.qianwen.new_user_pack"
|
||||
)
|
||||
),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="new_user_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.new_user_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
with pytest.raises(ApiProblemError) as exc_info:
|
||||
await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert exc_info.value.code == "PAYMENT_STARTER_PACK_INELIGIBLE"
|
||||
|
||||
|
||||
class TestPaymentServiceStarterPackSuccess:
|
||||
@pytest.mark.asyncio
|
||||
async def test_grants_starter_pack_and_updates_claim(self) -> None:
|
||||
payment_repo = _FakePaymentRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=payment_repo,
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(
|
||||
result=_make_verified_transaction(
|
||||
product_id="com.meeyao.qianwen.new_user_pack"
|
||||
)
|
||||
),
|
||||
)
|
||||
request = VerifyTransactionRequest(
|
||||
productCode="new_user_pack",
|
||||
appStoreProductId="com.meeyao.qianwen.new_user_pack",
|
||||
transactionId="2000000123456789",
|
||||
signedTransactionInfo="fake_jws",
|
||||
)
|
||||
result = await service.verify_and_grant(
|
||||
user_id=uuid4(),
|
||||
user_email="test@example.com",
|
||||
request=request,
|
||||
)
|
||||
assert result.status == "granted"
|
||||
assert result.credits_added == 60
|
||||
assert payment_repo.claim_starter_pack_called
|
||||
|
||||
|
||||
class _FakeAccountForRefund:
|
||||
def __init__(self, balance: int = 100, lifetime_earned: int = 100) -> None:
|
||||
self.balance: int = balance
|
||||
self.frozen_balance: int = 0
|
||||
self.lifetime_earned: int = lifetime_earned
|
||||
self.lifetime_spent: int = 0
|
||||
self.version: int = 1
|
||||
|
||||
|
||||
class _FakePaymentRepoForRefund:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transaction: AppleIapTransaction | None = None,
|
||||
account: _FakeAccountForRefund | None = None,
|
||||
) -> None:
|
||||
self._transaction = transaction
|
||||
self.account = account or _FakeAccountForRefund()
|
||||
self.inserted_transactions: list[AppleIapTransaction] = []
|
||||
|
||||
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
|
||||
return self._transaction
|
||||
|
||||
async def get_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund:
|
||||
return self.account
|
||||
|
||||
async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund:
|
||||
return self.account
|
||||
|
||||
async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None:
|
||||
self.inserted_transactions.append(transaction)
|
||||
|
||||
async def get_register_bonus_claim(self, *, email_hash: str) -> None:
|
||||
return None
|
||||
|
||||
async def upsert_register_bonus_claim_for_starter_pack(
|
||||
self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestProcessRefundUnknownTransaction:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_silently_for_unknown_transaction(self) -> None:
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=None),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.process_refund(transaction_id="nonexistent")
|
||||
|
||||
|
||||
class TestProcessRefundNotGranted:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_for_non_granted_transaction(self) -> None:
|
||||
txn = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=uuid4(),
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000999999999",
|
||||
original_transaction_id="2000000999999999",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="verified",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
)
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=txn),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.process_refund(transaction_id="2000000999999999")
|
||||
assert txn.status == "verified"
|
||||
|
||||
|
||||
class TestProcessRefundSufficientBalance:
|
||||
@pytest.mark.asyncio
|
||||
async def test_deducts_credits_and_writes_refund_ledger(self) -> None:
|
||||
user_id = uuid4()
|
||||
txn = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000999999999",
|
||||
original_transaction_id="2000000999999999",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="granted",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
ledger_event_id="payment.apple_iap:2000000999999999",
|
||||
)
|
||||
account = _FakeAccountForRefund(balance=150, lifetime_earned=200)
|
||||
points_repo = _FakePointsRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
|
||||
points_repo=points_repo,
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.process_refund(transaction_id="2000000999999999")
|
||||
assert txn.status == "refunded"
|
||||
assert account.balance == 50
|
||||
assert account.lifetime_earned == 100
|
||||
assert len(points_repo.appended_ledger) == 1
|
||||
ledger = points_repo.appended_ledger[0]
|
||||
assert ledger.change_type.value == "refund"
|
||||
assert ledger.direction == -1
|
||||
assert ledger.amount == 100
|
||||
|
||||
|
||||
class TestProcessRefundInsufficientBalance:
|
||||
@pytest.mark.asyncio
|
||||
async def test_deducts_to_zero_and_sets_insufficient_status(self) -> None:
|
||||
user_id = uuid4()
|
||||
txn = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000999999998",
|
||||
original_transaction_id="2000000999999998",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="granted",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
ledger_event_id="payment.apple_iap:2000000999999998",
|
||||
)
|
||||
account = _FakeAccountForRefund(balance=30, lifetime_earned=100)
|
||||
points_repo = _FakePointsRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
|
||||
points_repo=points_repo,
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.process_refund(transaction_id="2000000999999998")
|
||||
assert txn.status == "refunded_insufficient"
|
||||
assert txn.failure_code == "INSUFFICIENT_BALANCE"
|
||||
assert account.balance == 0
|
||||
assert len(points_repo.appended_ledger) == 1
|
||||
ledger = points_repo.appended_ledger[0]
|
||||
assert ledger.amount == 30
|
||||
|
||||
|
||||
class TestProcessRefundIdempotency:
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_refund_is_noop(self) -> None:
|
||||
user_id = uuid4()
|
||||
txn = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000999999997",
|
||||
original_transaction_id="2000000999999997",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="refunded",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
)
|
||||
points_repo = _FakePointsRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=_FakeAccountForRefund(balance=50)),
|
||||
points_repo=points_repo,
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.process_refund(transaction_id="2000000999999997")
|
||||
assert len(points_repo.appended_ledger) == 0
|
||||
assert txn.status == "refunded"
|
||||
|
||||
|
||||
class TestHandleServerNotificationRefund:
|
||||
@pytest.mark.asyncio
|
||||
async def test_processes_refund_notification(self) -> None:
|
||||
user_id = uuid4()
|
||||
txn = AppleIapTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
product_code="basic_pack",
|
||||
app_store_product_id="com.meeyao.qianwen.basic_pack",
|
||||
transaction_id="2000000999999001",
|
||||
original_transaction_id="2000000999999001",
|
||||
environment="Sandbox",
|
||||
bundle_id="com.meeyao.qianwen",
|
||||
purchase_date="1700000000000",
|
||||
status="granted",
|
||||
credits=100,
|
||||
signed_transaction_info="fake",
|
||||
apple_payload_json={},
|
||||
ledger_event_id="payment.apple_iap:2000000999999001",
|
||||
)
|
||||
account = _FakeAccountForRefund(balance=200, lifetime_earned=200)
|
||||
points_repo = _FakePointsRepository()
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account),
|
||||
points_repo=points_repo,
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001")
|
||||
notification_payload = json.dumps({
|
||||
"notificationType": "REFUND",
|
||||
"data": {"signedTransactionInfo": signed_txn},
|
||||
})
|
||||
signed_payload = _make_fake_jws(notification_payload)
|
||||
|
||||
await service.handle_server_notification(signed_payload=signed_payload)
|
||||
assert txn.status == "refunded"
|
||||
assert account.balance == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_empty_payload(self) -> None:
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.handle_server_notification(signed_payload="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_non_refund_notification(self) -> None:
|
||||
import json
|
||||
|
||||
notification_payload = json.dumps({
|
||||
"notificationType": "DID_RENEW",
|
||||
"data": {},
|
||||
})
|
||||
signed_payload = _make_fake_jws(notification_payload)
|
||||
service = PaymentService(
|
||||
payment_repo=_FakePaymentRepoForRefund(),
|
||||
points_repo=_FakePointsRepository(),
|
||||
verifier=_FakeVerifier(result=_make_verified_transaction()),
|
||||
)
|
||||
await service.handle_server_notification(signed_payload=signed_payload)
|
||||
|
||||
|
||||
def _make_fake_jws(payload_str: str) -> str:
|
||||
import base64
|
||||
h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
|
||||
p = base64.urlsafe_b64encode(payload_str.encode()).rstrip(b"=").decode()
|
||||
return f"{h}.{p}.fake_signature"
|
||||
|
||||
|
||||
def _make_fake_signed_transaction(transaction_id: str) -> str:
|
||||
import base64
|
||||
import json
|
||||
|
||||
txn_payload = json.dumps({"transactionId": transaction_id})
|
||||
h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode()
|
||||
p = base64.urlsafe_b64encode(txn_payload.encode()).rstrip(b"=").decode()
|
||||
return f"{h}.{p}.fake_signature"
|
||||
Reference in New Issue
Block a user