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

- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml
- 优化 Apple IAP 服务和验证逻辑
- 更新套餐数据模型和协议文档
- 添加支付相关测试用例
This commit is contained in:
ZL-Q
2026-04-28 17:21:14 +08:00
parent a940f2ea47
commit 295dbc09ab
23 changed files with 285 additions and 304 deletions
+5 -5
View File
@@ -23,17 +23,17 @@
{
"displayPrice" : "6.00",
"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"
},
{
+6
View File
@@ -32,6 +32,12 @@
<string>需要将头像处理结果保存到您的相册</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要麦克风权限用于语音追问</string>
<key>NSLocalNetworkUsageDescription</key>
<string>用于开发调试时连接本地调试服务。</string>
<key>NSBonjourServices</key>
<array>
<string>_dartobservatory._tcp</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -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<void> _handlePurchase(PurchaseDetails purchase) async {
switch (purchase.status) {
case PurchaseStatus.purchased:
await _verifyAndComplete(purchase);
case PurchaseStatus.canceled:
await _inAppPurchase.completePurchase(purchase);
_setState(PurchaseFlowState.idle);
case PurchaseStatus.error:
await _inAppPurchase.completePurchase(purchase);
_setError(purchase.error?.message ?? 'Purchase failed');
case PurchaseStatus.pending:
_setState(PurchaseFlowState.purchasing);
case PurchaseStatus.restored:
await _inAppPurchase.completePurchase(purchase);
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<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',
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,
@@ -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<PackageInfo> packages;
factory PackagesResult.fromJson(Map<String, dynamic> json) {
return PackagesResult(
region: json['region'] as String,
currency: json['currency'] as String,
packages: (json['packages'] as List<dynamic>)
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
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<AppColorPalette> {
final Color warning;
final Color warningContainer;
final Color onWarningContainer;
final Color incomeGreenBg;
final Color incomeGreenText;
@override
ThemeExtension<AppColorPalette> copyWith({
@@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
Color? warning,
Color? warningContainer,
Color? onWarningContainer,
Color? incomeGreenBg,
Color? incomeGreenText,
}) {
return AppColorPalette(
accentPurple: accentPurple ?? this.accentPurple,
@@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension<AppColorPalette> {
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<AppColorPalette> {
other.onWarningContainer,
t,
)!,
incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!,
incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!,
);
}
}
+1
View File
@@ -43,6 +43,7 @@ dependencies:
onboarding_overlay: ^3.2.3
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.
@@ -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,
@@ -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",
]
+1 -30
View File
@@ -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
+2 -36
View File
@@ -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
@@ -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
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
@@ -1,30 +0,0 @@
region: US
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: 0.99
credits: 60
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: 4.99
credits: 100
sort_order: 10
enabled: true
- product_code: popular_pack
type: regular
price: 7.99
credits: 210
sort_order: 20
enabled: true
- product_code: premium_pack
type: regular
price: 12.99
credits: 415
sort_order: 30
enabled: true
-2
View File
@@ -6,7 +6,6 @@ from utils.paths import (
get_gua_catalog_path,
get_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",
+4 -8
View File
@@ -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"
+20 -2
View File
@@ -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",
+3
View File
@@ -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()
+36 -22
View File
@@ -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,
)
@@ -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",
},
+4 -4
View File
@@ -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
@@ -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",
@@ -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
@@ -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)