feat(payment): 优化套餐配置和支付服务

- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml
- 优化 Apple IAP 服务和验证逻辑
- 更新套餐数据模型和协议文档
- 添加支付相关测试用例
This commit is contained in:
ZL-Q
2026-04-28 17:21:14 +08:00
parent a940f2ea47
commit 295dbc09ab
23 changed files with 285 additions and 304 deletions
+5 -5
View File
@@ -23,17 +23,17 @@
{ {
"displayPrice" : "6.00", "displayPrice" : "6.00",
"familyShareable" : false, "familyShareable" : false,
"internalID" : "basic_pack_001", "internalID" : "starter_pack_001",
"introductoryOffer" : null, "introductoryOffer" : null,
"localizations" : [ "localizations" : [
{ {
"description" : "基础信用点套餐", "description" : "入门信用点套餐",
"displayName" : "基础包", "displayName" : "入门包",
"locale" : "zh_CN" "locale" : "zh_CN"
} }
], ],
"productID" : "com.meeyao.qianwen.basic_pack", "productID" : "com.meeyao.qianwen.starter_pack",
"referenceName" : "基础包", "referenceName" : "入门包",
"type" : "Consumable" "type" : "Consumable"
}, },
{ {
+6
View File
@@ -32,6 +32,12 @@
<string>需要将头像处理结果保存到您的相册</string> <string>需要将头像处理结果保存到您的相册</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>需要麦克风权限用于语音追问</string> <string>需要麦克风权限用于语音追问</string>
<key>NSLocalNetworkUsageDescription</key>
<string>用于开发调试时连接本地调试服务。</string>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -14,25 +14,11 @@ import '../models/apple_purchase_models.dart';
enum PurchaseFlowState { idle, purchasing, verifying, success, failed } 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 { class AppleIapService with ChangeNotifier {
AppleIapService({ AppleIapService({required ApiClient apiClient, required String userId})
required ApiClient apiClient, : _paymentApi = ApplePaymentApi(apiClient: apiClient),
required String userId, _inAppPurchase = InAppPurchase.instance,
}) : _paymentApi = ApplePaymentApi(apiClient: apiClient), _appAccountToken = _hashUserId(userId);
_inAppPurchase = InAppPurchase.instance,
_appAccountToken = _hashUserId(userId);
final ApplePaymentApi _paymentApi; final ApplePaymentApi _paymentApi;
final InAppPurchase _inAppPurchase; final InAppPurchase _inAppPurchase;
@@ -116,7 +102,7 @@ class AppleIapService with ChangeNotifier {
); );
if (!bought) { if (!bought) {
_setError('Failed to initiate purchase'); _setState(PurchaseFlowState.idle);
return false; return false;
} }
@@ -130,19 +116,62 @@ class AppleIapService with ChangeNotifier {
} }
Future<void> _handlePurchase(PurchaseDetails purchase) async { Future<void> _handlePurchase(PurchaseDetails purchase) async {
switch (purchase.status) { try {
case PurchaseStatus.purchased: switch (purchase.status) {
await _verifyAndComplete(purchase); case PurchaseStatus.purchased:
case PurchaseStatus.canceled: await _verifyAndComplete(purchase);
await _inAppPurchase.completePurchase(purchase); case PurchaseStatus.canceled:
_setState(PurchaseFlowState.idle); await _completePurchaseSafely(purchase);
case PurchaseStatus.error: _setState(PurchaseFlowState.idle);
await _inAppPurchase.completePurchase(purchase); case PurchaseStatus.error:
_setError(purchase.error?.message ?? 'Purchase failed'); final errorCode = purchase.error?.code;
case PurchaseStatus.pending: final isUserCancel = errorCode == '2';
_setState(PurchaseFlowState.purchasing); if (isUserCancel) {
case PurchaseStatus.restored: await _completePurchaseSafely(purchase);
await _inAppPurchase.completePurchase(purchase); _setState(PurchaseFlowState.idle);
} else {
_logger.warning(
message: 'Purchase error',
extra: {
'errorCode': errorCode,
'errorMessage': purchase.error?.message,
},
);
await _completePurchaseSafely(purchase);
_setError(purchase.error?.message ?? 'Purchase failed');
}
case PurchaseStatus.pending:
_setState(PurchaseFlowState.purchasing);
case PurchaseStatus.restored:
await _completePurchaseSafely(purchase);
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to handle purchase',
error: e,
stackTrace: stackTrace,
extra: {
'productID': purchase.productID,
'purchaseID': purchase.purchaseID,
'status': purchase.status.name,
},
);
_setState(PurchaseFlowState.idle);
}
}
Future<void> _completePurchaseSafely(PurchaseDetails purchase) async {
try {
await _inAppPurchase.completePurchase(purchase);
} catch (e, stackTrace) {
_logger.warning(
message: 'completePurchase failed (likely null purchaseID): $e',
stackTrace: stackTrace,
extra: {
'productID': purchase.productID,
'purchaseID': purchase.purchaseID,
},
);
} }
} }
@@ -178,7 +207,12 @@ class AppleIapService with ChangeNotifier {
message: 'Purchase verification failed', message: 'Purchase verification failed',
error: e, error: e,
stackTrace: stackTrace, stackTrace: stackTrace,
extra: {'transactionId': purchase.purchaseID}, extra: {
'transactionId': purchase.purchaseID,
'productID': purchase.productID,
'errorType': e.runtimeType.toString(),
'errorMessage': e.toString(),
},
); );
if (_isRetryableError(e)) { if (_isRetryableError(e)) {
@@ -216,7 +250,7 @@ class AppleIapService with ChangeNotifier {
String _productCodeFromStoreKitId(String storeKitId) { String _productCodeFromStoreKitId(String storeKitId) {
return switch (storeKitId) { return switch (storeKitId) {
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack', 'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
'com.meeyao.qianwen.basic_pack' => 'basic_pack', 'com.meeyao.qianwen.starter_pack' => 'starter_pack',
'com.meeyao.qianwen.popular_pack' => 'popular_pack', 'com.meeyao.qianwen.popular_pack' => 'popular_pack',
'com.meeyao.qianwen.premium_pack' => 'premium_pack', 'com.meeyao.qianwen.premium_pack' => 'premium_pack',
_ => storeKitId, _ => storeKitId,
@@ -1,4 +1,4 @@
enum ProductCode { newUserPack, basicPack, popularPack, premiumPack } enum ProductCode { newUserPack, starterPack, popularPack, premiumPack }
enum PackageType { starter, regular } enum PackageType { starter, regular }
@@ -7,7 +7,6 @@ class PackageInfo {
required this.productCode, required this.productCode,
required this.appStoreProductId, required this.appStoreProductId,
required this.type, required this.type,
required this.price,
required this.credits, required this.credits,
required this.isStarter, required this.isStarter,
required this.starterEligible, required this.starterEligible,
@@ -17,7 +16,6 @@ class PackageInfo {
final ProductCode productCode; final ProductCode productCode;
final String appStoreProductId; final String appStoreProductId;
final PackageType type; final PackageType type;
final double price;
final int credits; final int credits;
final bool isStarter; final bool isStarter;
final bool starterEligible; final bool starterEligible;
@@ -30,7 +28,6 @@ class PackageInfo {
type: json['type'] == 'starter' type: json['type'] == 'starter'
? PackageType.starter ? PackageType.starter
: PackageType.regular, : PackageType.regular,
price: (json['price'] as num).toDouble(),
credits: json['credits'] as int, credits: json['credits'] as int,
isStarter: json['isStarter'] as bool, isStarter: json['isStarter'] as bool,
starterEligible: json['starterEligible'] as bool, starterEligible: json['starterEligible'] as bool,
@@ -41,31 +38,23 @@ class PackageInfo {
static ProductCode _parseProductCode(String code) { static ProductCode _parseProductCode(String code) {
return switch (code) { return switch (code) {
'new_user_pack' => ProductCode.newUserPack, 'new_user_pack' => ProductCode.newUserPack,
'basic_pack' => ProductCode.basicPack, 'starter_pack' => ProductCode.starterPack,
'popular_pack' => ProductCode.popularPack, 'popular_pack' => ProductCode.popularPack,
'premium_pack' => ProductCode.premiumPack, 'premium_pack' => ProductCode.premiumPack,
_ => throw ArgumentError('Unknown product code: $code'), _ => throw ArgumentError('Unknown product code: $code'),
}; };
} }
String get priceDisplay => '\$${price.toStringAsFixed(2)}';
} }
class PackagesResult { class PackagesResult {
const PackagesResult({ const PackagesResult({
required this.region,
required this.currency,
required this.packages, required this.packages,
}); });
final String region;
final String currency;
final List<PackageInfo> packages; final List<PackageInfo> packages;
factory PackagesResult.fromJson(Map<String, dynamic> json) { factory PackagesResult.fromJson(Map<String, dynamic> json) {
return PackagesResult( return PackagesResult(
region: json['region'] as String,
currency: json['currency'] as String,
packages: (json['packages'] as List<dynamic>) packages: (json['packages'] as List<dynamic>)
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>)) .map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
required this.warning, required this.warning,
required this.warningContainer, required this.warningContainer,
required this.onWarningContainer, required this.onWarningContainer,
required this.incomeGreenBg,
required this.incomeGreenText,
}); });
final Color accentPurple; final Color accentPurple;
@@ -39,6 +41,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
final Color warning; final Color warning;
final Color warningContainer; final Color warningContainer;
final Color onWarningContainer; final Color onWarningContainer;
final Color incomeGreenBg;
final Color incomeGreenText;
@override @override
ThemeExtension<AppColorPalette> copyWith({ ThemeExtension<AppColorPalette> copyWith({
@@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
Color? warning, Color? warning,
Color? warningContainer, Color? warningContainer,
Color? onWarningContainer, Color? onWarningContainer,
Color? incomeGreenBg,
Color? incomeGreenText,
}) { }) {
return AppColorPalette( return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple, accentPurple: accentPurple ?? this.accentPurple,
@@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
warning: warning ?? this.warning, warning: warning ?? this.warning,
warningContainer: warningContainer ?? this.warningContainer, warningContainer: warningContainer ?? this.warningContainer,
onWarningContainer: onWarningContainer ?? this.onWarningContainer, onWarningContainer: onWarningContainer ?? this.onWarningContainer,
incomeGreenBg: incomeGreenBg ?? this.incomeGreenBg,
incomeGreenText: incomeGreenText ?? this.incomeGreenText,
); );
} }
@@ -131,6 +139,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
other.onWarningContainer, other.onWarningContainer,
t, t,
)!, )!,
incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!,
incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!,
); );
} }
} }
+1
View File
@@ -43,6 +43,7 @@ dependencies:
onboarding_overlay: ^3.2.3 onboarding_overlay: ^3.2.3
image_picker: ^1.1.2 image_picker: ^1.1.2
record: ^6.1.1 record: ^6.1.1
flutter_timezone: ^3.0.1
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@@ -5,16 +5,16 @@ void main() {
group('VerifyTransactionRequest', () { group('VerifyTransactionRequest', () {
test('toJson includes all required fields', () { test('toJson includes all required fields', () {
const request = VerifyTransactionRequest( const request = VerifyTransactionRequest(
productCode: 'basic_pack', productCode: 'starter_pack',
appStoreProductId: 'com.meeyao.qianwen.basic_pack', appStoreProductId: 'com.meeyao.qianwen.starter_pack',
transactionId: '1000000123456789', transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
); );
final json = request.toJson(); final json = request.toJson();
expect(json['productCode'], 'basic_pack'); expect(json['productCode'], 'starter_pack');
expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack'); expect(json['appStoreProductId'], 'com.meeyao.qianwen.starter_pack');
expect(json['transactionId'], '1000000123456789'); expect(json['transactionId'], '1000000123456789');
expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...'); expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...');
expect(json.containsKey('appAccountToken'), false); expect(json.containsKey('appAccountToken'), false);
@@ -22,8 +22,8 @@ void main() {
test('toJson includes appAccountToken when provided', () { test('toJson includes appAccountToken when provided', () {
const request = VerifyTransactionRequest( const request = VerifyTransactionRequest(
productCode: 'basic_pack', productCode: 'starter_pack',
appStoreProductId: 'com.meeyao.qianwen.basic_pack', appStoreProductId: 'com.meeyao.qianwen.starter_pack',
transactionId: '1000000123456789', transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
appAccountToken: 'abc123def456', appAccountToken: 'abc123def456',
@@ -39,7 +39,7 @@ void main() {
test('parses granted status correctly', () { test('parses granted status correctly', () {
final json = { final json = {
'status': 'granted', 'status': 'granted',
'productCode': 'basic_pack', 'productCode': 'starter_pack',
'transactionId': '1000000123456789', 'transactionId': '1000000123456789',
'creditsAdded': 100, 'creditsAdded': 100,
'newBalance': 180, 'newBalance': 180,
@@ -49,7 +49,7 @@ void main() {
final response = VerifyTransactionResponse.fromJson(json); final response = VerifyTransactionResponse.fromJson(json);
expect(response.status, VerifyTransactionStatus.granted); expect(response.status, VerifyTransactionStatus.granted);
expect(response.productCode, 'basic_pack'); expect(response.productCode, 'starter_pack');
expect(response.transactionId, '1000000123456789'); expect(response.transactionId, '1000000123456789');
expect(response.creditsAdded, 100); expect(response.creditsAdded, 100);
expect(response.newBalance, 180); expect(response.newBalance, 180);
@@ -59,7 +59,7 @@ void main() {
test('parses already_granted status correctly', () { test('parses already_granted status correctly', () {
final json = { final json = {
'status': 'already_granted', 'status': 'already_granted',
'productCode': 'basic_pack', 'productCode': 'starter_pack',
'transactionId': '1000000123456789', 'transactionId': '1000000123456789',
'creditsAdded': 0, 'creditsAdded': 0,
'newBalance': 180, 'newBalance': 180,
@@ -1,21 +1,9 @@
from core.config.packages.registry import (
clear_packages_cache,
get_packages_config_for_region,
)
from core.config.packages.schema import ( from core.config.packages.schema import (
PackageConfig,
PackageType, PackageType,
ProductCode, ProductCode,
RegionPackagesConfig,
load_packages_config,
) )
__all__ = [ __all__ = [
"clear_packages_cache",
"get_packages_config_for_region",
"load_packages_config",
"PackageConfig",
"PackageType", "PackageType",
"ProductCode", "ProductCode",
"RegionPackagesConfig",
] ]
+1 -30
View File
@@ -1,34 +1,5 @@
from __future__ import annotations from __future__ import annotations
from core.config.packages.schema import (
RegionPackagesConfig,
load_packages_config,
)
from utils.paths import get_default_package_config_path, get_package_config_path
_CONFIG_CACHE: dict[str, RegionPackagesConfig] = {}
def get_packages_config_for_region(country: str) -> RegionPackagesConfig:
if country in _CONFIG_CACHE:
return _CONFIG_CACHE[country]
region_file = get_package_config_path(country)
if region_file.exists():
config = load_packages_config(region_file)
_CONFIG_CACHE[country] = config
return config
default_file = get_default_package_config_path()
if not default_file.exists():
raise RuntimeError(f"No default packages config found: {default_file}")
config = load_packages_config(default_file)
_CONFIG_CACHE[country] = config
return config
def clear_packages_cache() -> None: def clear_packages_cache() -> None:
global _CONFIG_CACHE pass
_CONFIG_CACHE = {}
+2 -36
View File
@@ -1,11 +1,7 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from pathlib import Path from typing import Literal
from typing import ClassVar, Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class PackageType(str, Enum): class PackageType(str, Enum):
@@ -15,37 +11,7 @@ class PackageType(str, Enum):
ProductCode = Literal[ ProductCode = Literal[
"new_user_pack", "new_user_pack",
"basic_pack", "starter_pack",
"popular_pack", "popular_pack",
"premium_pack", "premium_pack",
] ]
class PackageConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
product_code: ProductCode
type: PackageType
price: float = Field(ge=0)
credits: int = Field(ge=1)
sort_order: int = Field(default=0, ge=0)
enabled: bool = Field(default=True)
class RegionPackagesConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
region: str = Field(min_length=1, max_length=8)
currency: str = Field(min_length=1, max_length=8)
packages: list[PackageConfig] = Field(min_length=1)
def load_packages_config(path: Path) -> RegionPackagesConfig:
with path.open("r", encoding="utf-8") as file:
loaded: object = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid packages config format: {path}")
try:
return RegionPackagesConfig.model_validate(loaded)
except ValidationError as exc:
raise ValueError(f"Invalid packages config data: {path}") from exc
@@ -1,30 +0,0 @@
region: DEFAULT
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: 0.99
credits: 60
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: 4.99
credits: 100
sort_order: 10
enabled: true
- product_code: popular_pack
type: regular
price: 7.99
credits: 210
sort_order: 20
enabled: true
- product_code: premium_pack
type: regular
price: 12.99
credits: 415
sort_order: 30
enabled: true
@@ -3,15 +3,23 @@ product_mappings:
app_store_product_id: com.meeyao.qianwen.new_user_pack app_store_product_id: com.meeyao.qianwen.new_user_pack
credits: 60 credits: 60
type: starter type: starter
basic_pack: sort_order: 0
app_store_product_id: com.meeyao.qianwen.basic_pack enabled: true
starter_pack:
app_store_product_id: com.meeyao.qianwen.starter_pack
credits: 100 credits: 100
type: regular type: regular
sort_order: 10
enabled: true
popular_pack: popular_pack:
app_store_product_id: com.meeyao.qianwen.popular_pack app_store_product_id: com.meeyao.qianwen.popular_pack
credits: 210 credits: 210
type: regular type: regular
sort_order: 20
enabled: true
premium_pack: premium_pack:
app_store_product_id: com.meeyao.qianwen.premium_pack app_store_product_id: com.meeyao.qianwen.premium_pack
credits: 415 credits: 415
type: regular type: regular
sort_order: 30
enabled: true
@@ -1,30 +0,0 @@
region: US
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: 0.99
credits: 60
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: 4.99
credits: 100
sort_order: 10
enabled: true
- product_code: popular_pack
type: regular
price: 7.99
credits: 210
sort_order: 20
enabled: true
- product_code: premium_pack
type: regular
price: 12.99
credits: 415
sort_order: 30
enabled: true
-2
View File
@@ -6,7 +6,6 @@ from utils.paths import (
get_gua_catalog_path, get_gua_catalog_path,
get_llm_catalog_config_path, get_llm_catalog_config_path,
get_notification_config_dir, get_notification_config_dir,
get_package_config_path,
get_packages_config_dir, get_packages_config_dir,
get_src_root, get_src_root,
get_static_config_dir, get_static_config_dir,
@@ -21,7 +20,6 @@ __all__ = [
"get_gua_catalog_path", "get_gua_catalog_path",
"get_llm_catalog_config_path", "get_llm_catalog_config_path",
"get_notification_config_dir", "get_notification_config_dir",
"get_package_config_path",
"get_packages_config_dir", "get_packages_config_dir",
"get_src_root", "get_src_root",
"get_static_config_dir", "get_static_config_dir",
+4 -8
View File
@@ -19,6 +19,10 @@ def get_packages_config_dir() -> Path:
return get_static_config_dir() / "packages" return get_static_config_dir() / "packages"
def get_default_package_config_path() -> Path:
return get_packages_config_dir() / "default.yaml"
def get_database_config_dir() -> Path: def get_database_config_dir() -> Path:
return get_static_config_dir() / "database" return get_static_config_dir() / "database"
@@ -31,14 +35,6 @@ def get_divination_data_dir() -> Path:
return get_src_root() / "core/divination/data" return get_src_root() / "core/divination/data"
def get_package_config_path(country: str) -> Path:
return get_packages_config_dir() / f"{country.lower()}.yaml"
def get_default_package_config_path() -> Path:
return get_packages_config_dir() / "default.yaml"
def get_llm_catalog_config_path() -> Path: def get_llm_catalog_config_path() -> Path:
return get_database_config_dir() / "llm_catalog.yaml" return get_database_config_dir() / "llm_catalog.yaml"
+20 -2
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey) _ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey)
_APPLE_ROOT_CA_G3_FINGERPRINT = ( _APPLE_ROOT_CA_G3_FINGERPRINT = (
"0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5" "b52cb02fd567e0359fe8fa4d4c41037970fe01b0"
) )
@@ -51,7 +51,8 @@ class AppleJwsVerifier:
) -> VerifiedTransaction | VerificationError: ) -> VerifiedTransaction | VerificationError:
try: try:
unverified_header = jwt.get_unverified_header(signed_transaction_info) unverified_header = jwt.get_unverified_header(signed_transaction_info)
except jwt.exceptions.DecodeError: except jwt.exceptions.DecodeError as e:
logger.error("Failed to decode JWS header: %s", str(e))
return VerificationError( return VerificationError(
code="PAYMENT_TRANSACTION_INVALID", code="PAYMENT_TRANSACTION_INVALID",
detail="Failed to decode JWS header", detail="Failed to decode JWS header",
@@ -117,6 +118,11 @@ class AppleJwsVerifier:
bundle_id: str = str(payload.get("bundleId", "")) bundle_id: str = str(payload.get("bundleId", ""))
if bundle_id != expected_bundle_id: if bundle_id != expected_bundle_id:
logger.error(
"bundleId mismatch: expected=%s got=%s",
expected_bundle_id,
bundle_id,
)
return VerificationError( return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH", code="PAYMENT_PRODUCT_MISMATCH",
detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}", detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}",
@@ -124,6 +130,11 @@ class AppleJwsVerifier:
product_id: str = str(payload.get("productId", "")) product_id: str = str(payload.get("productId", ""))
if product_id != expected_product_id: if product_id != expected_product_id:
logger.error(
"productId mismatch: expected=%s got=%s",
expected_product_id,
product_id,
)
return VerificationError( return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH", code="PAYMENT_PRODUCT_MISMATCH",
detail=f"productId mismatch: expected={expected_product_id} got={product_id}", detail=f"productId mismatch: expected={expected_product_id} got={product_id}",
@@ -131,12 +142,18 @@ class AppleJwsVerifier:
environment: str = str(payload.get("environment", "")) environment: str = str(payload.get("environment", ""))
if environment not in ("Sandbox", "Production"): if environment not in ("Sandbox", "Production"):
logger.error("Invalid environment: %s", environment)
return VerificationError( return VerificationError(
code="PAYMENT_TRANSACTION_INVALID", code="PAYMENT_TRANSACTION_INVALID",
detail=f"Invalid environment: {environment}", detail=f"Invalid environment: {environment}",
) )
if environment != expected_environment: if environment != expected_environment:
logger.error(
"Environment mismatch: expected=%s got=%s",
expected_environment,
environment,
)
return VerificationError( return VerificationError(
code="PAYMENT_ENVIRONMENT_MISMATCH", code="PAYMENT_ENVIRONMENT_MISMATCH",
detail=f"Environment mismatch: expected={expected_environment} got={environment}", detail=f"Environment mismatch: expected={expected_environment} got={environment}",
@@ -159,6 +176,7 @@ class AppleJwsVerifier:
app_account_token_raw = payload.get("appAccountToken") app_account_token_raw = payload.get("appAccountToken")
if not transaction_id: if not transaction_id:
logger.error("Missing transactionId in payload")
return VerificationError( return VerificationError(
code="PAYMENT_TRANSACTION_INVALID", code="PAYMENT_TRANSACTION_INVALID",
detail="Missing transactionId in payload", detail="Missing transactionId in payload",
+3
View File
@@ -83,3 +83,6 @@ class PaymentRepository:
if claim is None: if claim is None:
raise RuntimeError("Failed to upsert register bonus claim") raise RuntimeError("Failed to upsert register bonus claim")
return claim return claim
async def commit(self) -> None:
await self._session.commit()
+36 -22
View File
@@ -35,6 +35,8 @@ class ProductMapping:
app_store_product_id: str app_store_product_id: str
credits: int credits: int
type: str type: str
sort_order: int = 0
enabled: bool = True
_product_mappings_cache: dict[str, ProductMapping] | None = None _product_mappings_cache: dict[str, ProductMapping] | None = None
@@ -52,14 +54,16 @@ def _load_product_mappings() -> dict[str, ProductMapping]:
with mapping_path.open("r", encoding="utf-8") as f: with mapping_path.open("r", encoding="utf-8") as f:
raw: Any = yaml.safe_load(f) or {} raw: Any = yaml.safe_load(f) or {}
mappings: dict[str, ProductMapping] = {} mappings: dict[str, ProductMapping] = {}
product_mappings: Any = raw.get("product_mappings", {}) product_mappings: Any = raw.get("product_mappings", {})
for code, entry in product_mappings.items(): for code, entry in product_mappings.items():
mappings[str(code)] = ProductMapping( mappings[str(code)] = ProductMapping(
app_store_product_id=str(entry["app_store_product_id"]), app_store_product_id=str(entry["app_store_product_id"]),
credits=int(entry["credits"]), credits=int(entry["credits"]),
type=str(entry["type"]), type=str(entry["type"]),
) sort_order=int(entry.get("sort_order", 0)),
enabled=bool(entry.get("enabled", True)),
)
_product_mappings_cache = mappings _product_mappings_cache = mappings
return mappings return mappings
@@ -119,6 +123,12 @@ class PaymentService:
) )
if isinstance(result, VerificationError): if isinstance(result, VerificationError):
logger.error(
"Transaction verification failed: code=%s detail=%s transaction_id=%s",
result.code,
result.detail,
request.transaction_id,
)
status_code = 422 status_code = 422
if result.code == "PAYMENT_TRANSACTION_REVOKED": if result.code == "PAYMENT_TRANSACTION_REVOKED":
status_code = 409 status_code = 409
@@ -132,6 +142,11 @@ class PaymentService:
verified: VerifiedTransaction = result verified: VerifiedTransaction = result
if str(verified.transaction_id) != request.transaction_id: if str(verified.transaction_id) != request.transaction_id:
logger.error(
"transactionId mismatch: request=%s verified=%s",
request.transaction_id,
verified.transaction_id,
)
raise ApiProblemError( raise ApiProblemError(
status_code=422, status_code=422,
detail=problem_payload( detail=problem_payload(
@@ -273,6 +288,15 @@ class PaymentService:
transaction_record.status = "granted" transaction_record.status = "granted"
transaction_record.ledger_event_id = event_id transaction_record.ledger_event_id = event_id
logger.info(
"Transaction granted: user_id=%s transaction_id=%s product_code=%s credits=%d new_balance=%d",
user_id,
verified.transaction_id,
request.product_code,
credits,
new_balance,
)
if is_starter and email_hash and normalized_email: if is_starter and email_hash and normalized_email:
_ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack(
email_hash=email_hash, email_hash=email_hash,
@@ -280,6 +304,8 @@ class PaymentService:
first_user_id_snapshot=user_id, first_user_id_snapshot=user_id,
) )
await self._payment_repo.commit()
return VerifyTransactionResponse( return VerifyTransactionResponse(
status="granted", status="granted",
productCode=request.product_code, productCode=request.product_code,
@@ -313,11 +339,6 @@ class PaymentService:
return return
if txn.status not in ("granted",): if txn.status not in ("granted",):
logger.info(
"Refund skipped: transaction %s status=%s",
transaction_id,
txn.status,
)
return return
user_id = txn.user_id user_id = txn.user_id
@@ -405,6 +426,8 @@ class PaymentService:
txn.status, txn.status,
) )
await self._payment_repo.commit()
async def handle_server_notification(self, *, signed_payload: str) -> None: async def handle_server_notification(self, *, signed_payload: str) -> None:
if not signed_payload: if not signed_payload:
logger.warning("Empty Apple server notification payload") logger.warning("Empty Apple server notification payload")
@@ -467,13 +490,4 @@ class PaymentService:
return return
if notification_type == "DID_RENEW" and transaction_id: if notification_type == "DID_RENEW" and transaction_id:
logger.info(
"Apple DID_RENEW for transaction %s, no action needed",
transaction_id,
)
return return
logger.info(
"Apple notification type=%s not handled, skipped",
notification_type,
)
@@ -20,8 +20,8 @@ async def test_verify_endpoint_returns_401_without_auth() -> None:
response = await client.post( response = await client.post(
"/api/v1/payments/apple/transactions/verify", "/api/v1/payments/apple/transactions/verify",
json={ json={
"productCode": "basic_pack", "productCode": "starter_pack",
"appStoreProductId": "com.meeyao.qianwen.basic_pack", "appStoreProductId": "com.meeyao.qianwen.starter_pack",
"transactionId": "0000000000000001", "transactionId": "0000000000000001",
"signedTransactionInfo": "fake_jws", "signedTransactionInfo": "fake_jws",
}, },
+4 -4
View File
@@ -22,7 +22,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction( result = verifier.verify_signed_transaction(
"not-a-jws", "not-a-jws",
expected_bundle_id="com.meeyao.qianwen", expected_bundle_id="com.meeyao.qianwen",
expected_product_id="com.meeyao.qianwen.basic_pack", expected_product_id="com.meeyao.qianwen.starter_pack",
) )
assert isinstance(result, VerificationError) assert isinstance(result, VerificationError)
assert result.code == "PAYMENT_TRANSACTION_INVALID" assert result.code == "PAYMENT_TRANSACTION_INVALID"
@@ -34,7 +34,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction( result = verifier.verify_signed_transaction(
f"{h}.{p}.fake", f"{h}.{p}.fake",
expected_bundle_id="com.meeyao.qianwen", expected_bundle_id="com.meeyao.qianwen",
expected_product_id="com.meeyao.qianwen.basic_pack", expected_product_id="com.meeyao.qianwen.starter_pack",
) )
assert isinstance(result, VerificationError) assert isinstance(result, VerificationError)
assert "x5c" in result.detail assert "x5c" in result.detail
@@ -45,7 +45,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction( result = verifier.verify_signed_transaction(
f"{h}.{p}.fake", f"{h}.{p}.fake",
expected_bundle_id="com.meeyao.qianwen", expected_bundle_id="com.meeyao.qianwen",
expected_product_id="com.meeyao.qianwen.basic_pack", expected_product_id="com.meeyao.qianwen.starter_pack",
) )
assert isinstance(result, VerificationError) assert isinstance(result, VerificationError)
assert "x5c" in result.detail assert "x5c" in result.detail
@@ -62,7 +62,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction( result = verifier.verify_signed_transaction(
f"{h}.{p}.fake", f"{h}.{p}.fake",
expected_bundle_id="com.meeyao.qianwen", expected_bundle_id="com.meeyao.qianwen",
expected_product_id="com.meeyao.qianwen.basic_pack", expected_product_id="com.meeyao.qianwen.starter_pack",
) )
assert isinstance(result, VerificationError) assert isinstance(result, VerificationError)
assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail
@@ -30,6 +30,7 @@ class _FakePaymentRepository:
self.inserted_transactions: list[AppleIapTransaction] = [] self.inserted_transactions: list[AppleIapTransaction] = []
self.claim: RegisterBonusClaims | None = None self.claim: RegisterBonusClaims | None = None
self.claim_starter_pack_called: bool = False self.claim_starter_pack_called: bool = False
self.commit_count = 0
async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount: async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount:
return self.account return self.account
@@ -59,6 +60,9 @@ class _FakePaymentRepository:
self.claim.has_purchased_starter_pack = True self.claim.has_purchased_starter_pack = True
return self.claim return self.claim
async def commit(self) -> None:
self.commit_count += 1
class _FakePointsRepository: class _FakePointsRepository:
def __init__(self) -> None: def __init__(self) -> None:
@@ -78,14 +82,17 @@ class _FakeVerifier:
*, *,
expected_bundle_id: str, expected_bundle_id: str,
expected_product_id: str, expected_product_id: str,
expected_environment: str,
) -> VerifiedTransaction | VerificationError: ) -> VerifiedTransaction | VerificationError:
del signed_transaction_info, expected_bundle_id, expected_product_id
del expected_environment
return self._result return self._result
def _make_verified_transaction( def _make_verified_transaction(
*, *,
transaction_id: str = "2000000123456789", transaction_id: str = "2000000123456789",
product_id: str = "com.meeyao.qianwen.basic_pack", product_id: str = "com.meeyao.qianwen.starter_pack",
environment: str = "Sandbox", environment: str = "Sandbox",
) -> VerifiedTransaction: ) -> VerifiedTransaction:
return VerifiedTransaction( return VerifiedTransaction(
@@ -134,7 +141,7 @@ class TestPaymentServiceProductMismatch:
verifier=_FakeVerifier(result=_make_verified_transaction()), verifier=_FakeVerifier(result=_make_verified_transaction()),
) )
request = VerifyTransactionRequest( request = VerifyTransactionRequest(
productCode="basic_pack", productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.wrong_pack", appStoreProductId="com.meeyao.qianwen.wrong_pack",
transactionId="2000000123456789", transactionId="2000000123456789",
signedTransactionInfo="fake_jws", signedTransactionInfo="fake_jws",
@@ -162,8 +169,8 @@ class TestPaymentServiceVerificationFailed:
), ),
) )
request = VerifyTransactionRequest( request = VerifyTransactionRequest(
productCode="basic_pack", productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack", appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789", transactionId="2000000123456789",
signedTransactionInfo="fake_jws", signedTransactionInfo="fake_jws",
) )
@@ -183,8 +190,8 @@ class TestPaymentServiceAlreadyGranted:
existing = AppleIapTransaction( existing = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=user_id, user_id=user_id,
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000123456789", transaction_id="2000000123456789",
original_transaction_id="2000000123456789", original_transaction_id="2000000123456789",
environment="Sandbox", environment="Sandbox",
@@ -202,8 +209,8 @@ class TestPaymentServiceAlreadyGranted:
verifier=_FakeVerifier(result=_make_verified_transaction()), verifier=_FakeVerifier(result=_make_verified_transaction()),
) )
request = VerifyTransactionRequest( request = VerifyTransactionRequest(
productCode="basic_pack", productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack", appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789", transactionId="2000000123456789",
signedTransactionInfo="fake_jws", signedTransactionInfo="fake_jws",
) )
@@ -222,8 +229,8 @@ class TestPaymentServiceTransactionConflict:
existing = AppleIapTransaction( existing = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=uuid4(), user_id=uuid4(),
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000123456789", transaction_id="2000000123456789",
original_transaction_id="2000000123456789", original_transaction_id="2000000123456789",
environment="Sandbox", environment="Sandbox",
@@ -241,8 +248,8 @@ class TestPaymentServiceTransactionConflict:
verifier=_FakeVerifier(result=_make_verified_transaction()), verifier=_FakeVerifier(result=_make_verified_transaction()),
) )
request = VerifyTransactionRequest( request = VerifyTransactionRequest(
productCode="basic_pack", productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack", appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789", transactionId="2000000123456789",
signedTransactionInfo="fake_jws", signedTransactionInfo="fake_jws",
) )
@@ -266,8 +273,8 @@ class TestPaymentServiceSuccessfulGrant:
verifier=_FakeVerifier(result=_make_verified_transaction()), verifier=_FakeVerifier(result=_make_verified_transaction()),
) )
request = VerifyTransactionRequest( request = VerifyTransactionRequest(
productCode="basic_pack", productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.basic_pack", appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789", transactionId="2000000123456789",
signedTransactionInfo="fake_jws", signedTransactionInfo="fake_jws",
) )
@@ -368,6 +375,7 @@ class _FakePaymentRepoForRefund:
self._transaction = transaction self._transaction = transaction
self.account = account or _FakeAccountForRefund() self.account = account or _FakeAccountForRefund()
self.inserted_transactions: list[AppleIapTransaction] = [] self.inserted_transactions: list[AppleIapTransaction] = []
self.commit_count = 0
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
return self._transaction return self._transaction
@@ -389,6 +397,9 @@ class _FakePaymentRepoForRefund:
) -> None: ) -> None:
pass pass
async def commit(self) -> None:
self.commit_count += 1
class TestProcessRefundUnknownTransaction: class TestProcessRefundUnknownTransaction:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -407,8 +418,8 @@ class TestProcessRefundNotGranted:
txn = AppleIapTransaction( txn = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=uuid4(), user_id=uuid4(),
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999999", transaction_id="2000000999999999",
original_transaction_id="2000000999999999", original_transaction_id="2000000999999999",
environment="Sandbox", environment="Sandbox",
@@ -435,8 +446,8 @@ class TestProcessRefundSufficientBalance:
txn = AppleIapTransaction( txn = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=user_id, user_id=user_id,
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999999", transaction_id="2000000999999999",
original_transaction_id="2000000999999999", original_transaction_id="2000000999999999",
environment="Sandbox", environment="Sandbox",
@@ -473,8 +484,8 @@ class TestProcessRefundInsufficientBalance:
txn = AppleIapTransaction( txn = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=user_id, user_id=user_id,
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999998", transaction_id="2000000999999998",
original_transaction_id="2000000999999998", original_transaction_id="2000000999999998",
environment="Sandbox", environment="Sandbox",
@@ -509,8 +520,8 @@ class TestProcessRefundIdempotency:
txn = AppleIapTransaction( txn = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=user_id, user_id=user_id,
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999997", transaction_id="2000000999999997",
original_transaction_id="2000000999999997", original_transaction_id="2000000999999997",
environment="Sandbox", environment="Sandbox",
@@ -539,8 +550,8 @@ class TestHandleServerNotificationRefund:
txn = AppleIapTransaction( txn = AppleIapTransaction(
id=uuid4(), id=uuid4(),
user_id=user_id, user_id=user_id,
product_code="basic_pack", product_code="starter_pack",
app_store_product_id="com.meeyao.qianwen.basic_pack", app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999001", transaction_id="2000000999999001",
original_transaction_id="2000000999999001", original_transaction_id="2000000999999001",
environment="Sandbox", environment="Sandbox",
@@ -19,6 +19,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
| code | status | meaning | frontend handling | | code | status | meaning | frontend handling |
|---|---:|---|---| |---|---:|---|---|
| `POINTS_INSUFFICIENT_BALANCE` | 402 | Not enough points to start this run | Show recharge/insufficient-points prompt | | `POINTS_INSUFFICIENT_BALANCE` | 402 | Not enough points to start this run | Show recharge/insufficient-points prompt |
| `POINTS_INVALID_CURSOR` | 422 | Points ledger pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page |
## Agent Session ## Agent Session
@@ -82,6 +83,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
| code | status | meaning | frontend handling | | code | status | meaning | frontend handling |
|---|---:|---|---| |---|---:|---|---|
| `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list | | `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list |
| `NOTIFICATION_INVALID_CURSOR` | 422 | Notification pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page |
## Invite ## Invite
@@ -230,6 +230,49 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`:
Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects. Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects.
## Points Ledger API
### GET /api/v1/points/ledger
Returns the authenticated user's points ledger in reverse chronological order.
**Request:**
- Auth: Required (JWT)
- Query:
- `limit`: integer, `1..100`, default `20`
- `cursor`: optional ISO 8601 datetime returned by the previous response `nextCursor`
**Response:**
```json
{
"items": [
{
"id": "9cfd5d1d-0dd8-4b30-88ce-6e4a63d22d76",
"direction": 1,
"amount": 60,
"balanceAfter": 160,
"changeType": "purchase",
"createdAt": "2026-04-28T08:30:00+00:00"
}
],
"nextCursor": "2026-04-28T08:30:00+00:00",
"hasMore": true
}
```
**Fields:**
- `items`: ledger rows ordered by `createdAt desc`
- `direction`: `1` for income, `-1` for spending/deduction
- `amount`: positive points delta
- `balanceAfter`: account balance after the ledger event
- `changeType`: one of `register`, `purchase`, `consume`, `adjust`, `refund`
- `createdAt`: ISO 8601 datetime for display and pagination
- `nextCursor`: last returned row `createdAt` when `hasMore=true`; otherwise `null`
- `hasMore`: whether another page is available
**Errors:**
- `POINTS_INVALID_CURSOR` (`422`): `cursor` is not a valid ISO 8601 datetime
## Packages API ## Packages API
### GET /api/v1/points/packages ### GET /api/v1/points/packages
@@ -243,25 +286,21 @@ Returns available purchase packages for the current user's region, including sta
**Response:** **Response:**
```json ```json
{ {
"region": "US",
"currency": "USD",
"packages": [ "packages": [
{ {
"productCode": "new_user_pack", "productCode": "new_user_pack",
"appStoreProductId": "com.meeyao.qianwen.new_user_pack",
"type": "starter", "type": "starter",
"price": "0.99",
"credits": 60, "credits": 60,
"badge": null,
"isStarter": true, "isStarter": true,
"starterEligible": true, "starterEligible": true,
"sortOrder": 0 "sortOrder": 0
}, },
{ {
"productCode": "basic_pack", "productCode": "starter_pack",
"appStoreProductId": "com.meeyao.qianwen.starter_pack",
"type": "regular", "type": "regular",
"price": "4.99",
"credits": 100, "credits": 100,
"badge": null,
"isStarter": false, "isStarter": false,
"starterEligible": false, "starterEligible": false,
"sortOrder": 10 "sortOrder": 10
@@ -271,51 +310,38 @@ Returns available purchase packages for the current user's region, including sta
``` ```
**Fields:** **Fields:**
- `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 - `packages`: List of available packages
- `productCode`: Unique product identifier (e.g., `new_user_pack`, `basic_pack`, `popular_pack`, `premium_pack`) - `productCode`: Unique product identifier (e.g., `new_user_pack`, `starter_pack`, `popular_pack`, `premium_pack`)
- `type`: "starter" (new user pack) or "regular" - `appStoreProductId`: Apple App Store product identifier used for StoreKit purchase
- `price`: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price) - `type`: "starter" (new user pack) or "regular"
- `credits`: Number of credits - `credits`: Number of credits
- `badge`: Optional badge text (e.g., "Popular") - `isStarter`: Whether this is a starter pack
- `isStarter`: Whether this is a starter pack - `starterEligible`: Whether user is eligible to purchase starter pack
- `starterEligible`: Whether user is eligible to purchase starter pack - `sortOrder`: Display order (ascending)
- `sortOrder`: Display order (ascending)
**Business Logic:** **Business Logic:**
1. Determine user's region from `profile.settings.preferences.country` (default: "US") 1. Load package mapping from `backend/src/core/config/static/packages/mapping.yaml`
2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`) 2. Check starter pack eligibility:
3. Check starter pack eligibility: - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
- If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response - Otherwise, include starter pack with `starterEligible: true`
- Otherwise, include starter pack with `starterEligible: true`
**Configuration Files:** **Configuration Files:**
- Path: `backend/src/core/config/static/packages/` - Path: `backend/src/core/config/static/packages/`
- Format: YAML - Format: YAML
- Example: `us.yaml` - Example: `mapping.yaml`
```yaml ```yaml
region: US product_mappings:
currency: USD new_user_pack:
packages: app_store_product_id: com.meeyao.qianwen.new_user_pack
- product_code: new_user_pack
type: starter
price: "0.99"
credits: 60 credits: 60
badge: null type: starter
sort_order: 0 sort_order: 0
enabled: true enabled: true
- product_code: basic_pack starter_pack:
type: regular app_store_product_id: com.meeyao.qianwen.starter_pack
price: "4.99"
credits: 100 credits: 100
badge: null type: regular
sort_order: 10 sort_order: 10
enabled: true enabled: true
``` ```
**Country/Region Codes:**
- Uses ISO 3166-1 alpha-2 standard
- Default: `US` (United States)
- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan)