diff --git a/apps/ios/EryaoProducts.storekit b/apps/ios/EryaoProducts.storekit
index 49c96d2..1ad1326 100644
--- a/apps/ios/EryaoProducts.storekit
+++ b/apps/ios/EryaoProducts.storekit
@@ -23,17 +23,17 @@
{
"displayPrice" : "6.00",
"familyShareable" : false,
- "internalID" : "basic_pack_001",
+ "internalID" : "starter_pack_001",
"introductoryOffer" : null,
"localizations" : [
{
- "description" : "基础信用点套餐",
- "displayName" : "基础包",
+ "description" : "入门信用点套餐",
+ "displayName" : "入门包",
"locale" : "zh_CN"
}
],
- "productID" : "com.meeyao.qianwen.basic_pack",
- "referenceName" : "基础包",
+ "productID" : "com.meeyao.qianwen.starter_pack",
+ "referenceName" : "入门包",
"type" : "Consumable"
},
{
diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist
index e6d3f08..6598592 100644
--- a/apps/ios/Runner/Info.plist
+++ b/apps/ios/Runner/Info.plist
@@ -32,6 +32,12 @@
需要将头像处理结果保存到您的相册
NSMicrophoneUsageDescription
需要麦克风权限用于语音追问
+ NSLocalNetworkUsageDescription
+ 用于开发调试时连接本地调试服务。
+ NSBonjourServices
+
+ _dartobservatory._tcp
+
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/apps/lib/features/payments/data/services/apple_iap_service.dart b/apps/lib/features/payments/data/services/apple_iap_service.dart
index adaa9dc..c938134 100644
--- a/apps/lib/features/payments/data/services/apple_iap_service.dart
+++ b/apps/lib/features/payments/data/services/apple_iap_service.dart
@@ -14,25 +14,11 @@ import '../models/apple_purchase_models.dart';
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
-class PurchaseResult {
- const PurchaseResult({
- required this.state,
- this.creditsAdded,
- this.errorMessage,
- });
-
- final PurchaseFlowState state;
- final int? creditsAdded;
- final String? errorMessage;
-}
-
class AppleIapService with ChangeNotifier {
- AppleIapService({
- required ApiClient apiClient,
- required String userId,
- }) : _paymentApi = ApplePaymentApi(apiClient: apiClient),
- _inAppPurchase = InAppPurchase.instance,
- _appAccountToken = _hashUserId(userId);
+ AppleIapService({required ApiClient apiClient, required String userId})
+ : _paymentApi = ApplePaymentApi(apiClient: apiClient),
+ _inAppPurchase = InAppPurchase.instance,
+ _appAccountToken = _hashUserId(userId);
final ApplePaymentApi _paymentApi;
final InAppPurchase _inAppPurchase;
@@ -116,7 +102,7 @@ class AppleIapService with ChangeNotifier {
);
if (!bought) {
- _setError('Failed to initiate purchase');
+ _setState(PurchaseFlowState.idle);
return false;
}
@@ -130,19 +116,62 @@ class AppleIapService with ChangeNotifier {
}
Future _handlePurchase(PurchaseDetails purchase) async {
- switch (purchase.status) {
- case PurchaseStatus.purchased:
- await _verifyAndComplete(purchase);
- case PurchaseStatus.canceled:
- await _inAppPurchase.completePurchase(purchase);
- _setState(PurchaseFlowState.idle);
- case PurchaseStatus.error:
- await _inAppPurchase.completePurchase(purchase);
- _setError(purchase.error?.message ?? 'Purchase failed');
- case PurchaseStatus.pending:
- _setState(PurchaseFlowState.purchasing);
- case PurchaseStatus.restored:
- await _inAppPurchase.completePurchase(purchase);
+ try {
+ switch (purchase.status) {
+ case PurchaseStatus.purchased:
+ await _verifyAndComplete(purchase);
+ case PurchaseStatus.canceled:
+ await _completePurchaseSafely(purchase);
+ _setState(PurchaseFlowState.idle);
+ case PurchaseStatus.error:
+ final errorCode = purchase.error?.code;
+ final isUserCancel = errorCode == '2';
+ if (isUserCancel) {
+ await _completePurchaseSafely(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 _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',
error: e,
stackTrace: stackTrace,
- extra: {'transactionId': purchase.purchaseID},
+ extra: {
+ 'transactionId': purchase.purchaseID,
+ 'productID': purchase.productID,
+ 'errorType': e.runtimeType.toString(),
+ 'errorMessage': e.toString(),
+ },
);
if (_isRetryableError(e)) {
@@ -216,7 +250,7 @@ class AppleIapService with ChangeNotifier {
String _productCodeFromStoreKitId(String storeKitId) {
return switch (storeKitId) {
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
- 'com.meeyao.qianwen.basic_pack' => 'basic_pack',
+ 'com.meeyao.qianwen.starter_pack' => 'starter_pack',
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
_ => storeKitId,
diff --git a/apps/lib/features/points/data/models/package_info.dart b/apps/lib/features/points/data/models/package_info.dart
index daa2398..aea1fae 100644
--- a/apps/lib/features/points/data/models/package_info.dart
+++ b/apps/lib/features/points/data/models/package_info.dart
@@ -1,4 +1,4 @@
-enum ProductCode { newUserPack, basicPack, popularPack, premiumPack }
+enum ProductCode { newUserPack, starterPack, popularPack, premiumPack }
enum PackageType { starter, regular }
@@ -7,7 +7,6 @@ class PackageInfo {
required this.productCode,
required this.appStoreProductId,
required this.type,
- required this.price,
required this.credits,
required this.isStarter,
required this.starterEligible,
@@ -17,7 +16,6 @@ class PackageInfo {
final ProductCode productCode;
final String appStoreProductId;
final PackageType type;
- final double price;
final int credits;
final bool isStarter;
final bool starterEligible;
@@ -30,7 +28,6 @@ class PackageInfo {
type: json['type'] == 'starter'
? PackageType.starter
: PackageType.regular,
- price: (json['price'] as num).toDouble(),
credits: json['credits'] as int,
isStarter: json['isStarter'] as bool,
starterEligible: json['starterEligible'] as bool,
@@ -41,31 +38,23 @@ class PackageInfo {
static ProductCode _parseProductCode(String code) {
return switch (code) {
'new_user_pack' => ProductCode.newUserPack,
- 'basic_pack' => ProductCode.basicPack,
+ 'starter_pack' => ProductCode.starterPack,
'popular_pack' => ProductCode.popularPack,
'premium_pack' => ProductCode.premiumPack,
_ => throw ArgumentError('Unknown product code: $code'),
};
}
-
- String get priceDisplay => '\$${price.toStringAsFixed(2)}';
}
class PackagesResult {
const PackagesResult({
- required this.region,
- required this.currency,
required this.packages,
});
- final String region;
- final String currency;
final List packages;
factory PackagesResult.fromJson(Map json) {
return PackagesResult(
- region: json['region'] as String,
- currency: json['currency'] as String,
packages: (json['packages'] as List)
.map((e) => PackageInfo.fromJson(e as Map))
.toList(),
diff --git a/apps/lib/shared/theme/app_color_palette.dart b/apps/lib/shared/theme/app_color_palette.dart
index 0d2721c..46d8c08 100644
--- a/apps/lib/shared/theme/app_color_palette.dart
+++ b/apps/lib/shared/theme/app_color_palette.dart
@@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension {
required this.warning,
required this.warningContainer,
required this.onWarningContainer,
+ required this.incomeGreenBg,
+ required this.incomeGreenText,
});
final Color accentPurple;
@@ -39,6 +41,8 @@ class AppColorPalette extends ThemeExtension {
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
+ final Color incomeGreenBg;
+ final Color incomeGreenText;
@override
ThemeExtension copyWith({
@@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension {
Color? warning,
Color? warningContainer,
Color? onWarningContainer,
+ Color? incomeGreenBg,
+ Color? incomeGreenText,
}) {
return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple,
@@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension {
warning: warning ?? this.warning,
warningContainer: warningContainer ?? this.warningContainer,
onWarningContainer: onWarningContainer ?? this.onWarningContainer,
+ incomeGreenBg: incomeGreenBg ?? this.incomeGreenBg,
+ incomeGreenText: incomeGreenText ?? this.incomeGreenText,
);
}
@@ -131,6 +139,8 @@ class AppColorPalette extends ThemeExtension {
other.onWarningContainer,
t,
)!,
+ incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!,
+ incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!,
);
}
}
diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml
index 2fa9495..b085238 100644
--- a/apps/pubspec.yaml
+++ b/apps/pubspec.yaml
@@ -43,6 +43,7 @@ dependencies:
onboarding_overlay: ^3.2.3
image_picker: ^1.1.2
record: ^6.1.1
+ flutter_timezone: ^3.0.1
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
diff --git a/apps/test/features/payments/data/models/apple_purchase_models_test.dart b/apps/test/features/payments/data/models/apple_purchase_models_test.dart
index 205db46..fa628ff 100644
--- a/apps/test/features/payments/data/models/apple_purchase_models_test.dart
+++ b/apps/test/features/payments/data/models/apple_purchase_models_test.dart
@@ -5,16 +5,16 @@ void main() {
group('VerifyTransactionRequest', () {
test('toJson includes all required fields', () {
const request = VerifyTransactionRequest(
- productCode: 'basic_pack',
- appStoreProductId: 'com.meeyao.qianwen.basic_pack',
+ productCode: 'starter_pack',
+ appStoreProductId: 'com.meeyao.qianwen.starter_pack',
transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
);
final json = request.toJson();
- expect(json['productCode'], 'basic_pack');
- expect(json['appStoreProductId'], 'com.meeyao.qianwen.basic_pack');
+ expect(json['productCode'], 'starter_pack');
+ expect(json['appStoreProductId'], 'com.meeyao.qianwen.starter_pack');
expect(json['transactionId'], '1000000123456789');
expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...');
expect(json.containsKey('appAccountToken'), false);
@@ -22,8 +22,8 @@ void main() {
test('toJson includes appAccountToken when provided', () {
const request = VerifyTransactionRequest(
- productCode: 'basic_pack',
- appStoreProductId: 'com.meeyao.qianwen.basic_pack',
+ productCode: 'starter_pack',
+ appStoreProductId: 'com.meeyao.qianwen.starter_pack',
transactionId: '1000000123456789',
signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...',
appAccountToken: 'abc123def456',
@@ -39,7 +39,7 @@ void main() {
test('parses granted status correctly', () {
final json = {
'status': 'granted',
- 'productCode': 'basic_pack',
+ 'productCode': 'starter_pack',
'transactionId': '1000000123456789',
'creditsAdded': 100,
'newBalance': 180,
@@ -49,7 +49,7 @@ void main() {
final response = VerifyTransactionResponse.fromJson(json);
expect(response.status, VerifyTransactionStatus.granted);
- expect(response.productCode, 'basic_pack');
+ expect(response.productCode, 'starter_pack');
expect(response.transactionId, '1000000123456789');
expect(response.creditsAdded, 100);
expect(response.newBalance, 180);
@@ -59,7 +59,7 @@ void main() {
test('parses already_granted status correctly', () {
final json = {
'status': 'already_granted',
- 'productCode': 'basic_pack',
+ 'productCode': 'starter_pack',
'transactionId': '1000000123456789',
'creditsAdded': 0,
'newBalance': 180,
diff --git a/backend/src/core/config/packages/__init__.py b/backend/src/core/config/packages/__init__.py
index 34903f7..ab25fd6 100644
--- a/backend/src/core/config/packages/__init__.py
+++ b/backend/src/core/config/packages/__init__.py
@@ -1,21 +1,9 @@
-from core.config.packages.registry import (
- clear_packages_cache,
- get_packages_config_for_region,
-)
from core.config.packages.schema import (
- PackageConfig,
PackageType,
ProductCode,
- RegionPackagesConfig,
- load_packages_config,
)
__all__ = [
- "clear_packages_cache",
- "get_packages_config_for_region",
- "load_packages_config",
- "PackageConfig",
"PackageType",
"ProductCode",
- "RegionPackagesConfig",
]
diff --git a/backend/src/core/config/packages/registry.py b/backend/src/core/config/packages/registry.py
index b6cd208..d723b87 100644
--- a/backend/src/core/config/packages/registry.py
+++ b/backend/src/core/config/packages/registry.py
@@ -1,34 +1,5 @@
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:
- global _CONFIG_CACHE
- _CONFIG_CACHE = {}
+ pass
diff --git a/backend/src/core/config/packages/schema.py b/backend/src/core/config/packages/schema.py
index aa07f21..7f66bae 100644
--- a/backend/src/core/config/packages/schema.py
+++ b/backend/src/core/config/packages/schema.py
@@ -1,11 +1,7 @@
from __future__ import annotations
from enum import Enum
-from pathlib import Path
-from typing import ClassVar, Literal
-
-import yaml
-from pydantic import BaseModel, ConfigDict, Field, ValidationError
+from typing import Literal
class PackageType(str, Enum):
@@ -15,37 +11,7 @@ class PackageType(str, Enum):
ProductCode = Literal[
"new_user_pack",
- "basic_pack",
+ "starter_pack",
"popular_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
diff --git a/backend/src/core/config/static/packages/default.yaml b/backend/src/core/config/static/packages/default.yaml
deleted file mode 100644
index a053c1e..0000000
--- a/backend/src/core/config/static/packages/default.yaml
+++ /dev/null
@@ -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
diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml
index d78d77c..8faa7f6 100644
--- a/backend/src/core/config/static/packages/mapping.yaml
+++ b/backend/src/core/config/static/packages/mapping.yaml
@@ -3,15 +3,23 @@ product_mappings:
app_store_product_id: com.meeyao.qianwen.new_user_pack
credits: 60
type: starter
- basic_pack:
- app_store_product_id: com.meeyao.qianwen.basic_pack
+ sort_order: 0
+ enabled: true
+ starter_pack:
+ app_store_product_id: com.meeyao.qianwen.starter_pack
credits: 100
type: regular
+ sort_order: 10
+ enabled: true
popular_pack:
app_store_product_id: com.meeyao.qianwen.popular_pack
credits: 210
type: regular
+ sort_order: 20
+ enabled: true
premium_pack:
app_store_product_id: com.meeyao.qianwen.premium_pack
credits: 415
type: regular
+ sort_order: 30
+ enabled: true
diff --git a/backend/src/core/config/static/packages/us.yaml b/backend/src/core/config/static/packages/us.yaml
deleted file mode 100644
index f9eaac8..0000000
--- a/backend/src/core/config/static/packages/us.yaml
+++ /dev/null
@@ -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
diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py
index aec8823..93fc284 100644
--- a/backend/src/utils/__init__.py
+++ b/backend/src/utils/__init__.py
@@ -6,7 +6,6 @@ from utils.paths import (
get_gua_catalog_path,
get_llm_catalog_config_path,
get_notification_config_dir,
- get_package_config_path,
get_packages_config_dir,
get_src_root,
get_static_config_dir,
@@ -21,7 +20,6 @@ __all__ = [
"get_gua_catalog_path",
"get_llm_catalog_config_path",
"get_notification_config_dir",
- "get_package_config_path",
"get_packages_config_dir",
"get_src_root",
"get_static_config_dir",
diff --git a/backend/src/utils/paths.py b/backend/src/utils/paths.py
index 61704a9..49e135e 100644
--- a/backend/src/utils/paths.py
+++ b/backend/src/utils/paths.py
@@ -19,6 +19,10 @@ def get_packages_config_dir() -> Path:
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:
return get_static_config_dir() / "database"
@@ -31,14 +35,6 @@ def get_divination_data_dir() -> Path:
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:
return get_database_config_dir() / "llm_catalog.yaml"
diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py
index 184d4f0..b7ffca0 100644
--- a/backend/src/v1/payments/apple_verifier.py
+++ b/backend/src/v1/payments/apple_verifier.py
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey)
_APPLE_ROOT_CA_G3_FINGERPRINT = (
- "0e429e09b3c0da64e87f0a659a6a40ac08dde5e1b115cca0e3a8f6a5"
+ "b52cb02fd567e0359fe8fa4d4c41037970fe01b0"
)
@@ -51,7 +51,8 @@ class AppleJwsVerifier:
) -> VerifiedTransaction | VerificationError:
try:
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(
code="PAYMENT_TRANSACTION_INVALID",
detail="Failed to decode JWS header",
@@ -117,6 +118,11 @@ class AppleJwsVerifier:
bundle_id: str = str(payload.get("bundleId", ""))
if bundle_id != expected_bundle_id:
+ logger.error(
+ "bundleId mismatch: expected=%s got=%s",
+ expected_bundle_id,
+ bundle_id,
+ )
return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH",
detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}",
@@ -124,6 +130,11 @@ class AppleJwsVerifier:
product_id: str = str(payload.get("productId", ""))
if product_id != expected_product_id:
+ logger.error(
+ "productId mismatch: expected=%s got=%s",
+ expected_product_id,
+ product_id,
+ )
return VerificationError(
code="PAYMENT_PRODUCT_MISMATCH",
detail=f"productId mismatch: expected={expected_product_id} got={product_id}",
@@ -131,12 +142,18 @@ class AppleJwsVerifier:
environment: str = str(payload.get("environment", ""))
if environment not in ("Sandbox", "Production"):
+ logger.error("Invalid environment: %s", environment)
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail=f"Invalid environment: {environment}",
)
if environment != expected_environment:
+ logger.error(
+ "Environment mismatch: expected=%s got=%s",
+ expected_environment,
+ environment,
+ )
return VerificationError(
code="PAYMENT_ENVIRONMENT_MISMATCH",
detail=f"Environment mismatch: expected={expected_environment} got={environment}",
@@ -159,6 +176,7 @@ class AppleJwsVerifier:
app_account_token_raw = payload.get("appAccountToken")
if not transaction_id:
+ logger.error("Missing transactionId in payload")
return VerificationError(
code="PAYMENT_TRANSACTION_INVALID",
detail="Missing transactionId in payload",
diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py
index 0da10dc..bac5a65 100644
--- a/backend/src/v1/payments/repository.py
+++ b/backend/src/v1/payments/repository.py
@@ -83,3 +83,6 @@ class PaymentRepository:
if claim is None:
raise RuntimeError("Failed to upsert register bonus claim")
return claim
+
+ async def commit(self) -> None:
+ await self._session.commit()
diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py
index e9032bc..8ea32f1 100644
--- a/backend/src/v1/payments/service.py
+++ b/backend/src/v1/payments/service.py
@@ -35,6 +35,8 @@ class ProductMapping:
app_store_product_id: str
credits: int
type: str
+ sort_order: int = 0
+ enabled: bool = True
_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:
raw: Any = yaml.safe_load(f) or {}
- mappings: dict[str, ProductMapping] = {}
- product_mappings: Any = raw.get("product_mappings", {})
- for code, entry in product_mappings.items():
- mappings[str(code)] = ProductMapping(
- app_store_product_id=str(entry["app_store_product_id"]),
- credits=int(entry["credits"]),
- type=str(entry["type"]),
- )
+ mappings: dict[str, ProductMapping] = {}
+ product_mappings: Any = raw.get("product_mappings", {})
+ for code, entry in product_mappings.items():
+ mappings[str(code)] = ProductMapping(
+ app_store_product_id=str(entry["app_store_product_id"]),
+ credits=int(entry["credits"]),
+ type=str(entry["type"]),
+ sort_order=int(entry.get("sort_order", 0)),
+ enabled=bool(entry.get("enabled", True)),
+ )
_product_mappings_cache = mappings
return mappings
@@ -119,6 +123,12 @@ class PaymentService:
)
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
if result.code == "PAYMENT_TRANSACTION_REVOKED":
status_code = 409
@@ -132,6 +142,11 @@ class PaymentService:
verified: VerifiedTransaction = result
if str(verified.transaction_id) != request.transaction_id:
+ logger.error(
+ "transactionId mismatch: request=%s verified=%s",
+ request.transaction_id,
+ verified.transaction_id,
+ )
raise ApiProblemError(
status_code=422,
detail=problem_payload(
@@ -273,6 +288,15 @@ class PaymentService:
transaction_record.status = "granted"
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:
_ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack(
email_hash=email_hash,
@@ -280,6 +304,8 @@ class PaymentService:
first_user_id_snapshot=user_id,
)
+ await self._payment_repo.commit()
+
return VerifyTransactionResponse(
status="granted",
productCode=request.product_code,
@@ -313,11 +339,6 @@ class PaymentService:
return
if txn.status not in ("granted",):
- logger.info(
- "Refund skipped: transaction %s status=%s",
- transaction_id,
- txn.status,
- )
return
user_id = txn.user_id
@@ -405,6 +426,8 @@ class PaymentService:
txn.status,
)
+ await self._payment_repo.commit()
+
async def handle_server_notification(self, *, signed_payload: str) -> None:
if not signed_payload:
logger.warning("Empty Apple server notification payload")
@@ -467,13 +490,4 @@ class PaymentService:
return
if notification_type == "DID_RENEW" and transaction_id:
- logger.info(
- "Apple DID_RENEW for transaction %s, no action needed",
- transaction_id,
- )
return
-
- logger.info(
- "Apple notification type=%s not handled, skipped",
- notification_type,
- )
diff --git a/backend/tests/integration/payments/test_verify_flow.py b/backend/tests/integration/payments/test_verify_flow.py
index 66a8b1c..880f5a9 100644
--- a/backend/tests/integration/payments/test_verify_flow.py
+++ b/backend/tests/integration/payments/test_verify_flow.py
@@ -20,8 +20,8 @@ async def test_verify_endpoint_returns_401_without_auth() -> None:
response = await client.post(
"/api/v1/payments/apple/transactions/verify",
json={
- "productCode": "basic_pack",
- "appStoreProductId": "com.meeyao.qianwen.basic_pack",
+ "productCode": "starter_pack",
+ "appStoreProductId": "com.meeyao.qianwen.starter_pack",
"transactionId": "0000000000000001",
"signedTransactionInfo": "fake_jws",
},
diff --git a/backend/tests/unit/payments/__init__.py b/backend/tests/unit/payments/__init__.py
index 5e739bf..a96f1cd 100644
--- a/backend/tests/unit/payments/__init__.py
+++ b/backend/tests/unit/payments/__init__.py
@@ -22,7 +22,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction(
"not-a-jws",
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 result.code == "PAYMENT_TRANSACTION_INVALID"
@@ -34,7 +34,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction(
f"{h}.{p}.fake",
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 "x5c" in result.detail
@@ -45,7 +45,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction(
f"{h}.{p}.fake",
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 "x5c" in result.detail
@@ -62,7 +62,7 @@ class TestAppleJwsVerifierInvalidInput:
result = verifier.verify_signed_transaction(
f"{h}.{p}.fake",
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 "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail
diff --git a/backend/tests/unit/payments/test_payment_service.py b/backend/tests/unit/payments/test_payment_service.py
index 3fb48e7..c9a539d 100644
--- a/backend/tests/unit/payments/test_payment_service.py
+++ b/backend/tests/unit/payments/test_payment_service.py
@@ -30,6 +30,7 @@ class _FakePaymentRepository:
self.inserted_transactions: list[AppleIapTransaction] = []
self.claim: RegisterBonusClaims | None = None
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:
return self.account
@@ -59,6 +60,9 @@ class _FakePaymentRepository:
self.claim.has_purchased_starter_pack = True
return self.claim
+ async def commit(self) -> None:
+ self.commit_count += 1
+
class _FakePointsRepository:
def __init__(self) -> None:
@@ -78,14 +82,17 @@ class _FakeVerifier:
*,
expected_bundle_id: str,
expected_product_id: str,
+ expected_environment: str,
) -> VerifiedTransaction | VerificationError:
+ del signed_transaction_info, expected_bundle_id, expected_product_id
+ del expected_environment
return self._result
def _make_verified_transaction(
*,
transaction_id: str = "2000000123456789",
- product_id: str = "com.meeyao.qianwen.basic_pack",
+ product_id: str = "com.meeyao.qianwen.starter_pack",
environment: str = "Sandbox",
) -> VerifiedTransaction:
return VerifiedTransaction(
@@ -134,7 +141,7 @@ class TestPaymentServiceProductMismatch:
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
- productCode="basic_pack",
+ productCode="starter_pack",
appStoreProductId="com.meeyao.qianwen.wrong_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
@@ -162,8 +169,8 @@ class TestPaymentServiceVerificationFailed:
),
)
request = VerifyTransactionRequest(
- productCode="basic_pack",
- appStoreProductId="com.meeyao.qianwen.basic_pack",
+ productCode="starter_pack",
+ appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
@@ -183,8 +190,8 @@ class TestPaymentServiceAlreadyGranted:
existing = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000123456789",
original_transaction_id="2000000123456789",
environment="Sandbox",
@@ -202,8 +209,8 @@ class TestPaymentServiceAlreadyGranted:
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
- productCode="basic_pack",
- appStoreProductId="com.meeyao.qianwen.basic_pack",
+ productCode="starter_pack",
+ appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
@@ -222,8 +229,8 @@ class TestPaymentServiceTransactionConflict:
existing = AppleIapTransaction(
id=uuid4(),
user_id=uuid4(),
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000123456789",
original_transaction_id="2000000123456789",
environment="Sandbox",
@@ -241,8 +248,8 @@ class TestPaymentServiceTransactionConflict:
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
- productCode="basic_pack",
- appStoreProductId="com.meeyao.qianwen.basic_pack",
+ productCode="starter_pack",
+ appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
@@ -266,8 +273,8 @@ class TestPaymentServiceSuccessfulGrant:
verifier=_FakeVerifier(result=_make_verified_transaction()),
)
request = VerifyTransactionRequest(
- productCode="basic_pack",
- appStoreProductId="com.meeyao.qianwen.basic_pack",
+ productCode="starter_pack",
+ appStoreProductId="com.meeyao.qianwen.starter_pack",
transactionId="2000000123456789",
signedTransactionInfo="fake_jws",
)
@@ -368,6 +375,7 @@ class _FakePaymentRepoForRefund:
self._transaction = transaction
self.account = account or _FakeAccountForRefund()
self.inserted_transactions: list[AppleIapTransaction] = []
+ self.commit_count = 0
async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None:
return self._transaction
@@ -389,6 +397,9 @@ class _FakePaymentRepoForRefund:
) -> None:
pass
+ async def commit(self) -> None:
+ self.commit_count += 1
+
class TestProcessRefundUnknownTransaction:
@pytest.mark.asyncio
@@ -407,8 +418,8 @@ class TestProcessRefundNotGranted:
txn = AppleIapTransaction(
id=uuid4(),
user_id=uuid4(),
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999999",
original_transaction_id="2000000999999999",
environment="Sandbox",
@@ -435,8 +446,8 @@ class TestProcessRefundSufficientBalance:
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999999",
original_transaction_id="2000000999999999",
environment="Sandbox",
@@ -473,8 +484,8 @@ class TestProcessRefundInsufficientBalance:
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999998",
original_transaction_id="2000000999999998",
environment="Sandbox",
@@ -509,8 +520,8 @@ class TestProcessRefundIdempotency:
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999997",
original_transaction_id="2000000999999997",
environment="Sandbox",
@@ -539,8 +550,8 @@ class TestHandleServerNotificationRefund:
txn = AppleIapTransaction(
id=uuid4(),
user_id=user_id,
- product_code="basic_pack",
- app_store_product_id="com.meeyao.qianwen.basic_pack",
+ product_code="starter_pack",
+ app_store_product_id="com.meeyao.qianwen.starter_pack",
transaction_id="2000000999999001",
original_transaction_id="2000000999999001",
environment="Sandbox",
diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md
index 58845a9..3121e02 100644
--- a/docs/protocols/common/http-error-codes.md
+++ b/docs/protocols/common/http-error-codes.md
@@ -19,6 +19,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
| code | status | meaning | frontend handling |
|---|---:|---|---|
| `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
@@ -82,6 +83,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
| 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_INVALID_CURSOR` | 422 | Notification pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page |
## Invite
diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md
index 8782788..0a66f6c 100644
--- a/docs/protocols/common/user-points-chat-data-protocol.md
+++ b/docs/protocols/common/user-points-chat-data-protocol.md
@@ -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.
+## 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
### GET /api/v1/points/packages
@@ -243,25 +286,21 @@ Returns available purchase packages for the current user's region, including sta
**Response:**
```json
{
- "region": "US",
- "currency": "USD",
"packages": [
{
"productCode": "new_user_pack",
+ "appStoreProductId": "com.meeyao.qianwen.new_user_pack",
"type": "starter",
- "price": "0.99",
"credits": 60,
- "badge": null,
"isStarter": true,
"starterEligible": true,
"sortOrder": 0
},
{
- "productCode": "basic_pack",
+ "productCode": "starter_pack",
+ "appStoreProductId": "com.meeyao.qianwen.starter_pack",
"type": "regular",
- "price": "4.99",
"credits": 100,
- "badge": null,
"isStarter": false,
"starterEligible": false,
"sortOrder": 10
@@ -271,51 +310,38 @@ Returns available purchase packages for the current user's region, including sta
```
**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
- - `productCode`: Unique product identifier (e.g., `new_user_pack`, `basic_pack`, `popular_pack`, `premium_pack`)
- - `type`: "starter" (new user pack) or "regular"
- - `price`: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price)
- - `credits`: Number of credits
- - `badge`: Optional badge text (e.g., "Popular")
- - `isStarter`: Whether this is a starter pack
- - `starterEligible`: Whether user is eligible to purchase starter pack
- - `sortOrder`: Display order (ascending)
+- `productCode`: Unique product identifier (e.g., `new_user_pack`, `starter_pack`, `popular_pack`, `premium_pack`)
+- `appStoreProductId`: Apple App Store product identifier used for StoreKit purchase
+- `type`: "starter" (new user pack) or "regular"
+- `credits`: Number of credits
+- `isStarter`: Whether this is a starter pack
+- `starterEligible`: Whether user is eligible to purchase starter pack
+- `sortOrder`: Display order (ascending)
**Business Logic:**
-1. Determine user's region from `profile.settings.preferences.country` (default: "US")
-2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`)
-3. Check starter pack eligibility:
- - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
- - Otherwise, include starter pack with `starterEligible: true`
+1. Load package mapping from `backend/src/core/config/static/packages/mapping.yaml`
+2. Check starter pack eligibility:
+ - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
+ - Otherwise, include starter pack with `starterEligible: true`
**Configuration Files:**
- Path: `backend/src/core/config/static/packages/`
- Format: YAML
-- Example: `us.yaml`
+- Example: `mapping.yaml`
```yaml
-region: US
-currency: USD
-packages:
- - product_code: new_user_pack
- type: starter
- price: "0.99"
+product_mappings:
+ new_user_pack:
+ app_store_product_id: com.meeyao.qianwen.new_user_pack
credits: 60
- badge: null
+ type: starter
sort_order: 0
enabled: true
- - product_code: basic_pack
- type: regular
- price: "4.99"
+ starter_pack:
+ app_store_product_id: com.meeyao.qianwen.starter_pack
credits: 100
- badge: null
+ type: regular
sort_order: 10
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)