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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user