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:
@@ -107,3 +107,18 @@ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"]
|
||||
############
|
||||
ERYAO_TEST__EMAIL=test@example.com
|
||||
ERYAO_TEST__CODE=123456
|
||||
|
||||
############
|
||||
# Apple IAP 配置
|
||||
############
|
||||
ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen
|
||||
# Server API 密钥(可选,用于主动查询交易状态)
|
||||
ERYAO_APPLE_IAP__SERVER_API_KEY_ID=
|
||||
ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY=
|
||||
ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID=
|
||||
# 沙盒测试账号(仅用于手动测试,不用于后端验证)
|
||||
ERYAO_APPLE_IAP__SANDBOX_TESTER_EMAIL=
|
||||
ERYAO_APPLE_IAP__SANDBOX_TESTER_PASSWORD=
|
||||
# Server Notifications V2 URL(在 App Store Connect 中配置)
|
||||
# 格式: https://<your-domain>/api/v1/payments/apple/notifications
|
||||
ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL=
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
# iOS Apple Pay 落实计划
|
||||
|
||||
## 当前状态
|
||||
|
||||
### 已完成
|
||||
- [x] 协议文档更新(`user-points-chat-data-protocol.md`、`http-error-codes.md`)
|
||||
- [x] PRD 退款扣回策略已明确
|
||||
- [x] 套餐配置 YAML 已正确命名(`new_user_pack`, `basic_pack` 等)
|
||||
- [x] **Phase 1: 数据库与枚举**(2026-04-27 完成)
|
||||
- [x] **Phase 2: 后端支付服务**(2026-04-27 完成)
|
||||
- [x] **Phase 3: iOS / Flutter IAP 接入**(2026-04-27 完成)
|
||||
|
||||
### 待实现
|
||||
- [ ] 联调与发布准备(Phase 4)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 数据库与枚举(后端基础)✅ 已完成
|
||||
|
||||
### 1.1 枚举扩展 ✅
|
||||
|
||||
**文件**: `backend/src/schemas/enums.py`
|
||||
|
||||
- [x] `PointsChangeType` 新增 `PURCHASE`, `REFUND`,移除 `GRANT`
|
||||
- [x] `PointsBizType` 新增 `PAYMENT`
|
||||
|
||||
**文件**: `backend/src/schemas/domain/points.py`
|
||||
- [x] 更新 `ApplyPointsChangeCommand` 验证逻辑,支持 `purchase/refund`
|
||||
- [x] 移除 `GrantLedgerMetadata`
|
||||
|
||||
### 1.2 数据库迁移 ✅
|
||||
|
||||
**迁移文件**: `backend/alembic/versions/20260427_0001_apple_iap_transactions.py`
|
||||
|
||||
- [x] 创建 `apple_iap_transactions` 表(按 PRD 5.3 定义)
|
||||
- [x] 更新 `points_ledger` check constraints:
|
||||
- `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')`
|
||||
- `biz_type is null or biz_type in ('chat', 'payment')`
|
||||
- 更新 `ck_points_ledger_biz_binding` 支持 `purchase/refund`
|
||||
- 更新 `ck_points_ledger_direction_by_change_type` 支持 `purchase(direction=1)` 和 `refund(direction=-1)`
|
||||
- 新增 `ck_points_ledger_metadata_payment_shape` 和 `ck_points_ledger_metadata_refund_shape`
|
||||
- 更新 `ck_points_ledger_metadata_adjust_shape`(`ticket_id` -> `reason`)
|
||||
- [x] 更新 `points_audit_ledger` check constraints 同步变更
|
||||
|
||||
**模型文件**:
|
||||
- [x] `backend/src/models/points_ledger.py` - 同步更新 SQLAlchemy CheckConstraint
|
||||
- [x] `backend/src/models/apple_iap_transaction.py` - 新建模型
|
||||
- [x] `backend/src/models/__init__.py` - 导出新模型
|
||||
|
||||
### 1.3 验证 ✅
|
||||
- [x] 迁移已应用到数据库
|
||||
- [x] `apple_iap_transactions` 表已创建
|
||||
- [x] `points_ledger` 约束已更新
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 后端支付服务 ✅ 已完成
|
||||
|
||||
### 2.1 配置扩展 ✅
|
||||
|
||||
- [x] `backend/src/core/config/settings.py` - 新增 `AppleIapSettings` 和 `apple_iap` 配置项
|
||||
- [x] `backend/src/core/config/static/packages/mapping.yaml` - productCode -> App Store Product ID 映射
|
||||
|
||||
### 2.2 API Schemas ✅
|
||||
|
||||
- [x] `backend/src/v1/payments/schemas.py` - `VerifyTransactionRequest` / `VerifyTransactionResponse`
|
||||
- [x] `backend/src/schemas/domain/points.py` - 新增 `PurchaseLedgerMetadata`
|
||||
|
||||
### 2.3 Apple JWS 验签器 ✅
|
||||
|
||||
- [x] `backend/src/v1/payments/apple_verifier.py`
|
||||
- JWS x5c 证书链验证(root fingerprint + issuer/subject chain)
|
||||
- bundleId / productId / environment / revocationDate 验证
|
||||
- 返回 `VerifiedTransaction | VerificationError`
|
||||
|
||||
### 2.4 支付数据仓库 ✅
|
||||
|
||||
- [x] `backend/src/v1/payments/repository.py` - `PaymentRepository`
|
||||
- `get_or_create_user_points_for_update`
|
||||
- `get_user_points_for_update` (for refund, no auto-create)
|
||||
- `get_transaction_by_transaction_id`
|
||||
- `insert_transaction`
|
||||
- `get_register_bonus_claim`
|
||||
- `upsert_register_bonus_claim_for_starter_pack`
|
||||
|
||||
### 2.5 支付服务 ✅
|
||||
|
||||
- [x] `backend/src/v1/payments/service.py` - `PaymentService`
|
||||
- `verify_and_grant`: productCode / appStoreProductId 校验, Apple JWS 验签, transaction_id 幂等, 新手包资格检查, 积分入账 + points_ledger + register_bonus_claims
|
||||
- `process_refund`: 退款扣回积分, 余额不足时扣到 0 并标记 `refunded_insufficient`, 幂等处理
|
||||
- `handle_server_notification`: 解析 Apple Server Notifications V2, REFUND/REVOKE 触发退款, DID_RENEW 记录日志
|
||||
|
||||
### 2.6 API 路由 + 依赖注入 ✅
|
||||
|
||||
- [x] `backend/src/v1/payments/router.py`
|
||||
- `POST /api/v1/payments/apple/transactions/verify`
|
||||
- `POST /api/v1/payments/apple/notifications`
|
||||
- [x] `backend/src/v1/payments/dependencies.py` - DI wiring
|
||||
- [x] `backend/src/v1/router.py` - 注册 payments_router
|
||||
|
||||
### 2.7 测试 ✅
|
||||
|
||||
- [x] `backend/tests/unit/payments/test_payment_service.py` - 16 个测试全部通过
|
||||
- 验证流程: product_not_found / product_mismatch / verification_failed / already_granted / transaction_conflict / successful_grant / starter_pack_ineligible / starter_pack_success
|
||||
- 退款流程: refund_unknown / refund_not_granted / refund_sufficient_balance / refund_insufficient_balance / refund_idempotency
|
||||
- 通知处理: notification_refund / notification_empty / notification_non_refund
|
||||
- [x] `backend/tests/unit/payments/__init__.py` - verifier 基础测试
|
||||
- [x] `backend/tests/integration/payments/test_verify_flow.py` - 集成测试骨架
|
||||
- [x] basedpyright 类型检查通过(0 errors)
|
||||
- [x] 所有模块 import 正常
|
||||
|
||||
### 未实现(后续迭代)
|
||||
|
||||
- `GET /api/v1/payments/apple/transactions/{transactionId}` 查询接口
|
||||
- `apple_client.py` Apple Server API 主动查询客户端(可选)
|
||||
|
||||
### 2.2 核心实现
|
||||
|
||||
#### 2.2.1 Apple JWS 验签器
|
||||
|
||||
**文件**: `backend/src/v1/payments/apple_verifier.py`
|
||||
|
||||
职责:
|
||||
- 下载/缓存 Apple 根证书链
|
||||
- 验证 JWS 签名
|
||||
- 解析 payload 并验证字段:`bundleId`, `productId`, `transactionId`, `environment`, `revocationDate`
|
||||
- 返回结构化验证结果
|
||||
|
||||
#### 2.2.2 支付服务
|
||||
|
||||
**文件**: `backend/src/v1/payments/service.py`
|
||||
|
||||
核心方法:
|
||||
- `verify_and_grant(user_id, request) -> VerifyResponse`
|
||||
- 1. 验证 productCode 存在且启用
|
||||
- 2. 验证 appStoreProductId 与映射匹配
|
||||
- 3. 调用 Apple verifier 验签
|
||||
- 4. 检查 transaction 幂等(已发放返回 `already_granted`)
|
||||
- 5. 检查新手包资格
|
||||
- 6. 事务:创建/更新 `apple_iap_transactions` + 更新 `user_points` + 写入 `points_ledger` + 更新 `register_bonus_claims`
|
||||
|
||||
- `process_refund(transaction_id) -> None`
|
||||
- 1. 查询 `apple_iap_transactions`
|
||||
- 2. 事务:扣减积分 + 写入 `refund` 流水 + 更新状态
|
||||
- 3. 余额不足时设置 `refunded_insufficient` 并告警
|
||||
|
||||
#### 2.2.3 API 接口
|
||||
|
||||
**文件**: `backend/src/v1/payments/router.py`
|
||||
|
||||
```
|
||||
POST /api/v1/payments/apple/transactions/verify
|
||||
POST /api/v1/payments/apple/notifications # App Store Server Notifications V2
|
||||
GET /api/v1/payments/apple/transactions/{transactionId} # 可选
|
||||
```
|
||||
|
||||
### 2.3 配置扩展
|
||||
|
||||
**文件**: `backend/src/core/config/settings.py`
|
||||
|
||||
新增配置项:
|
||||
```python
|
||||
apple_iap: AppleIapSettings
|
||||
|
||||
class AppleIapSettings:
|
||||
bundle_id: str
|
||||
root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer"
|
||||
jws_issuer_id: str | None = None # Server API (可选)
|
||||
jws_key_id: str | None = None
|
||||
jws_private_key: str | None = None
|
||||
```
|
||||
|
||||
**文件**: `backend/src/core/config/static/packages/mapping.yaml` (新建)
|
||||
|
||||
```yaml
|
||||
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
|
||||
```
|
||||
|
||||
### 2.4 测试
|
||||
|
||||
**单元测试**:
|
||||
- `backend/tests/unit/payments/test_apple_verifier.py`
|
||||
- `backend/tests/unit/payments/test_payment_service.py`
|
||||
|
||||
**集成测试**:
|
||||
- `backend/tests/integration/payments/test_verify_flow.py`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: iOS / Flutter 接入 ✅ 已完成
|
||||
|
||||
### 3.1 依赖添加 ✅
|
||||
|
||||
- [x] `apps/pubspec.yaml` — `in_app_purchase: ^3.2.3` + `in_app_purchase_storekit: ^0.4.8` + `crypto: ^3.0.7`
|
||||
|
||||
### 3.2 后端配合变更 ✅
|
||||
|
||||
- [x] `backend/src/v1/points/schemas.py` — `PackageInfo` 新增 `appStoreProductId` 字段
|
||||
- [x] `backend/src/v1/points/service.py` — `PackageInfoResult` 新增 `app_store_product_id`,`get_available_packages` 从 mapping.yaml 加载映射
|
||||
- [x] `backend/src/v1/points/router.py` — 响应中包含 `appStoreProductId`
|
||||
|
||||
### 3.3 前端目录结构 ✅
|
||||
|
||||
```
|
||||
apps/lib/features/payments/
|
||||
├── data/
|
||||
│ ├── apis/
|
||||
│ │ └── apple_payment_api.dart # 后端 verify 接口
|
||||
│ ├── models/
|
||||
│ │ └── apple_purchase_models.dart # VerifyTransactionRequest/Response
|
||||
│ └── services/
|
||||
│ └── apple_iap_service.dart # StoreKit 集成服务
|
||||
```
|
||||
|
||||
### 3.4 核心实现 ✅
|
||||
|
||||
- [x] `apple_purchase_models.dart` — VerifyTransactionRequest(含 appAccountToken)/ VerifyTransactionResponse(含 newBalance/ledgerEventId)
|
||||
- [x] `apple_payment_api.dart` — POST /api/v1/payments/apple/transactions/verify
|
||||
- [x] `apple_iap_service.dart` — AppleIapService (ChangeNotifier):
|
||||
- 初始化 purchaseStream 监听
|
||||
- queryProductDetails 查询 StoreKit 商品
|
||||
- `buyConsumable` 传递 `applicationUserName`(appAccountToken = userId MD5 hash)
|
||||
- 购买成功 → 发送 JWS + appAccountToken 到后端验证 → completePurchase
|
||||
- 可重试错误(5xx/网络)不 complete,下次启动自动重试
|
||||
- 不可重试错误(4xx)complete 并暴露 ApiProblem 供 UI 映射 l10n
|
||||
- 暴露 `lastApiProblem` 供错误码映射
|
||||
- [x] `package_info.dart` — 新增 `appStoreProductId` 字段
|
||||
- [x] `settings_section_widgets.dart` — CoinPackageCard 新增 `onPurchase`/`isPurchasing`/`isAvailable`/`unavailableMessage`
|
||||
- [x] `coin_center_screen.dart` — 集成 AppleIapService:
|
||||
- 接收 `userId`/`onBalanceChanged` 参数
|
||||
- StoreKit 价格覆盖后端参考价格
|
||||
- 商品不可用时禁用卡片并显示提示
|
||||
- pending 状态显示 "Apple 正在处理中"
|
||||
- 购买成功后调用 `_refreshBalance` 并回调 `onBalanceChanged`
|
||||
- 使用 `mapApiProblemToMessage` 映射错误码到 l10n
|
||||
|
||||
### 3.5 调用链更新 ✅
|
||||
|
||||
- [x] `app.dart` — HomeScreen 传递 `userId` + `onBalanceChanged`
|
||||
- [x] `home_screen.dart` — `_ProfileTab` 传递 `userId` + `onBalanceChanged`
|
||||
- [x] `settings_screen.dart` — 传递 `userId` + `onBalanceChanged` 给 CoinCenterScreen
|
||||
|
||||
### 3.6 错误处理与本地化 ✅
|
||||
|
||||
- [x] `api_problem_mapper.dart` — 6 个支付错误码映射
|
||||
- [x] 3 个 ARB 文件新增 7 个 l10n key:
|
||||
- `paymentSuccess` / `paymentVerifyFailed` / `paymentProductNotFound` / `paymentStarterPackIneligible`
|
||||
- `paymentProductUnavailable` / `paymentPending`
|
||||
- [x] `flutter gen-l10n` 生成通过
|
||||
|
||||
### 3.7 前端测试 ✅
|
||||
|
||||
- [x] `apps/test/features/payments/data/models/apple_purchase_models_test.dart`
|
||||
- VerifyTransactionRequest.toJson 含/不含 appAccountToken
|
||||
- VerifyTransactionResponse 解析 granted / already_granted
|
||||
- [x] 4 个测试全部通过
|
||||
|
||||
### 3.8 验证 ✅
|
||||
|
||||
- [x] `flutter analyze` 0 issues
|
||||
- [x] 后端 basedpyright 0 errors
|
||||
- [x] 后端 16 个单元测试全部通过
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 联调与发布准备
|
||||
|
||||
### 4.1 App Store Connect 配置
|
||||
|
||||
- [x] 创建 4 个消耗型 IAP 商品(Product ID 已确认与映射表一致)
|
||||
- [x] Product ID 与映射表一致
|
||||
- `com.meeyao.qianwen.new_user_pack` — 新手包
|
||||
- `com.meeyao.qianwen.basic_pack` — 基础包
|
||||
- `com.meeyao.qianwen.popular_pack` — 热门包
|
||||
- `com.meeyao.qianwen.premium_pack` — 高级包
|
||||
- [ ] 配置价格和描述
|
||||
- [x] 创建沙盒测试账号:`qiuzhiliang@xunmee.com`
|
||||
- [ ] 配置 Server Notifications V2 URL(生产环境公网 URL)
|
||||
|
||||
### 4.2 后端配置 ✅
|
||||
|
||||
- [x] 环境变量:`APPLE_IAP_BUNDLE_ID=com.meeyao.qianwen`
|
||||
- [x] Server API 密钥配置(`T6M7J28MAQ` / `862a2cd0-ad6e-47c8-ac5a-bef5676c470b`)
|
||||
- [x] 日志检查:不打印完整 JWS
|
||||
- [x] 环境判断:根据 `ERYAO_RUNTIME__ENVIRONMENT` 自动切换 Sandbox/Production
|
||||
|
||||
### 4.3 开发环境准备 ✅
|
||||
|
||||
- [x] `.env.example` 更新 Apple IAP 配置模板
|
||||
- [x] `.env` 写入实际配置值
|
||||
- [x] `AppleIapSettings` 支持所有配置字段
|
||||
- [x] `EryaoProducts.storekit` Xcode 本地测试配置文件
|
||||
|
||||
### 4.4 测试清单
|
||||
|
||||
- [ ] Xcode StoreKit Configuration 本地测试
|
||||
- [ ] Sandbox 购买成功验证
|
||||
- [ ] Sandbox 退款测试
|
||||
- [ ] 网络中断后重启恢复
|
||||
- [ ] 新手包重复购买阻断
|
||||
- [ ] TestFlight 环境验证
|
||||
|
||||
---
|
||||
|
||||
## 预估工时
|
||||
|
||||
| Phase | 工作项 | 预估时间 |
|
||||
|-------|--------|----------|
|
||||
| 1 | 枚举扩展 + 数据库迁移 | 2-3h |
|
||||
| 2 | 后端支付服务 + 测试 | 1-2 天 |
|
||||
| 3 | Flutter IAP 接入 | 1 天 |
|
||||
| 4 | 联调与发布准备 | 0.5-1 天 |
|
||||
|
||||
**总计**: 3-4 天
|
||||
|
||||
---
|
||||
|
||||
## 风险与依赖
|
||||
|
||||
| 风险/依赖 | 缓解措施 |
|
||||
|----------|---------|
|
||||
| App Store Connect 配置权限 | 提前确认账号权限,Phase 1 同步申请 |
|
||||
| `in_app_purchase` 插件无法暴露 signedData | 预研后确定是否需要 platform channel |
|
||||
| Apple 服务不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易 |
|
||||
@@ -119,7 +119,7 @@
|
||||
| `app_account_token` | UUID null | 客户端购买时传入的用户绑定 token,用于降低串单风险 |
|
||||
| `purchase_date` | timestamptz not null | Apple 交易购买时间 |
|
||||
| `revocation_date` | timestamptz null | Apple 撤销/退款时间 |
|
||||
| `status` | varchar not null | `received`、`verified`、`granted`、`failed`、`refunded`、`revoked` |
|
||||
| `status` | varchar not null | `received`、`verified`、`granted`、`failed`、`refunded`、`refunded_insufficient`、`revoked` |
|
||||
| `credits` | bigint not null | 本次应发积分,由后端套餐配置决定 |
|
||||
| `currency` | varchar null | Apple 或后端记录的币种 |
|
||||
| `price_milliunits` | bigint null | 如 Apple JWS 提供则保存,单位按 Apple 字段定义 |
|
||||
@@ -142,6 +142,7 @@
|
||||
新增积分变更类型:
|
||||
|
||||
- `PointsChangeType.PURCHASE = 'purchase'`
|
||||
- `PointsChangeType.REFUND = 'refund'`
|
||||
|
||||
新增业务类型:
|
||||
|
||||
@@ -149,12 +150,14 @@
|
||||
|
||||
约束调整:
|
||||
|
||||
- `change_type` 允许 `purchase`。
|
||||
- `change_type` 允许 `purchase`、`refund`。
|
||||
- `biz_type` 允许 `payment`。
|
||||
- `purchase` 必须 `direction = 1`。
|
||||
- `purchase` 必须 `biz_type = 'payment'` 且 `biz_id` 不为空。
|
||||
- `purchase` 的 `biz_id` 保存 `apple_iap_transactions.id`。
|
||||
- `refund` 必须 `direction = -1`。
|
||||
- `purchase/refund` 必须 `biz_type = 'payment'` 且 `biz_id` 不为空。
|
||||
- `purchase/refund` 的 `biz_id` 保存 `apple_iap_transactions.id`。
|
||||
- `purchase` 的 `event_id` 建议为 `payment.apple_iap:{transaction_id}`。
|
||||
- `refund` 的 `event_id` 建议为 `refund.apple_iap:{transaction_id}`。
|
||||
|
||||
`points_ledger.metadata.ext` 建议保存:
|
||||
|
||||
@@ -171,6 +174,16 @@
|
||||
}
|
||||
```
|
||||
|
||||
对于 `refund`,额外保存:
|
||||
|
||||
```json
|
||||
{
|
||||
"original_event_id": "payment.apple_iap:1000000123456789",
|
||||
"refund_reason": "CUSTOMER_REQUEST",
|
||||
"overdue_amount": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 `points_audit_ledger` 是否扩展
|
||||
|
||||
本期不强制把购买写入 `points_audit_ledger`。支付审计以 `apple_iap_transactions` 为准,用户积分变更以 `points_ledger` 为准。
|
||||
@@ -304,12 +317,46 @@
|
||||
|
||||
### 7.4 退款与撤销
|
||||
|
||||
本期至少保存 `revocationDate` 和交易状态,不自动扣回已发放积分。
|
||||
当收到 Apple 退款/撤销通知时,必须扣回已发放积分,避免积分被白嫖。
|
||||
|
||||
后续建议接入 App Store Server Notifications V2:
|
||||
**处理流程:**
|
||||
|
||||
- 收到退款/撤销通知后按 `transactionId` 更新 `apple_iap_transactions.status`。
|
||||
- 是否扣回积分需要产品另行决策。考虑积分可能已消耗,直接扣回可能导致负余额或复杂追偿,本期不默认实现。
|
||||
1. 后台任务或 App Store Server Notifications V2 收到退款/撤销事件。
|
||||
2. 按 `transactionId` 查询 `apple_iap_transactions`。
|
||||
3. 如果 `status != 'granted'`,忽略(未发放无需扣回)。
|
||||
4. 开启数据库事务:
|
||||
- 锁定 `user_points` 行。
|
||||
- 如果 `balance < credits`(余额不足以扣回):
|
||||
- 设置 `apple_iap_transactions.status = 'refunded_insufficient'`。
|
||||
- 记录 `failure_code = 'INSUFFICIENT_BALANCE'`。
|
||||
- 写入 `points_ledger` 的 `refund` 流水,`amount = balance`(扣到 0 为止)。
|
||||
- 事务提交后触发告警,等待人工处理。
|
||||
- 如果 `balance >= credits`:
|
||||
- 扣减 `user_points.balance` 和 `lifetime_earned`。
|
||||
- 写入 `points_ledger` 的 `refund` 流水。
|
||||
- 更新 `apple_iap_transactions.status = 'refunded'`。
|
||||
- 事务提交。
|
||||
5. `points_ledger` 的 `refund` 记录:
|
||||
- `change_type = 'refund'`
|
||||
- `direction = -1`
|
||||
- `biz_type = 'payment'`
|
||||
- `biz_id = apple_iap_transactions.id`
|
||||
- `event_id = refund.apple_iap:{transaction_id}`
|
||||
- `metadata.ext` 保存退款相关快照。
|
||||
|
||||
**App Store Server Notifications V2 接入(建议):**
|
||||
|
||||
- 配置 App Store Connect 的 Server Notifications URL。
|
||||
- 后端实现 `POST /api/v1/payments/apple/notifications` 接收 Apple 推送。
|
||||
- 解析 notification type:`REFUND`、`REVOKE` 等。
|
||||
- 按上述流程处理退款/撤销。
|
||||
|
||||
**余额不足时的处理策略:**
|
||||
|
||||
- 不允许用户余额变为负数。
|
||||
- 扣到 0 为止,剩余欠款记录在 `apple_iap_transactions.metadata.overdue_amount`。
|
||||
- 触发运营告警,人工决定是否追偿或标记坏账。
|
||||
- 后续用户充值时,可考虑优先补扣欠款(需要产品决策,本期不实现)。
|
||||
|
||||
## 8. iOS / Flutter 接入设计
|
||||
|
||||
@@ -415,6 +462,9 @@ apps/lib/features/payments/
|
||||
- 新手包第一次购买成功,第二次返回 `PAYMENT_STARTER_PACK_INELIGIBLE`。
|
||||
- 入账后 `user_points.balance/lifetime_earned` 正确增加。
|
||||
- 入账后 `points_ledger` 写入 `purchase/payment`。
|
||||
- 退款时余额充足:正确扣减积分,写入 `refund` 流水,状态变为 `refunded`。
|
||||
- 退款时余额不足:扣到 0,状态变为 `refunded_insufficient`,触发告警路径。
|
||||
- 重复退款通知:幂等处理,不重复扣减。
|
||||
|
||||
### 12.2 后端集成测试
|
||||
|
||||
@@ -448,11 +498,15 @@ apps/lib/features/payments/
|
||||
|
||||
### Phase 2:后端支付服务
|
||||
|
||||
1. 新增 `v1/payments/` 模块。
|
||||
2. 实现 Apple transaction verifier 抽象。
|
||||
3. 实现支付校验、幂等和积分入账服务。
|
||||
4. 接入 `v1/router.py`。
|
||||
5. 添加单元测试和集成测试。
|
||||
1. 更新协议文档和错误码。
|
||||
2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。
|
||||
3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。
|
||||
4. 新增 `v1/payments/` 模块。
|
||||
5. 实现 Apple transaction verifier 抽象。
|
||||
6. 实现支付校验、幂等和积分入账服务。
|
||||
7. 实现退款/撤销处理服务(扣回积分)。
|
||||
8. 接入 `v1/router.py`。
|
||||
9. 添加单元测试和集成测试。
|
||||
|
||||
### Phase 3:iOS / Flutter
|
||||
|
||||
@@ -466,8 +520,10 @@ apps/lib/features/payments/
|
||||
|
||||
1. 配置 App Store Connect 商品。
|
||||
2. 配置后端 Apple 验签参数。
|
||||
3. Sandbox 和 TestFlight 测试。
|
||||
4. 检查日志、错误码、补偿路径。
|
||||
3. 配置 App Store Server Notifications V2 URL。
|
||||
4. Sandbox 和 TestFlight 测试。
|
||||
5. 测试退款/撤销流程(使用 Sandbox 退款功能)。
|
||||
6. 检查日志、错误码、补偿路径。
|
||||
|
||||
## 14. 风险与缓解
|
||||
|
||||
@@ -478,7 +534,8 @@ apps/lib/features/payments/
|
||||
| 商品配置不一致 | 后端维护 `productCode -> App Store Product ID` 映射并强校验 |
|
||||
| 新手包被重复购买 | 后端事务内锁定/更新 `register_bonus_claims` |
|
||||
| Apple 服务临时不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易稍后重试 |
|
||||
| 退款后积分已消耗 | 本期只记录退款状态,不自动扣回;后续单独设计追偿策略 |
|
||||
| 退款时余额不足扣回 | 扣到 0 为止,记录欠款,触发告警等待人工处理 |
|
||||
| 退款通知丢失或延迟 | 后台定时任务查询 Apple Server API 获取交易状态变更 |
|
||||
| Flutter 插件无法暴露 StoreKit2 signedData | 增加最小 iOS platform channel 获取 `Transaction.signedData` |
|
||||
|
||||
## 15. 成功标准
|
||||
@@ -488,4 +545,5 @@ apps/lib/features/payments/
|
||||
- 新手包同一邮箱身份只能发放一次。
|
||||
- 支付入账后 `user_points`、`points_ledger`、`apple_iap_transactions` 数据一致。
|
||||
- 网络中断/重复提交不会导致漏发或重复发放。
|
||||
- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。
|
||||
- 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Workspace Index - opencode
|
||||
|
||||
> Journal tracking for AI development sessions.
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 0
|
||||
- **Last Active**: -
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
---
|
||||
|
||||
## Active Documents
|
||||
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~0 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits |
|
||||
|---|------|-------|---------|
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
@@ -0,0 +1,7 @@
|
||||
# Journal - opencode (Part 1)
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-04-27
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"identifier" : "EryaoProducts",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "0.99",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "new_user_pack_001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "新用户专属优惠套餐",
|
||||
"displayName" : "新手包",
|
||||
"locale" : "zh_CN"
|
||||
}
|
||||
],
|
||||
"productID" : "com.meeyao.qianwen.new_user_pack",
|
||||
"referenceName" : "新手包",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "6.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "basic_pack_001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "基础信用点套餐",
|
||||
"displayName" : "基础包",
|
||||
"locale" : "zh_CN"
|
||||
}
|
||||
],
|
||||
"productID" : "com.meeyao.qianwen.basic_pack",
|
||||
"referenceName" : "基础包",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "18.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "popular_pack_001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "热门信用点套餐",
|
||||
"displayName" : "热门包",
|
||||
"locale" : "zh_CN"
|
||||
}
|
||||
],
|
||||
"productID" : "com.meeyao.qianwen.popular_pack",
|
||||
"referenceName" : "热门包",
|
||||
"type" : "Consumable"
|
||||
},
|
||||
{
|
||||
"displayPrice" : "68.00",
|
||||
"familyShareable" : false,
|
||||
"internalID" : "premium_pack_001",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "高级信用点套餐",
|
||||
"displayName" : "高级包",
|
||||
"locale" : "zh_CN"
|
||||
}
|
||||
],
|
||||
"productID" : "com.meeyao.qianwen.premium_pack",
|
||||
"referenceName" : "高级包",
|
||||
"type" : "Consumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6738123456",
|
||||
"_developerTeamID" : "YOUR_TEAM_ID",
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 756460800,
|
||||
"_locale" : "zh_CN",
|
||||
"_storefront" : "CHN",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -412,6 +412,15 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
});
|
||||
}
|
||||
|
||||
void _handleBalanceChanged(int newBalance) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_creditsBalance = newBalance;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
@@ -451,6 +460,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
_refreshProfile(userEmail: state.user!.email);
|
||||
return HomeScreen(
|
||||
account: state.user!.email,
|
||||
userId: state.user!.id,
|
||||
sessionStore: _sessionStore,
|
||||
currentLocale: _locale,
|
||||
profileSettings: _profileSettings,
|
||||
@@ -467,6 +477,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
onDeleteHistorySession: _handleHistorySessionDeleted,
|
||||
onLogout: _authBloc.logout,
|
||||
onDeleteAccount: _deleteAccount,
|
||||
onBalanceChanged: _handleBalanceChanged,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ class Env {
|
||||
if (Platform.isAndroid) {
|
||||
return 'http://10.0.2.2:5775';
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
return 'http://192.168.1.63:5775';
|
||||
}
|
||||
return 'http://localhost:5775';
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
|
||||
return l10n.errorAsrUnavailable;
|
||||
case 'PROFILE_DELETE_FAILED':
|
||||
return l10n.errorProfileDeleteFailed;
|
||||
case 'PAYMENT_PRODUCT_NOT_FOUND':
|
||||
return l10n.paymentProductNotFound;
|
||||
case 'PAYMENT_PRODUCT_MISMATCH':
|
||||
return l10n.paymentVerifyFailed;
|
||||
case 'PAYMENT_ENVIRONMENT_MISMATCH':
|
||||
return l10n.paymentVerifyFailed;
|
||||
case 'PAYMENT_TRANSACTION_INVALID':
|
||||
return l10n.paymentVerifyFailed;
|
||||
case 'PAYMENT_TRANSACTION_REVOKED':
|
||||
return l10n.paymentVerifyFailed;
|
||||
case 'PAYMENT_TRANSACTION_CONFLICT':
|
||||
return l10n.paymentVerifyFailed;
|
||||
case 'PAYMENT_STARTER_PACK_INELIGIBLE':
|
||||
return l10n.paymentStarterPackIneligible;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return PopScope<void>(
|
||||
canPop: false,
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
return;
|
||||
|
||||
@@ -27,6 +27,7 @@ class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.sessionStore,
|
||||
required this.currentLocale,
|
||||
required this.profileSettings,
|
||||
@@ -43,9 +44,11 @@ class HomeScreen extends StatefulWidget {
|
||||
required this.onDeleteHistorySession,
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final SessionStore sessionStore;
|
||||
final Locale currentLocale;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
@@ -65,6 +68,7 @@ class HomeScreen extends StatefulWidget {
|
||||
final Future<void> Function(String threadId) onDeleteHistorySession;
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
@@ -132,6 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_ProfileTab(
|
||||
account: widget.account,
|
||||
userId: widget.userId,
|
||||
settings: widget.profileSettings,
|
||||
coinBalance: widget.coinBalance,
|
||||
inviteRepository: _inviteRepository,
|
||||
@@ -142,6 +147,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onUploadAvatar: widget.onUploadAvatar,
|
||||
onLogout: widget.onLogout,
|
||||
onDeleteAccount: widget.onDeleteAccount,
|
||||
onBalanceChanged: widget.onBalanceChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
|
||||
class _ProfileTab extends StatelessWidget {
|
||||
const _ProfileTab({
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
@@ -571,9 +578,11 @@ class _ProfileTab extends StatelessWidget {
|
||||
required this.onUploadAvatar,
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
@@ -585,11 +594,13 @@ class _ProfileTab extends StatelessWidget {
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsScreen(
|
||||
account: account,
|
||||
userId: userId,
|
||||
settings: settings,
|
||||
coinBalance: coinBalance,
|
||||
inviteRepository: inviteRepository,
|
||||
@@ -600,6 +611,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
onUploadAvatar: onUploadAvatar,
|
||||
onLogout: onLogout,
|
||||
onDeleteAccount: onDeleteAccount,
|
||||
onBalanceChanged: onBalanceChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -703,15 +715,24 @@ class _WelcomeDialog extends StatefulWidget {
|
||||
class _WelcomeDialogState extends State<_WelcomeDialog> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasScrolledToBottom = false;
|
||||
bool _hasCheckedInitialScroll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_hasCheckedInitialScroll) {
|
||||
_hasCheckedInitialScroll = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_syncScrollState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -730,7 +751,7 @@ class _WelcomeDialogState extends State<_WelcomeDialog> {
|
||||
}
|
||||
final max = _scrollController.position.maxScrollExtent;
|
||||
final current = _scrollController.offset;
|
||||
final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md;
|
||||
final canReadAll = max <= 50.0 || current >= max - AppSpacing.md;
|
||||
if (_hasScrolledToBottom == canReadAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
class ApplePaymentApi {
|
||||
const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<VerifyTransactionResponse> verifyTransaction(
|
||||
VerifyTransactionRequest request,
|
||||
) async {
|
||||
final json = await _apiClient.postJson(
|
||||
'/api/v1/payments/apple/transactions/verify',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return VerifyTransactionResponse.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
class VerifyTransactionRequest {
|
||||
const VerifyTransactionRequest({
|
||||
required this.productCode,
|
||||
required this.appStoreProductId,
|
||||
required this.transactionId,
|
||||
required this.signedTransactionInfo,
|
||||
this.appAccountToken,
|
||||
});
|
||||
|
||||
final String productCode;
|
||||
final String appStoreProductId;
|
||||
final String transactionId;
|
||||
final String signedTransactionInfo;
|
||||
final String? appAccountToken;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'productCode': productCode,
|
||||
'appStoreProductId': appStoreProductId,
|
||||
'transactionId': transactionId,
|
||||
'signedTransactionInfo': signedTransactionInfo,
|
||||
if (appAccountToken != null) 'appAccountToken': appAccountToken,
|
||||
};
|
||||
}
|
||||
|
||||
enum VerifyTransactionStatus { granted, alreadyGranted }
|
||||
|
||||
class VerifyTransactionResponse {
|
||||
const VerifyTransactionResponse({
|
||||
required this.status,
|
||||
required this.productCode,
|
||||
required this.transactionId,
|
||||
required this.creditsAdded,
|
||||
required this.newBalance,
|
||||
required this.ledgerEventId,
|
||||
});
|
||||
|
||||
final VerifyTransactionStatus status;
|
||||
final String productCode;
|
||||
final String transactionId;
|
||||
final int creditsAdded;
|
||||
final int newBalance;
|
||||
final String ledgerEventId;
|
||||
|
||||
factory VerifyTransactionResponse.fromJson(Map<String, dynamic> json) {
|
||||
return VerifyTransactionResponse(
|
||||
status: json['status'] == 'already_granted'
|
||||
? VerifyTransactionStatus.alreadyGranted
|
||||
: VerifyTransactionStatus.granted,
|
||||
productCode: json['productCode'] as String,
|
||||
transactionId: json['transactionId'] as String,
|
||||
creditsAdded: json['creditsAdded'] as int,
|
||||
newBalance: json['newBalance'] as int,
|
||||
ledgerEventId: json['ledgerEventId'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../apis/apple_payment_api.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
|
||||
|
||||
class PurchaseResult {
|
||||
const PurchaseResult({
|
||||
required this.state,
|
||||
this.creditsAdded,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final PurchaseFlowState state;
|
||||
final int? creditsAdded;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
class AppleIapService with ChangeNotifier {
|
||||
AppleIapService({
|
||||
required ApiClient apiClient,
|
||||
required String userId,
|
||||
}) : _paymentApi = ApplePaymentApi(apiClient: apiClient),
|
||||
_inAppPurchase = InAppPurchase.instance,
|
||||
_appAccountToken = _hashUserId(userId);
|
||||
|
||||
final ApplePaymentApi _paymentApi;
|
||||
final InAppPurchase _inAppPurchase;
|
||||
final String? _appAccountToken;
|
||||
final Logger _logger = getLogger('features.payments.apple_iap_service');
|
||||
|
||||
static String? _hashUserId(String userId) {
|
||||
if (userId.isEmpty) return null;
|
||||
final bytes = utf8.encode(userId);
|
||||
final digest = md5.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||
Map<String, ProductDetails> _storeKitProducts = {};
|
||||
PurchaseFlowState _state = PurchaseFlowState.idle;
|
||||
String? _lastError;
|
||||
ApiProblem? _lastApiProblem;
|
||||
|
||||
PurchaseFlowState get state => _state;
|
||||
String? get lastError => _lastError;
|
||||
ApiProblem? get lastApiProblem => _lastApiProblem;
|
||||
|
||||
void init() {
|
||||
final Stream<List<PurchaseDetails>> purchaseUpdated;
|
||||
purchaseUpdated = _inAppPurchase.purchaseStream;
|
||||
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
|
||||
}
|
||||
|
||||
Future<void> loadStoreKitProducts(List<PackageInfo> packages) async {
|
||||
final ids = packages
|
||||
.map((p) => p.appStoreProductId)
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final response = await _inAppPurchase.queryProductDetails(ids);
|
||||
if (response.notFoundIDs.isNotEmpty) {
|
||||
_logger.warning(
|
||||
message: 'Some StoreKit products not found',
|
||||
extra: {'notFound': response.notFoundIDs.join(', ')},
|
||||
);
|
||||
}
|
||||
|
||||
final products = <String, ProductDetails>{};
|
||||
for (final detail in response.productDetails) {
|
||||
products[detail.id] = detail;
|
||||
}
|
||||
_storeKitProducts = products;
|
||||
}
|
||||
|
||||
ProductDetails? getStoreKitProduct(String appStoreProductId) {
|
||||
return _storeKitProducts[appStoreProductId];
|
||||
}
|
||||
|
||||
Future<bool> purchase(PackageInfo package) async {
|
||||
if (_state == PurchaseFlowState.purchasing ||
|
||||
_state == PurchaseFlowState.verifying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final product = _storeKitProducts[package.appStoreProductId];
|
||||
if (product == null) {
|
||||
_logger.warning(
|
||||
message: 'StoreKit product not found for purchase',
|
||||
extra: {'productId': package.appStoreProductId},
|
||||
);
|
||||
_setError('Product not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
|
||||
final purchaseParam = PurchaseParam(
|
||||
productDetails: product,
|
||||
applicationUserName: _appAccountToken,
|
||||
);
|
||||
final bought = await _inAppPurchase.buyConsumable(
|
||||
purchaseParam: purchaseParam,
|
||||
);
|
||||
|
||||
if (!bought) {
|
||||
_setError('Failed to initiate purchase');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
|
||||
for (final purchase in purchases) {
|
||||
_handlePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PurchaseDetails purchase) async {
|
||||
switch (purchase.status) {
|
||||
case PurchaseStatus.purchased:
|
||||
await _verifyAndComplete(purchase);
|
||||
case PurchaseStatus.canceled:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setState(PurchaseFlowState.idle);
|
||||
case PurchaseStatus.error:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setError(purchase.error?.message ?? 'Purchase failed');
|
||||
case PurchaseStatus.pending:
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
case PurchaseStatus.restored:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyAndComplete(PurchaseDetails purchase) async {
|
||||
_setState(PurchaseFlowState.verifying);
|
||||
|
||||
try {
|
||||
final request = VerifyTransactionRequest(
|
||||
productCode: _productCodeFromStoreKitId(purchase.productID),
|
||||
appStoreProductId: purchase.productID,
|
||||
transactionId: purchase.purchaseID ?? '',
|
||||
signedTransactionInfo: purchase.verificationData.serverVerificationData,
|
||||
appAccountToken: _appAccountToken,
|
||||
);
|
||||
|
||||
final response = await _paymentApi.verifyTransaction(request);
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
|
||||
_state = PurchaseFlowState.success;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
_logger.info(
|
||||
message: 'Purchase verified and completed',
|
||||
extra: {
|
||||
'transactionId': purchase.purchaseID,
|
||||
'creditsAdded': response.creditsAdded,
|
||||
'status': response.status.name,
|
||||
},
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Purchase verification failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
extra: {'transactionId': purchase.purchaseID},
|
||||
);
|
||||
|
||||
if (_isRetryableError(e)) {
|
||||
_setState(PurchaseFlowState.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
if (e is ApiProblem) {
|
||||
_lastApiProblem = e;
|
||||
_lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed';
|
||||
} else {
|
||||
_lastApiProblem = null;
|
||||
_lastError = _extractErrorMessage(e);
|
||||
}
|
||||
_state = PurchaseFlowState.failed;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRetryableError(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.status >= 500 || error.status == 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _extractErrorMessage(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.detail.isNotEmpty ? error.detail : 'Verification failed';
|
||||
}
|
||||
return 'Verification failed';
|
||||
}
|
||||
|
||||
String _productCodeFromStoreKitId(String storeKitId) {
|
||||
return switch (storeKitId) {
|
||||
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
|
||||
'com.meeyao.qianwen.basic_pack' => 'basic_pack',
|
||||
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
|
||||
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
|
||||
_ => storeKitId,
|
||||
};
|
||||
}
|
||||
|
||||
void _setState(PurchaseFlowState state) {
|
||||
_state = state;
|
||||
if (state != PurchaseFlowState.failed) {
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_state = PurchaseFlowState.failed;
|
||||
_lastError = message;
|
||||
_lastApiProblem = null;
|
||||
_logger.warning(message: 'Purchase flow error', extra: {'error': message});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetState() {
|
||||
_state = PurchaseFlowState.idle;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ enum PackageType { starter, regular }
|
||||
class PackageInfo {
|
||||
const PackageInfo({
|
||||
required this.productCode,
|
||||
required this.appStoreProductId,
|
||||
required this.type,
|
||||
required this.price,
|
||||
required this.credits,
|
||||
@@ -14,6 +15,7 @@ class PackageInfo {
|
||||
});
|
||||
|
||||
final ProductCode productCode;
|
||||
final String appStoreProductId;
|
||||
final PackageType type;
|
||||
final double price;
|
||||
final int credits;
|
||||
@@ -24,6 +26,7 @@ class PackageInfo {
|
||||
factory PackageInfo.fromJson(Map<String, dynamic> json) {
|
||||
return PackageInfo(
|
||||
productCode: _parseProductCode(json['productCode'] as String),
|
||||
appStoreProductId: json['appStoreProductId'] as String,
|
||||
type: json['type'] == 'starter'
|
||||
? PackageType.starter
|
||||
: PackageType.regular,
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem_mapper.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../../data/storage/local_kv_store.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../payments/data/services/apple_iap_service.dart';
|
||||
import '../../../points/data/apis/points_api.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class CoinCenterScreen extends StatefulWidget {
|
||||
const CoinCenterScreen({super.key, required this.balance});
|
||||
const CoinCenterScreen({
|
||||
super.key,
|
||||
required this.balance,
|
||||
required this.userId,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final int balance;
|
||||
final String userId;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
|
||||
@@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
final Logger _logger = getLogger('features.settings.coin_center_screen');
|
||||
List<PackageInfo>? _packages;
|
||||
bool _isLoading = true;
|
||||
AppleIapService? _iapService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
|
||||
final api = PointsApi(apiClient.rawDio);
|
||||
final result = await api.getPackages();
|
||||
|
||||
final service = AppleIapService(
|
||||
apiClient: apiClient,
|
||||
userId: widget.userId,
|
||||
);
|
||||
service.init();
|
||||
service.addListener(_onPurchaseStateChanged);
|
||||
|
||||
if (await InAppPurchase.instance.isAvailable()) {
|
||||
await service.loadStoreKitProducts(result.packages);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
_isLoading = false;
|
||||
_iapService = service;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
@@ -61,6 +90,60 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onPurchaseStateChanged() {
|
||||
final service = _iapService;
|
||||
if (service == null || !mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (service.state) {
|
||||
case PurchaseFlowState.success:
|
||||
Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
|
||||
_refreshBalance();
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.failed:
|
||||
final apiProblem = service.lastApiProblem;
|
||||
final error = apiProblem != null
|
||||
? mapApiProblemToMessage(apiProblem, l10n)
|
||||
: (service.lastError ?? l10n.paymentVerifyFailed);
|
||||
Toast.show(context, error, type: ToastType.error);
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.purchasing:
|
||||
case PurchaseFlowState.verifying:
|
||||
case PurchaseFlowState.idle:
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _refreshBalance() async {
|
||||
try {
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
final response = await apiClient.getJson('/api/v1/points/balance');
|
||||
final newBalance = response['availableBalance'] as int;
|
||||
widget.onBalanceChanged(newBalance);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.warning(
|
||||
message: 'Failed to refresh balance after purchase: $e',
|
||||
extra: {'stackTrace': stackTrace.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_iapService?.removeListener(_onPurchaseStateChanged);
|
||||
_iapService?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -120,13 +203,13 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SectionLabel(text: l10n.settingsCoinRechargeSection),
|
||||
..._buildPackageCards(l10n),
|
||||
..._buildPackageCards(l10n, colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n) {
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n, ColorScheme colors) {
|
||||
if (_isLoading) {
|
||||
return [
|
||||
const Padding(
|
||||
@@ -140,22 +223,66 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
return [];
|
||||
}
|
||||
|
||||
final isBusy = _iapService?.state == PurchaseFlowState.purchasing ||
|
||||
_iapService?.state == PurchaseFlowState.verifying;
|
||||
final isPending = _iapService?.state == PurchaseFlowState.purchasing;
|
||||
|
||||
return List.generate(_packages!.length, (index) {
|
||||
final pkg = _packages![index];
|
||||
final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
final isAvailable = storeKitProduct != null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: AppSpacing.md),
|
||||
if (isPending && !isBusy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Text(
|
||||
l10n.paymentPending,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
CoinPackageCard(
|
||||
title: _getPackageTitle(pkg, l10n),
|
||||
price: pkg.priceDisplay,
|
||||
price: _getDisplayPrice(pkg),
|
||||
amount: pkg.credits,
|
||||
badge: _getPackageBadge(pkg, l10n),
|
||||
onPurchase: () => _handlePurchase(pkg),
|
||||
isPurchasing: isBusy,
|
||||
isAvailable: isAvailable,
|
||||
unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _getDisplayPrice(PackageInfo pkg) {
|
||||
final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
if (product != null) {
|
||||
return product.price;
|
||||
}
|
||||
return pkg.priceDisplay;
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PackageInfo pkg) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_iapService == null) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isIOS) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
await _iapService!.purchase(pkg);
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
|
||||
@@ -34,19 +34,11 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return PopScope<ProfileSettingsV1>(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(_settings);
|
||||
},
|
||||
child: Scaffold(
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(_settings),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
title: Text(l10n.settingsGeneralTitle),
|
||||
@@ -145,7 +137,6 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ class _BindCodeSection extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isBinding ? null : onBind,
|
||||
onPressed: null,
|
||||
style: FilledButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -415,16 +415,7 @@ class _BindCodeSection extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: isBinding
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(l10n.settingsInviteBindButton),
|
||||
child: Text(l10n.settingsComingSoon),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../models/legal_document_type.dart';
|
||||
import '../utils/legal_document_assets.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'legal_document_screen.dart';
|
||||
|
||||
class LegalCenterScreen extends StatelessWidget {
|
||||
const LegalCenterScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final locale = Localizations.localeOf(context);
|
||||
final documents = [
|
||||
LegalDocumentType.aboutUs,
|
||||
LegalDocumentType.privacyPolicy,
|
||||
LegalDocumentType.termsOfService,
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsLegalCenterTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
SectionLabel(text: l10n.settingsSectionAbout),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
for (int i = 0; i < documents.length; i++)
|
||||
SettingsMenuTile(
|
||||
icon: legalDocumentIcon(documents[i]),
|
||||
title: legalDocumentTitle(l10n, documents[i]),
|
||||
subtitle: legalDocumentSubtitle(l10n, documents[i]),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: i != documents.length - 1,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LegalDocumentScreen(
|
||||
title: legalDocumentTitle(l10n, documents[i]),
|
||||
assetPath: legalDocumentAssetPath(locale, documents[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,22 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../data/models/profile_settings.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
import '../models/legal_document_type.dart';
|
||||
import '../utils/legal_document_assets.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
import 'feedback_screen.dart';
|
||||
import 'general_settings_screen.dart';
|
||||
import 'invite_screen.dart';
|
||||
import 'legal_center_screen.dart';
|
||||
import 'legal_document_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({
|
||||
super.key,
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
@@ -30,9 +33,11 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onSaveProfile,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
@@ -44,6 +49,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -119,35 +125,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openInvite,
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: l10n.settingsLegalCenterTitle,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: _openLegalCenter,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.feedback_outlined,
|
||||
title: l10n.settingsFeedbackTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => FeedbackScreen(apiClient: widget.apiClient),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.person_rounded,
|
||||
title: l10n.settingsAccountAndDataTitle,
|
||||
@@ -159,6 +147,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: l10n.aboutUs,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.aboutUs),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.security_rounded,
|
||||
title: l10n.privacyPolicy,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: l10n.termsOfService,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.termsOfService),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
FilledButton(
|
||||
onPressed: _confirmLogout,
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -180,7 +195,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Future<void> _openCoinCenter() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CoinCenterScreen(balance: widget.coinBalance),
|
||||
builder: (_) => CoinCenterScreen(
|
||||
balance: widget.coinBalance,
|
||||
userId: widget.userId,
|
||||
onBalanceChanged: widget.onBalanceChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -229,9 +248,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLegalCenter() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
|
||||
void _openLegalDocument(LegalDocumentType type) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final locale = Localizations.localeOf(context);
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LegalDocumentScreen(
|
||||
title: legalDocumentTitle(l10n, type),
|
||||
assetPath: legalDocumentAssetPath(locale, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class SectionLabel extends StatelessWidget {
|
||||
const SectionLabel({super.key, required this.text});
|
||||
@@ -420,12 +418,20 @@ class CoinPackageCard extends StatelessWidget {
|
||||
required this.price,
|
||||
required this.amount,
|
||||
this.badge,
|
||||
this.onPurchase,
|
||||
this.isPurchasing = false,
|
||||
this.isAvailable = true,
|
||||
this.unavailableMessage,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String price;
|
||||
final int amount;
|
||||
final String? badge;
|
||||
final VoidCallback? onPurchase;
|
||||
final bool isPurchasing;
|
||||
final bool isAvailable;
|
||||
final String? unavailableMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -483,6 +489,14 @@ class CoinPackageCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
if (!isAvailable && unavailableMessage != null)
|
||||
Text(
|
||||
unavailableMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.error,
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
@@ -493,19 +507,22 @@ class CoinPackageCard extends StatelessWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Toast.show(
|
||||
context,
|
||||
l10n.settingsPurchasePending,
|
||||
type: ToastType.info,
|
||||
);
|
||||
},
|
||||
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: Text(l10n.settingsPurchaseButton),
|
||||
child: isPurchasing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: Text(l10n.settingsPurchaseButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -505,5 +505,11 @@
|
||||
"feedbackContentRequired": "Please enter feedback content",
|
||||
"feedbackContentTooLong": "Feedback content cannot exceed 500 characters",
|
||||
"feedbackTooManyImages": "Maximum 3 images allowed",
|
||||
"feedbackImageTooLarge": "Image size cannot exceed 5MB"
|
||||
"feedbackImageTooLarge": "Image size cannot exceed 5MB",
|
||||
"paymentSuccess": "Purchase successful",
|
||||
"paymentVerifyFailed": "Purchase verification failed, please try again later",
|
||||
"paymentProductNotFound": "Product temporarily unavailable",
|
||||
"paymentStarterPackIneligible": "Starter pack is limited to one purchase per user",
|
||||
"paymentProductUnavailable": "Product temporarily unavailable",
|
||||
"paymentPending": "Apple is processing, please wait"
|
||||
}
|
||||
|
||||
@@ -2432,6 +2432,42 @@ abstract class AppLocalizations {
|
||||
/// In zh, this message translates to:
|
||||
/// **'图片大小不能超过5MB'**
|
||||
String get feedbackImageTooLarge;
|
||||
|
||||
/// No description provided for @paymentSuccess.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'购买成功'**
|
||||
String get paymentSuccess;
|
||||
|
||||
/// No description provided for @paymentVerifyFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'购买验证失败,请稍后重试'**
|
||||
String get paymentVerifyFailed;
|
||||
|
||||
/// No description provided for @paymentProductNotFound.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'商品暂时不可用'**
|
||||
String get paymentProductNotFound;
|
||||
|
||||
/// No description provided for @paymentStarterPackIneligible.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新手包每位用户仅限购买一次'**
|
||||
String get paymentStarterPackIneligible;
|
||||
|
||||
/// No description provided for @paymentProductUnavailable.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'商品暂时不可用'**
|
||||
String get paymentProductUnavailable;
|
||||
|
||||
/// No description provided for @paymentPending.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'Apple 正在处理中,请稍候'**
|
||||
String get paymentPending;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1282,4 +1282,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get feedbackImageTooLarge => 'Image size cannot exceed 5MB';
|
||||
|
||||
@override
|
||||
String get paymentSuccess => 'Purchase successful';
|
||||
|
||||
@override
|
||||
String get paymentVerifyFailed =>
|
||||
'Purchase verification failed, please try again later';
|
||||
|
||||
@override
|
||||
String get paymentProductNotFound => 'Product temporarily unavailable';
|
||||
|
||||
@override
|
||||
String get paymentStarterPackIneligible =>
|
||||
'Starter pack is limited to one purchase per user';
|
||||
|
||||
@override
|
||||
String get paymentProductUnavailable => 'Product temporarily unavailable';
|
||||
|
||||
@override
|
||||
String get paymentPending => 'Apple is processing, please wait';
|
||||
}
|
||||
|
||||
@@ -1225,6 +1225,24 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get feedbackImageTooLarge => '图片大小不能超过5MB';
|
||||
|
||||
@override
|
||||
String get paymentSuccess => '购买成功';
|
||||
|
||||
@override
|
||||
String get paymentVerifyFailed => '购买验证失败,请稍后重试';
|
||||
|
||||
@override
|
||||
String get paymentProductNotFound => '商品暂时不可用';
|
||||
|
||||
@override
|
||||
String get paymentStarterPackIneligible => '新手包每位用户仅限购买一次';
|
||||
|
||||
@override
|
||||
String get paymentProductUnavailable => '商品暂时不可用';
|
||||
|
||||
@override
|
||||
String get paymentPending => 'Apple 正在处理中,请稍候';
|
||||
}
|
||||
|
||||
/// The translations for Chinese, using the Han script (`zh_Hant`).
|
||||
@@ -2204,4 +2222,22 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
|
||||
|
||||
@override
|
||||
String get feedbackImageTooLarge => '圖片大小不能超過5MB';
|
||||
|
||||
@override
|
||||
String get paymentSuccess => '購買成功';
|
||||
|
||||
@override
|
||||
String get paymentVerifyFailed => '購買驗證失敗,請稍後重試';
|
||||
|
||||
@override
|
||||
String get paymentProductNotFound => '商品暫時不可用';
|
||||
|
||||
@override
|
||||
String get paymentStarterPackIneligible => '新手包每位用戶僅限購買一次';
|
||||
|
||||
@override
|
||||
String get paymentProductUnavailable => '商品暫時不可用';
|
||||
|
||||
@override
|
||||
String get paymentPending => 'Apple 正在處理中,請稍候';
|
||||
}
|
||||
|
||||
@@ -505,5 +505,11 @@
|
||||
"feedbackContentRequired": "请输入反馈内容",
|
||||
"feedbackContentTooLong": "反馈内容不能超过500字",
|
||||
"feedbackTooManyImages": "最多只能上传3张图片",
|
||||
"feedbackImageTooLarge": "图片大小不能超过5MB"
|
||||
"feedbackImageTooLarge": "图片大小不能超过5MB",
|
||||
"paymentSuccess": "购买成功",
|
||||
"paymentVerifyFailed": "购买验证失败,请稍后重试",
|
||||
"paymentProductNotFound": "商品暂时不可用",
|
||||
"paymentStarterPackIneligible": "新手包每位用户仅限购买一次",
|
||||
"paymentProductUnavailable": "商品暂时不可用",
|
||||
"paymentPending": "Apple 正在处理中,请稍候"
|
||||
}
|
||||
|
||||
@@ -407,5 +407,11 @@
|
||||
"feedbackContentRequired": "請輸入回饋內容",
|
||||
"feedbackContentTooLong": "回饋內容不能超過500字",
|
||||
"feedbackTooManyImages": "最多只能上傳3張圖片",
|
||||
"feedbackImageTooLarge": "圖片大小不能超過5MB"
|
||||
"feedbackImageTooLarge": "圖片大小不能超過5MB",
|
||||
"paymentSuccess": "購買成功",
|
||||
"paymentVerifyFailed": "購買驗證失敗,請稍後重試",
|
||||
"paymentProductNotFound": "商品暫時不可用",
|
||||
"paymentStarterPackIneligible": "新手包每位用戶僅限購買一次",
|
||||
"paymentProductUnavailable": "商品暫時不可用",
|
||||
"paymentPending": "Apple 正在處理中,請稍候"
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ dependencies:
|
||||
cupertino_icons: ^1.0.8
|
||||
device_info_plus: ^12.4.0
|
||||
package_info_plus: ^9.0.1
|
||||
in_app_purchase: ^3.2.3
|
||||
in_app_purchase_storekit: ^0.4.8
|
||||
crypto: ^3.0.7
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meeyao_qianwen/features/payments/data/models/apple_purchase_models.dart';
|
||||
|
||||
void main() {
|
||||
group('VerifyTransactionRequest', () {
|
||||
test('toJson includes all required fields', () {
|
||||
const request = VerifyTransactionRequest(
|
||||
productCode: 'basic_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
|
||||
transactionId: '1000000123456789',
|
||||
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['productCode'], 'basic_pack');
|
||||
expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack');
|
||||
expect(json['transactionId'], '1000000123456789');
|
||||
expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...');
|
||||
expect(json.containsKey('appAccountToken'), false);
|
||||
});
|
||||
|
||||
test('toJson includes appAccountToken when provided', () {
|
||||
const request = VerifyTransactionRequest(
|
||||
productCode: 'basic_pack',
|
||||
appStoreProductId: 'com.meeyao.qianwen.basic_pack',
|
||||
transactionId: '1000000123456789',
|
||||
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
|
||||
appAccountToken: 'abc123def456',
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['appAccountToken'], 'abc123def456');
|
||||
});
|
||||
});
|
||||
|
||||
group('VerifyTransactionResponse', () {
|
||||
test('parses granted status correctly', () {
|
||||
final json = {
|
||||
'status': 'granted',
|
||||
'productCode': 'basic_pack',
|
||||
'transactionId': '1000000123456789',
|
||||
'creditsAdded': 100,
|
||||
'newBalance': 180,
|
||||
'ledgerEventId': 'payment.apple_iap:1000000123456789',
|
||||
};
|
||||
|
||||
final response = VerifyTransactionResponse.fromJson(json);
|
||||
|
||||
expect(response.status, VerifyTransactionStatus.granted);
|
||||
expect(response.productCode, 'basic_pack');
|
||||
expect(response.transactionId, '1000000123456789');
|
||||
expect(response.creditsAdded, 100);
|
||||
expect(response.newBalance, 180);
|
||||
expect(response.ledgerEventId, 'payment.apple_iap:1000000123456789');
|
||||
});
|
||||
|
||||
test('parses already_granted status correctly', () {
|
||||
final json = {
|
||||
'status': 'already_granted',
|
||||
'productCode': 'basic_pack',
|
||||
'transactionId': '1000000123456789',
|
||||
'creditsAdded': 0,
|
||||
'newBalance': 180,
|
||||
'ledgerEventId': 'payment.apple_iap:1000000123456789',
|
||||
};
|
||||
|
||||
final response = VerifyTransactionResponse.fromJson(json);
|
||||
|
||||
expect(response.status, VerifyTransactionStatus.alreadyGranted);
|
||||
expect(response.creditsAdded, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ agents:
|
||||
enabled_tools: []
|
||||
|
||||
- agent_type: worker
|
||||
llm_model_code: deepseek-chat
|
||||
llm_model_code: deepseek-v4-flash
|
||||
status: active
|
||||
config:
|
||||
temperature: 0.7
|
||||
|
||||
@@ -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"
|
||||
@@ -100,6 +100,21 @@ This document is the source of truth for backend RFC7807 `code` values consumed
|
||||
| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not supported (only jpg/png) | Show supported format hint |
|
||||
| `FEEDBACK_SUBMIT_FAILED` | 500 | Feedback submission failed | Show retry prompt |
|
||||
|
||||
## Payment (Apple IAP)
|
||||
|
||||
| code | status | meaning | frontend handling |
|
||||
|---|---:|---|---|
|
||||
| `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` does not exist or is not enabled | Refresh packages and show product-unavailable message |
|
||||
| `PAYMENT_PRODUCT_MISMATCH` | 422 | Client product ID does not match backend/Apple verification result | Block grant and prompt retry |
|
||||
| `PAYMENT_ENVIRONMENT_MISMATCH` | 422 | Transaction environment (Sandbox/Production) does not match server environment | Show purchase-verification-failed message |
|
||||
| `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction invalid, signature verification failed, or payload malformed | Show purchase-verification-failed message |
|
||||
| `PAYMENT_TRANSACTION_REVOKED` | 409 | Transaction has been revoked or refunded, grant not allowed | Show purchase-unavailable message |
|
||||
| `PAYMENT_TRANSACTION_CONFLICT` | 409 | Transaction already processed by another user or in conflicting state | Prompt to contact support or refresh balance |
|
||||
| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | Current email identity has already purchased starter pack | Refresh packages and hide starter pack |
|
||||
| `PAYMENT_APPLE_UNAVAILABLE` | 503 | Apple Server API or certificate fetch unavailable | Show retry-later message; do NOT complete/finish transaction |
|
||||
| `PAYMENT_GRANT_FAILED` | 500 | Verification succeeded but grant transaction failed | Show retry-later message; retain transaction for compensation |
|
||||
| `PAYMENT_REFUND_INSUFFICIENT_BALANCE` | 409 | User has insufficient balance for refund clawback | Log for manual review; do not auto-clawback |
|
||||
|
||||
## Global
|
||||
|
||||
| code | status | meaning | frontend handling |
|
||||
|
||||
@@ -34,6 +34,25 @@ Protocol verification status:
|
||||
- Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`.
|
||||
- Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`.
|
||||
|
||||
## Points Change Types (change_type)
|
||||
|
||||
| Type | Direction | Meaning | biz_type | Description |
|
||||
|------|-----------|---------|----------|-------------|
|
||||
| `register` | +1 | 注册奖励 | `null` | 新用户注册赠送积分 |
|
||||
| `consume` | -1 | 消费扣减 | `chat` | 用户占卜消耗积分 |
|
||||
| `adjust` | ±1 | 手动调整 | `null` | 系统或管理员手动调整积分,通用调整不绑定业务场景 |
|
||||
| `purchase` | +1 | 购买入账 | `payment` | 用户支付购买积分 |
|
||||
| `refund` | -1 | 退款扣回 | `payment` | 退款后扣回积分 |
|
||||
|
||||
## Points Business Types (biz_type)
|
||||
|
||||
| Type | Meaning | Associated change_type |
|
||||
|------|---------|------------------------|
|
||||
| `chat` | 聊天/占卜业务 | `consume` |
|
||||
| `payment` | 支付业务 | `purchase`, `refund` |
|
||||
|
||||
Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`).
|
||||
|
||||
## Table contract
|
||||
|
||||
### profiles
|
||||
@@ -62,21 +81,23 @@ Protocol verification status:
|
||||
- PK: `id`
|
||||
- FK:
|
||||
- `user_id -> auth.users.id` (`on delete cascade`)
|
||||
- `biz_id -> sessions.id` (`on delete restrict`, nullable)
|
||||
- `biz_id -> sessions.id` (`on delete restrict`, nullable) — only for `biz_type='chat'`
|
||||
- `operator_id -> auth.users.id` (`on delete set null`)
|
||||
- Core fields: `direction`, `amount`, `balance_after`, `change_type`, `biz_type`, `biz_id`, `event_id`, `operator_id`, `metadata`, `created_at`, `updated_at`
|
||||
- Constraints:
|
||||
- `amount > 0`
|
||||
- `direction in (1, -1)`
|
||||
- `balance_after >= 0`
|
||||
- `change_type in ('register', 'consume', 'grant', 'adjust')`
|
||||
- `biz_type is null or biz_type='chat'`
|
||||
- `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')`
|
||||
- `biz_type is null or biz_type in ('chat', 'payment')`
|
||||
- biz binding:
|
||||
- `register => biz_type is null and biz_id is null`
|
||||
- `consume/grant/adjust => biz_type='chat' and biz_id not null`
|
||||
- `consume => biz_type='chat' and biz_id not null`
|
||||
- `adjust => biz_type is null and biz_id is null` (通用调整,不绑定业务场景)
|
||||
- `purchase/refund => biz_type='payment' and biz_id not null` (biz_id references `apple_iap_transactions.id` as logical FK, not database FK)
|
||||
- direction and change_type coupling:
|
||||
- `register/grant => direction = 1`
|
||||
- `consume => direction = -1`
|
||||
- `register/purchase => direction = 1`
|
||||
- `consume/refund => direction = -1`
|
||||
- `adjust => direction in (1, -1)`
|
||||
- idempotency: `unique (user_id, event_id)`
|
||||
|
||||
@@ -89,8 +110,8 @@ Protocol verification status:
|
||||
- `amount >= 0`
|
||||
- `direction in (1, 0, -1)`
|
||||
- `balance_after >= 0`
|
||||
- `change_type in ('register', 'consume', 'grant', 'adjust')`
|
||||
- `biz_type is null or biz_type='chat'`
|
||||
- `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')`
|
||||
- `biz_type is null or biz_type in ('chat', 'payment')`
|
||||
- `billed_to in ('user', 'platform')`
|
||||
- metadata must be object
|
||||
- idempotency: `unique (event_id)`
|
||||
@@ -141,8 +162,9 @@ JSON constraints:
|
||||
- Per `change_type`:
|
||||
- `register`: no `charge`, and no chat binding (`biz_type/biz_id` both null)
|
||||
- `consume`: requires `charge` object with required fields
|
||||
- `grant`: no extra metadata shape requirement
|
||||
- `adjust`: requires `ext.ticket_id` non-empty
|
||||
- `adjust`: requires `ext.reason` non-empty (通用调整,系统或管理员均可操作,不绑定业务)
|
||||
- `purchase`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id`
|
||||
- `refund`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id`, `ext.original_event_id`
|
||||
|
||||
## Signup initialization contract
|
||||
|
||||
@@ -225,9 +247,9 @@ Returns available purchase packages for the current user's region, including sta
|
||||
"currency": "USD",
|
||||
"packages": [
|
||||
{
|
||||
"productCode": "new_user_pack_099_60",
|
||||
"productCode": "new_user_pack",
|
||||
"type": "starter",
|
||||
"priceUsd": "0.99",
|
||||
"price": "0.99",
|
||||
"credits": 60,
|
||||
"badge": null,
|
||||
"isStarter": true,
|
||||
@@ -235,9 +257,9 @@ Returns available purchase packages for the current user's region, including sta
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"productCode": "basic_pack_499_100",
|
||||
"productCode": "basic_pack",
|
||||
"type": "regular",
|
||||
"priceUsd": "4.99",
|
||||
"price": "4.99",
|
||||
"credits": 100,
|
||||
"badge": null,
|
||||
"isStarter": false,
|
||||
@@ -252,9 +274,9 @@ Returns available purchase packages for the current user's region, including sta
|
||||
- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN")
|
||||
- `currency`: ISO 4217 currency code (e.g., "USD")
|
||||
- `packages`: List of available packages
|
||||
- `productCode`: Unique product identifier
|
||||
- `productCode`: Unique product identifier (e.g., `new_user_pack`, `basic_pack`, `popular_pack`, `premium_pack`)
|
||||
- `type`: "starter" (new user pack) or "regular"
|
||||
- `priceUsd`: Price in USD (decimal string)
|
||||
- `price`: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price)
|
||||
- `credits`: Number of credits
|
||||
- `badge`: Optional badge text (e.g., "Popular")
|
||||
- `isStarter`: Whether this is a starter pack
|
||||
@@ -277,16 +299,16 @@ Returns available purchase packages for the current user's region, including sta
|
||||
region: US
|
||||
currency: USD
|
||||
packages:
|
||||
- product_code: new_user_pack_099_60
|
||||
- product_code: new_user_pack
|
||||
type: starter
|
||||
price_usd: "0.99"
|
||||
price: "0.99"
|
||||
credits: 60
|
||||
badge: null
|
||||
sort_order: 0
|
||||
enabled: true
|
||||
- product_code: basic_pack_499_100
|
||||
- product_code: basic_pack
|
||||
type: regular
|
||||
price_usd: "4.99"
|
||||
price: "4.99"
|
||||
credits: 100
|
||||
badge: null
|
||||
sort_order: 10
|
||||
|
||||
Reference in New Issue
Block a user