feat(payment): 优化套餐配置和支付服务
- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml - 优化 Apple IAP 服务和验证逻辑 - 更新套餐数据模型和协议文档 - 添加支付相关测试用例
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 = {}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user