Files
Aether/crates/aether-data/adapters/sqlite/src/wallet/tests.rs
T

1506 lines
54 KiB
Rust

use super::SqliteWalletReadRepository;
use crate::run_migrations;
use aether_data_contracts::repository::wallet::{
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeListQuery,
AdminWalletListQuery, CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
FailAdminWalletRefundInput, ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome, WalletLookupKey,
WalletMutationOutcome, WalletReadRepository, WalletWriteRepository,
};
use serde_json::json;
#[tokio::test]
async fn sqlite_wallet_read_repository_reads_wallet_contract_views() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
seed_rows(&pool).await;
let repository = SqliteWalletReadRepository::new(pool);
let wallet = repository
.find(WalletLookupKey::UserId("user-1"))
.await
.expect("wallet find should query")
.expect("wallet should exist");
assert_eq!(wallet.total_adjusted, 3.0);
let page = repository
.list_admin_wallets(&AdminWalletListQuery {
status: Some("active".to_string()),
owner_type: Some("user".to_string()),
limit: 10,
offset: 0,
})
.await
.expect("admin wallets should list");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].total_adjusted, 3.0);
let orders = repository
.list_admin_payment_orders(&AdminPaymentOrderListQuery {
status: Some("credited".to_string()),
payment_method: Some("redeem_code".to_string()),
limit: 10,
offset: 0,
})
.await
.expect("payment orders should list");
assert_eq!(orders.total, 1);
assert_eq!(
orders.items[0].gateway_response.as_ref().unwrap()["ok"],
true
);
let refunds = repository
.list_admin_wallet_refunds("wallet-1", 10, 0)
.await
.expect("refunds should list");
assert_eq!(refunds.total, 1);
assert_eq!(
refunds.items[0].payout_proof.as_ref().unwrap()["proof"],
"ok"
);
let callbacks = repository
.list_admin_payment_callbacks(Some("redeem_code"), 10, 0)
.await
.expect("callbacks should list");
assert_eq!(callbacks.total, 1);
assert!(callbacks.items[0].signature_valid);
let codes = repository
.list_admin_redeem_codes(&AdminRedeemCodeListQuery {
batch_id: "batch-1".to_string(),
status: Some("redeemed".to_string()),
limit: 10,
offset: 0,
})
.await
.expect("redeem codes should list");
assert_eq!(codes.total, 1);
assert_eq!(codes.items[0].masked_code, "ABCD****WXYZ");
let today = super::current_billing_date("UTC").expect("UTC should parse");
sqlx::query("UPDATE wallet_daily_usage_ledgers SET billing_date = ? WHERE id = 'daily-1'")
.bind(today)
.execute(repository.pool())
.await
.expect("daily row should update");
let daily = repository
.find_wallet_today_usage("wallet-1", "UTC")
.await
.expect("daily usage should query")
.expect("daily usage should exist");
assert_eq!(daily.total_requests, 2);
}
#[tokio::test]
async fn sqlite_wallet_write_repository_handles_public_recharge_callback_and_refund() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteWalletReadRepository::new(pool);
let order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-write-1".to_string()),
user_id: "user-write-1".to_string(),
amount_usd: 12.5,
pay_amount: Some(12.5),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payment_method: "alipay".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-order-write-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-no-write-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("recharge order should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new wallet should be active")
}
};
assert_eq!(order.status, "pending");
let callback = repository
.process_payment_callback(ProcessPaymentCallbackInput {
payment_method: "alipay".to_string(),
payment_provider: None,
payment_channel: None,
callback_key: "callback-write-1".to_string(),
order_no: Some("order-no-write-1".to_string()),
gateway_order_id: Some("gateway-order-write-1".to_string()),
amount_usd: 12.5,
pay_amount: Some(12.5),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payload_hash: "payload-hash-write-1".to_string(),
payload: json!({ "status": "paid" }),
signature_valid: true,
})
.await
.expect("payment callback should process");
let ProcessPaymentCallbackOutcome::Applied {
wallet_id, order, ..
} = callback
else {
panic!("callback should credit the order");
};
assert_eq!(wallet_id, "wallet-write-1");
assert_eq!(order.status, "credited");
let wallet = repository
.find(WalletLookupKey::UserId("user-write-1"))
.await
.expect("wallet should query")
.expect("wallet should exist");
assert_eq!(wallet.balance, 12.5);
assert_eq!(wallet.total_recharged, 12.5);
let refund = repository
.create_wallet_refund_request(CreateWalletRefundRequestInput {
wallet_id: wallet.id.clone(),
user_id: "user-write-1".to_string(),
amount_usd: 4.0,
payment_order_id: Some(order.id.clone()),
source_type: None,
source_id: None,
refund_mode: None,
reason: Some("requested".to_string()),
idempotency_key: Some("idem-refund-write-1".to_string()),
refund_no: "refund-no-write-1".to_string(),
})
.await
.expect("refund request should create");
let CreateWalletRefundRequestOutcome::Created(refund) = refund else {
panic!("refund request should be created");
};
let duplicate = repository
.create_wallet_refund_request(CreateWalletRefundRequestInput {
wallet_id: wallet.id.clone(),
user_id: "user-write-1".to_string(),
amount_usd: 4.0,
payment_order_id: Some(order.id.clone()),
source_type: None,
source_id: None,
refund_mode: None,
reason: Some("requested".to_string()),
idempotency_key: Some("idem-refund-write-1".to_string()),
refund_no: "refund-no-write-duplicate".to_string(),
})
.await
.expect("duplicate refund request should resolve");
assert!(matches!(
duplicate,
CreateWalletRefundRequestOutcome::Duplicate(_)
));
let processed = repository
.process_admin_wallet_refund(ProcessAdminWalletRefundInput {
wallet_id: wallet.id.clone(),
refund_id: refund.id.clone(),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("refund should process");
let WalletMutationOutcome::Applied((wallet, refund, transaction)) = processed else {
panic!("refund should be processed");
};
assert_eq!(wallet.balance, 8.5);
assert_eq!(refund.status, "processing");
assert_eq!(transaction.reason_code, "refund_out");
let completed = repository
.complete_admin_wallet_refund(CompleteAdminWalletRefundInput {
wallet_id: wallet.id.clone(),
refund_id: refund.id.clone(),
gateway_refund_id: Some("gateway-refund-write-1".to_string()),
payout_reference: Some("payout-ref-write-1".to_string()),
payout_proof: Some(json!({ "proof": "ok" })),
})
.await
.expect("refund should complete");
let WalletMutationOutcome::Applied(completed_refund) = completed else {
panic!("refund should be completed");
};
assert_eq!(completed_refund.status, "succeeded");
assert_eq!(
completed_refund.payout_proof.as_ref().unwrap()["proof"],
"ok"
);
let refund_to_fail = repository
.create_wallet_refund_request(CreateWalletRefundRequestInput {
wallet_id: wallet.id.clone(),
user_id: "user-write-1".to_string(),
amount_usd: 1.5,
payment_order_id: Some(order.id.clone()),
source_type: None,
source_id: None,
refund_mode: None,
reason: Some("requested again".to_string()),
idempotency_key: Some("idem-refund-write-2".to_string()),
refund_no: "refund-no-write-2".to_string(),
})
.await
.expect("second refund request should create");
let CreateWalletRefundRequestOutcome::Created(refund_to_fail) = refund_to_fail else {
panic!("second refund request should be created");
};
let processed_to_fail = repository
.process_admin_wallet_refund(ProcessAdminWalletRefundInput {
wallet_id: wallet.id.clone(),
refund_id: refund_to_fail.id.clone(),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("second refund should process");
assert!(matches!(
processed_to_fail,
WalletMutationOutcome::Applied(_)
));
let failed = repository
.fail_admin_wallet_refund(FailAdminWalletRefundInput {
wallet_id: wallet.id.clone(),
refund_id: refund_to_fail.id.clone(),
reason: "manual failure".to_string(),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("second refund should fail");
let WalletMutationOutcome::Applied((wallet, failed_refund, revert_transaction)) = failed else {
panic!("second refund should fail with wallet restoration");
};
assert_eq!(wallet.balance, 8.5);
assert_eq!(failed_refund.status, "failed");
assert_eq!(
revert_transaction.as_ref().unwrap().reason_code,
"refund_revert"
);
let batch = repository
.create_admin_redeem_code_batch(CreateAdminRedeemCodeBatchInput {
name: "Write Batch".to_string(),
amount_usd: 3.5,
currency: "USD".to_string(),
balance_bucket: "gift".to_string(),
total_count: 1,
expires_at_unix_secs: None,
description: Some("write smoke".to_string()),
created_by: Some("admin-1".to_string()),
})
.await
.expect("redeem batch should create");
assert_eq!(batch.batch.active_count, 1);
let redeem_code = batch.codes[0].code.clone();
let redeem = repository
.redeem_wallet_code(RedeemWalletCodeInput {
code: redeem_code,
user_id: "user-write-1".to_string(),
order_no: "redeem-order-write-1".to_string(),
})
.await
.expect("redeem should apply");
let RedeemWalletCodeOutcome::Redeemed {
wallet,
order,
amount_usd,
batch_name,
} = redeem
else {
panic!("redeem should succeed");
};
assert_eq!(wallet.gift_balance, 3.5);
assert_eq!(order.payment_method, "gift_code");
assert_eq!(amount_usd, 3.5);
assert_eq!(batch_name, "Write Batch");
let disabled_batch = repository
.create_admin_redeem_code_batch(CreateAdminRedeemCodeBatchInput {
name: "Disabled Batch".to_string(),
amount_usd: 1.25,
currency: "USD".to_string(),
balance_bucket: "gift".to_string(),
total_count: 2,
expires_at_unix_secs: None,
description: Some("disable smoke".to_string()),
created_by: Some("admin-1".to_string()),
})
.await
.expect("disable batch should create");
let disabled_code = repository
.disable_admin_redeem_code(DisableAdminRedeemCodeInput {
code_id: disabled_batch.codes[0].code_id.clone(),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("redeem code should disable");
let WalletMutationOutcome::Applied(disabled_code) = disabled_code else {
panic!("redeem code should be disabled");
};
assert_eq!(disabled_code.status, "disabled");
let disabled_batch = repository
.disable_admin_redeem_code_batch(DisableAdminRedeemCodeBatchInput {
batch_id: disabled_batch.batch.id.clone(),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("redeem batch should disable");
let WalletMutationOutcome::Applied(disabled_batch) = disabled_batch else {
panic!("redeem batch should be disabled");
};
assert_eq!(disabled_batch.status, "disabled");
assert_eq!(disabled_batch.active_count, 0);
let deleted_batch = repository
.delete_admin_redeem_code_batch(DeleteAdminRedeemCodeBatchInput {
batch_id: disabled_batch.id,
operator_id: Some("admin-1".to_string()),
})
.await
.expect("disabled unredeemed batch should delete");
assert!(matches!(deleted_batch, WalletMutationOutcome::Applied(_)));
let (wallet, adjustment) = repository
.adjust_wallet_balance(AdjustWalletBalanceInput {
wallet_id: wallet.id.clone(),
amount_usd: -2.0,
balance_type: "gift".to_string(),
operator_id: Some("admin-1".to_string()),
description: Some("trim gift".to_string()),
})
.await
.expect("adjustment should run")
.expect("wallet should exist");
assert_eq!(wallet.gift_balance, 1.5);
assert_eq!(adjustment.reason_code, "adjust_admin");
assert_eq!(
adjustment.balance_after,
wallet.balance + wallet.gift_balance
);
let (wallet, order) = repository
.create_manual_wallet_recharge(CreateManualWalletRechargeInput {
wallet_id: wallet.id,
amount_usd: 5.0,
payment_method: "admin_manual".to_string(),
operator_id: Some("admin-1".to_string()),
description: Some("manual topup".to_string()),
order_no: "manual-order-write-1".to_string(),
})
.await
.expect("manual recharge should run")
.expect("wallet should exist");
assert_eq!(wallet.balance, 13.5);
assert_eq!(order.status, "credited");
assert_eq!(order.payment_method, "admin_manual");
let credit_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-credit-1".to_string()),
user_id: "user-credit-1".to_string(),
amount_usd: 2.25,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
payment_method: "manual_gateway".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-order-credit-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-no-credit-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("credit order should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new credit wallet should be active")
}
};
let credited = repository
.credit_admin_payment_order(CreditAdminPaymentOrderInput {
order_id: credit_order.id.clone(),
gateway_order_id: Some("gateway-order-credit-paid-1".to_string()),
pay_amount: Some(2.25),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
gateway_response_patch: Some(json!({ "settled": true })),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("credit order should apply");
let WalletMutationOutcome::Applied((credited_order, applied)) = credited else {
panic!("credit order should be applied");
};
assert!(applied);
assert_eq!(credited_order.status, "credited");
assert_eq!(
credited_order.gateway_response.as_ref().unwrap()["manual_credit"],
true
);
let credited_again = repository
.credit_admin_payment_order(CreditAdminPaymentOrderInput {
order_id: credit_order.id,
gateway_order_id: None,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
gateway_response_patch: None,
operator_id: Some("admin-1".to_string()),
})
.await
.expect("credit order should be idempotent");
assert!(matches!(
credited_again,
WalletMutationOutcome::Applied((_, false))
));
let expiring_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-expire-1".to_string()),
user_id: "user-expire-1".to_string(),
amount_usd: 1.0,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
payment_method: "alipay".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-order-expire-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-no-expire-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("expiring order should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new expire wallet should be active")
}
};
let expired = repository
.expire_admin_payment_order(&expiring_order.id)
.await
.expect("expire should run");
assert!(matches!(expired, WalletMutationOutcome::Applied((_, true))));
let expired_again = repository
.expire_admin_payment_order(&expiring_order.id)
.await
.expect("expire should be idempotent");
assert!(matches!(
expired_again,
WalletMutationOutcome::Applied((_, false))
));
let failing_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-fail-1".to_string()),
user_id: "user-fail-1".to_string(),
amount_usd: 1.0,
pay_amount: None,
pay_currency: None,
exchange_rate: None,
payment_method: "alipay".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-order-fail-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-no-fail-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("failing order should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new fail wallet should be active")
}
};
let failed_order = repository
.fail_admin_payment_order(&failing_order.id)
.await
.expect("fail should run");
let WalletMutationOutcome::Applied(failed_order) = failed_order else {
panic!("payment order should fail");
};
assert_eq!(failed_order.status, "failed");
}
#[tokio::test]
async fn sqlite_plan_purchase_blocks_duplicate_pending_active_period_order_and_manual_credit_fulfills(
) {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteWalletReadRepository::new(pool);
sqlx::query(
"INSERT INTO users (id, username, email, auth_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("user-active-period-1")
.bind("Active Period Buyer")
.bind("active-period@example.com")
.bind("local")
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("user should seed");
let _wallet_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-active-period-1".to_string()),
user_id: "user-active-period-1".to_string(),
amount_usd: 1.0,
pay_amount: Some(1.0),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payment_method: "bootstrap".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-bootstrap-active-period-1".to_string(),
gateway_response: json!({ "bootstrap": true }),
order_no: "order-bootstrap-active-period-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("wallet should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new wallet should be active")
}
};
let plan_snapshot = json!({
"id": "active-period-plan",
"title": "每日额度月卡",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "active_period",
"entitlements": [
{
"type": "daily_quota",
"daily_quota_usd": 50.0,
"reset_timezone": "Asia/Shanghai",
"allow_wallet_overage": false
}
]
});
sqlx::query(
r#"
INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit, duration_value,
max_active_per_user, purchase_limit_scope, entitlements_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind("active-period-plan")
.bind("每日额度月卡")
.bind(100.0_f64)
.bind("CNY")
.bind("month")
.bind(1_i64)
.bind(1_i64)
.bind("active_period")
.bind(plan_snapshot["entitlements"].to_string())
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("billing plan should seed");
let first_order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-active-period-1".to_string(),
amount_usd: 13.8,
pay_amount: 100.0,
pay_currency: "CNY".to_string(),
exchange_rate: 7.24637681,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-plan-active-period-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-plan-active-period-1".to_string(),
product_id: "active-period-plan".to_string(),
product_snapshot: plan_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("first active period order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("first active period order should be created, got {other:?}"),
};
let duplicate_pending = repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-active-period-1".to_string(),
amount_usd: 13.8,
pay_amount: 100.0,
pay_currency: "CNY".to_string(),
exchange_rate: 7.24637681,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-plan-active-period-2".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-plan-active-period-2".to_string(),
product_id: "active-period-plan".to_string(),
product_snapshot: plan_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("duplicate active period order should resolve");
assert!(matches!(
duplicate_pending,
CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached
));
let credited = repository
.credit_admin_payment_order(CreditAdminPaymentOrderInput {
order_id: first_order.id.clone(),
gateway_order_id: Some("gateway-plan-active-period-paid-1".to_string()),
pay_amount: Some(100.0),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.24637681),
gateway_response_patch: Some(json!({ "settled": true })),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("manual plan credit should run");
let WalletMutationOutcome::Applied((credited_order, applied)) = credited else {
panic!("manual plan credit should be applied");
};
assert!(applied);
assert_eq!(credited_order.status, "credited");
assert_eq!(credited_order.refundable_amount_usd, 0.0);
let entitlement_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE user_id = ? AND plan_id = ?",
)
.bind("user-active-period-1")
.bind("active-period-plan")
.fetch_one(repository.pool())
.await
.expect("entitlement count should query");
assert_eq!(entitlement_count, 1);
let wallet_balance: f64 = sqlx::query_scalar("SELECT balance FROM wallets WHERE id = ?")
.bind("wallet-active-period-1")
.fetch_one(repository.pool())
.await
.expect("wallet balance should query");
assert_eq!(wallet_balance, 0.0);
}
#[tokio::test]
async fn sqlite_finds_reusable_pending_plan_purchase_order() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteWalletReadRepository::new(pool);
sqlx::query(
"INSERT INTO users (id, username, email, auth_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("user-pending-plan-1")
.bind("Pending Buyer")
.bind("pending-plan@example.com")
.bind("local")
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("user should seed");
let _wallet_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-pending-plan-1".to_string()),
user_id: "user-pending-plan-1".to_string(),
amount_usd: 1.0,
pay_amount: Some(1.0),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payment_method: "bootstrap".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-bootstrap-pending-plan-1".to_string(),
gateway_response: json!({ "bootstrap": true }),
order_no: "order-bootstrap-pending-plan-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("wallet should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new wallet should be active")
}
};
let plan_snapshot = json!({
"id": "pending-plan",
"title": "每日额度月卡",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "active_period",
"entitlements": [
{
"type": "daily_quota",
"daily_quota_usd": 50.0,
"reset_timezone": "Asia/Shanghai",
"allow_wallet_overage": false
}
]
});
let pending_order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-pending-plan-1".to_string(),
amount_usd: 13.8,
pay_amount: 100.0,
pay_currency: "CNY".to_string(),
exchange_rate: 7.24637681,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-pending-plan-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-pending-plan-1".to_string(),
product_id: "pending-plan".to_string(),
product_snapshot: plan_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("pending plan order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("pending plan order should be created, got {other:?}"),
};
let now = chrono::Utc::now().timestamp().max(0);
for (id, order_no, status, product_id, user_id, expires_at, created_at) in [
(
"expired-pending-plan-order",
"order-expired-pending-plan",
"pending",
"pending-plan",
"user-pending-plan-1",
now - 10,
now + 10,
),
(
"credited-pending-plan-order",
"order-credited-pending-plan",
"credited",
"pending-plan",
"user-pending-plan-1",
now + 3_600,
now + 20,
),
(
"other-user-pending-plan-order",
"order-other-user-pending-plan",
"pending",
"pending-plan",
"other-user",
now + 3_600,
now + 30,
),
] {
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency,
exchange_rate, refunded_amount_usd, refundable_amount_usd, payment_method,
payment_provider, payment_channel, order_kind, product_id, product_snapshot,
fulfillment_status, gateway_order_id, gateway_response, status, created_at, expires_at
) VALUES (?, ?, ?, ?, 13.8, 100.0, 'CNY', 7.24637681, 0, 0, 'alipay',
'epay', 'alipay', 'plan_purchase', ?, ?, 'pending', ?, ?, ?, ?, ?)
"#,
)
.bind(id)
.bind(order_no)
.bind("wallet-pending-plan-1")
.bind(user_id)
.bind(product_id)
.bind(plan_snapshot.to_string())
.bind(format!("gateway-{id}"))
.bind(json!({ "checkout": id }).to_string())
.bind(status)
.bind(created_at)
.bind(expires_at)
.execute(repository.pool())
.await
.expect("extra payment order should seed");
}
let found = repository
.find_pending_plan_purchase_order_by_user_id("user-pending-plan-1", "pending-plan")
.await
.expect("pending plan lookup should run")
.expect("pending plan order should be found");
assert_eq!(found.id, pending_order.id);
assert_eq!(
repository
.find_pending_plan_purchase_order_by_user_id("user-pending-plan-1", "missing-plan")
.await
.expect("missing plan lookup should run"),
None
);
assert_eq!(
repository
.find_pending_plan_purchase_order_by_user_id("missing-user", "pending-plan")
.await
.expect("missing user lookup should run"),
None
);
}
#[tokio::test]
async fn sqlite_plan_purchase_replaces_same_class_entitlements_on_manual_credit() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteWalletReadRepository::new(pool);
sqlx::query(
"INSERT INTO users (id, username, email, auth_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("user-upgrade-1")
.bind("Upgrade Buyer")
.bind("upgrade@example.com")
.bind("local")
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("user should seed");
let _wallet_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-upgrade-1".to_string()),
user_id: "user-upgrade-1".to_string(),
amount_usd: 1.0,
pay_amount: Some(1.0),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payment_method: "bootstrap".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-bootstrap-upgrade-1".to_string(),
gateway_response: json!({ "bootstrap": true }),
order_no: "order-bootstrap-upgrade-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("wallet should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new wallet should be active")
}
};
let low_snapshot = json!({
"id": "pro-basic",
"title": "Pro Basic",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "active_period",
"entitlements": [{"type": "daily_quota", "daily_quota_usd": 10.0}]
});
let high_snapshot = json!({
"id": "pro-plus",
"title": "Pro Plus",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "active_period",
"entitlements": [{"type": "daily_quota", "daily_quota_usd": 50.0}]
});
for (id, title, snapshot) in [
("pro-basic", "Pro Basic", &low_snapshot),
("pro-plus", "Pro Plus", &high_snapshot),
] {
sqlx::query(
r#"
INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit, duration_value,
max_active_per_user, purchase_limit_scope, entitlements_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(id)
.bind(title)
.bind(100.0_f64)
.bind("CNY")
.bind("month")
.bind(1_i64)
.bind(1_i64)
.bind("active_period")
.bind(snapshot["entitlements"].to_string())
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("billing plan should seed");
}
let low_order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-upgrade-1".to_string(),
amount_usd: 13.8,
pay_amount: 100.0,
pay_currency: "CNY".to_string(),
exchange_rate: 7.24637681,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-pro-basic-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-pro-basic-1".to_string(),
product_id: "pro-basic".to_string(),
product_snapshot: low_snapshot,
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("low order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("low order should be created, got {other:?}"),
};
let WalletMutationOutcome::Applied((_, true)) = repository
.credit_admin_payment_order(CreditAdminPaymentOrderInput {
order_id: low_order.id,
gateway_order_id: Some("gateway-pro-basic-paid-1".to_string()),
pay_amount: Some(100.0),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.24637681),
gateway_response_patch: Some(json!({ "settled": true })),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("low plan credit should run")
else {
panic!("low plan credit should apply");
};
let high_order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-upgrade-1".to_string(),
amount_usd: 13.8,
pay_amount: 100.0,
pay_currency: "CNY".to_string(),
exchange_rate: 7.24637681,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-pro-plus-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-pro-plus-1".to_string(),
product_id: "pro-plus".to_string(),
product_snapshot: high_snapshot,
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("high order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("high order should be created, got {other:?}"),
};
let WalletMutationOutcome::Applied((_, true)) = repository
.credit_admin_payment_order(CreditAdminPaymentOrderInput {
order_id: high_order.id,
gateway_order_id: Some("gateway-pro-plus-paid-1".to_string()),
pay_amount: Some(100.0),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.24637681),
gateway_response_patch: Some(json!({ "settled": true })),
operator_id: Some("admin-1".to_string()),
})
.await
.expect("high plan credit should run")
else {
panic!("high plan credit should apply");
};
let low_status: String = sqlx::query_scalar(
"SELECT status FROM user_plan_entitlements WHERE user_id = ? AND plan_id = ?",
)
.bind("user-upgrade-1")
.bind("pro-basic")
.fetch_one(repository.pool())
.await
.expect("low entitlement status should query");
assert_eq!(low_status, "replaced");
let active_high_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE user_id = ? AND plan_id = ? AND status = 'active'",
)
.bind("user-upgrade-1")
.bind("pro-plus")
.fetch_one(repository.pool())
.await
.expect("high entitlement count should query");
assert_eq!(active_high_count, 1);
}
#[tokio::test]
async fn sqlite_plan_purchase_respects_lifetime_purchase_limit() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteWalletReadRepository::new(pool);
sqlx::query(
"INSERT INTO users (id, username, email, auth_source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind("user-lifetime-1")
.bind("Lifetime Buyer")
.bind("lifetime@example.com")
.bind("local")
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("user should seed");
let _wallet_order = match repository
.create_wallet_recharge_order(CreateWalletRechargeOrderInput {
preferred_wallet_id: Some("wallet-lifetime-1".to_string()),
user_id: "user-lifetime-1".to_string(),
amount_usd: 1.0,
pay_amount: Some(1.0),
pay_currency: Some("USD".to_string()),
exchange_rate: Some(1.0),
payment_method: "bootstrap".to_string(),
payment_provider: None,
payment_channel: None,
gateway_order_id: "gateway-bootstrap-lifetime-1".to_string(),
gateway_response: json!({ "bootstrap": true }),
order_no: "order-bootstrap-lifetime-1".to_string(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("wallet should be created")
{
CreateWalletRechargeOrderOutcome::Created(order) => order,
CreateWalletRechargeOrderOutcome::WalletInactive => {
panic!("new wallet should be active")
}
};
let plan_snapshot = json!({
"id": "first-plan",
"title": "首购特惠包",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "lifetime",
"entitlements": [
{
"type": "wallet_credit",
"amount_usd": 1.0,
"balance_bucket": "gift"
}
]
});
sqlx::query(
r#"
INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit, duration_value,
max_active_per_user, purchase_limit_scope, entitlements_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind("first-plan")
.bind("首购特惠包")
.bind(7.2_f64)
.bind("CNY")
.bind("month")
.bind(1_i64)
.bind(1_i64)
.bind("lifetime")
.bind(plan_snapshot["entitlements"].to_string())
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("billing plan should seed");
let first_order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-lifetime-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-plan-lifetime-1".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-plan-lifetime-1".to_string(),
product_id: "first-plan".to_string(),
product_snapshot: plan_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("first plan order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("first plan order should be created, got {other:?}"),
};
assert_eq!(first_order.status, "pending");
let callback = repository
.process_payment_callback(ProcessPaymentCallbackInput {
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
callback_key: "callback-plan-lifetime-1".to_string(),
order_no: Some("order-plan-lifetime-1".to_string()),
gateway_order_id: Some("gateway-plan-lifetime-1".to_string()),
amount_usd: 1.0,
pay_amount: Some(7.2),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.2),
payload_hash: "payload-plan-lifetime-1".to_string(),
payload: json!({ "trade_status": "TRADE_SUCCESS" }),
signature_valid: true,
})
.await
.expect("plan payment callback should process");
assert!(matches!(
callback,
ProcessPaymentCallbackOutcome::Applied { .. }
));
let entitlement_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE user_id = ? AND plan_id = ?",
)
.bind("user-lifetime-1")
.bind("first-plan")
.fetch_one(repository.pool())
.await
.expect("entitlement count should query");
assert_eq!(entitlement_count, 1);
let second_order = repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-lifetime-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: "gateway-plan-lifetime-2".to_string(),
gateway_response: json!({ "checkout": true }),
order_no: "order-plan-lifetime-2".to_string(),
product_id: "first-plan".to_string(),
product_snapshot: plan_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("second plan order should resolve");
assert!(matches!(
second_order,
CreatePlanPurchaseOrderOutcome::ActivePlanLimitReached
));
let unlimited_snapshot = json!({
"id": "unlimited-plan",
"title": "不限购余额包",
"duration_unit": "month",
"duration_value": 1,
"max_active_per_user": 1,
"purchase_limit_scope": "unlimited",
"entitlements": [
{
"type": "wallet_credit",
"amount_usd": 1.0,
"balance_bucket": "gift"
}
]
});
sqlx::query(
r#"
INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit, duration_value,
max_active_per_user, purchase_limit_scope, entitlements_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind("unlimited-plan")
.bind("不限购余额包")
.bind(7.2_f64)
.bind("CNY")
.bind("month")
.bind(1_i64)
.bind(1_i64)
.bind("unlimited")
.bind(unlimited_snapshot["entitlements"].to_string())
.bind(1_i64)
.bind(1_i64)
.execute(repository.pool())
.await
.expect("unlimited billing plan should seed");
for index in 1..=2 {
let order_no = format!("order-plan-unlimited-{index}");
let gateway_order_id = format!("gateway-plan-unlimited-{index}");
let order = match repository
.create_plan_purchase_order(CreatePlanPurchaseOrderInput {
preferred_wallet_id: None,
user_id: "user-lifetime-1".to_string(),
amount_usd: 1.0,
pay_amount: 7.2,
pay_currency: "CNY".to_string(),
exchange_rate: 7.2,
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
gateway_order_id: gateway_order_id.clone(),
gateway_response: json!({ "checkout": true }),
order_no: order_no.clone(),
product_id: "unlimited-plan".to_string(),
product_snapshot: unlimited_snapshot.clone(),
expires_at_unix_secs: 4_102_444_800,
})
.await
.expect("unlimited plan order should create")
{
CreatePlanPurchaseOrderOutcome::Created(order) => order,
other => panic!("unlimited plan order should be created, got {other:?}"),
};
assert_eq!(order.status, "pending");
let callback = repository
.process_payment_callback(ProcessPaymentCallbackInput {
payment_method: "alipay".to_string(),
payment_provider: Some("epay".to_string()),
payment_channel: Some("alipay".to_string()),
callback_key: format!("callback-plan-unlimited-{index}"),
order_no: Some(order_no),
gateway_order_id: Some(gateway_order_id),
amount_usd: 1.0,
pay_amount: Some(7.2),
pay_currency: Some("CNY".to_string()),
exchange_rate: Some(7.2),
payload_hash: format!("payload-plan-unlimited-{index}"),
payload: json!({ "trade_status": "TRADE_SUCCESS" }),
signature_valid: true,
})
.await
.expect("unlimited plan payment callback should process");
assert!(matches!(
callback,
ProcessPaymentCallbackOutcome::Applied { .. }
));
}
let unlimited_entitlement_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM user_plan_entitlements WHERE user_id = ? AND plan_id = ?",
)
.bind("user-lifetime-1")
.bind("unlimited-plan")
.fetch_one(repository.pool())
.await
.expect("unlimited entitlement count should query");
assert_eq!(unlimited_entitlement_count, 2);
}
impl SqliteWalletReadRepository {
fn pool(&self) -> &sqlx::SqlitePool {
&self.pool
}
}
async fn seed_rows(pool: &sqlx::SqlitePool) {
sqlx::query(
r#"
INSERT INTO users (id, username, email, auth_source, created_at, updated_at)
VALUES ('user-1', 'Alice', 'alice@example.com', 'local', 1, 1)
"#,
)
.execute(pool)
.await
.expect("user should seed");
sqlx::query(
r#"
INSERT INTO wallets (
id, user_id, balance, gift_balance, total_recharged, total_consumed,
total_refunded, total_adjusted, created_at, updated_at
) VALUES (
'wallet-1', 'user-1', 10.0, 2.0, 20.0, 4.0, 1.0, 3.0, 1, 2
)
"#,
)
.execute(pool)
.await
.expect("wallet should seed");
sqlx::query(
r#"
INSERT INTO payment_orders (
id, order_no, wallet_id, user_id, amount_usd, refunded_amount_usd,
refundable_amount_usd, payment_method, gateway_response, status, created_at
) VALUES (
'order-1', 'order-no-1', 'wallet-1', 'user-1', 5.0, 1.0, 4.0,
'redeem_code', '{"ok":true}', 'credited', 3
)
"#,
)
.execute(pool)
.await
.expect("payment order should seed");
sqlx::query(
r#"
INSERT INTO payment_callbacks (
id, payment_order_id, payment_method, callback_key, order_no,
signature_valid, payload, created_at
) VALUES (
'callback-1', 'order-1', 'redeem_code', 'callback-key-1',
'order-no-1', 1, '{"event":"paid"}', 4
)
"#,
)
.execute(pool)
.await
.expect("callback should seed");
sqlx::query(
r#"
INSERT INTO refund_requests (
id, refund_no, wallet_id, user_id, payment_order_id, source_type,
refund_mode, amount_usd, status, payout_proof, created_at, updated_at
) VALUES (
'refund-1', 'refund-no-1', 'wallet-1', 'user-1', 'order-1',
'payment_order', 'offline_payout', 1.0, 'completed',
'{"proof":"ok"}', 5, 6
)
"#,
)
.execute(pool)
.await
.expect("refund should seed");
sqlx::query(
r#"
INSERT INTO wallet_transactions (
id, wallet_id, category, reason_code, amount, balance_before,
balance_after, recharge_balance_before, recharge_balance_after,
gift_balance_before, gift_balance_after, created_at
) VALUES (
'tx-1', 'wallet-1', 'credit', 'manual_adjustment', 3.0, 7.0, 10.0,
5.0, 8.0, 2.0, 2.0, 7
)
"#,
)
.execute(pool)
.await
.expect("transaction should seed");
sqlx::query(
r#"
INSERT INTO redeem_code_batches (
id, name, amount_usd, total_count, created_at, updated_at
) VALUES (
'batch-1', 'Batch One', 5.0, 1, 8, 9
)
"#,
)
.execute(pool)
.await
.expect("redeem batch should seed");
sqlx::query(
r#"
INSERT INTO redeem_codes (
id, batch_id, code_hash, code_prefix, code_suffix, status,
redeemed_by_user_id, redeemed_wallet_id, redeemed_payment_order_id,
redeemed_at, created_at, updated_at
) VALUES (
'code-1', 'batch-1', 'hash-1', 'ABCD', 'WXYZ', 'redeemed',
'user-1', 'wallet-1', 'order-1', 10, 8, 10
)
"#,
)
.execute(pool)
.await
.expect("redeem code should seed");
sqlx::query(
r#"
INSERT INTO wallet_daily_usage_ledgers (
id, wallet_id, billing_date, billing_timezone, total_cost_usd,
total_requests, input_tokens, output_tokens, cache_creation_tokens,
cache_read_tokens, aggregated_at, created_at, updated_at
) VALUES (
'daily-1', 'wallet-1', '2000-01-01', 'UTC', 1.25, 2, 10, 20, 3, 4, 11, 11, 11
)
"#,
)
.execute(pool)
.await
.expect("daily usage should seed");
}