From 60b89cc840d6d99972c15423c7655335c011c7ae Mon Sep 17 00:00:00 2001 From: elky Date: Fri, 11 Sep 2026 13:04:01 +0800 Subject: [PATCH] fix(payment): restore recharge crediting and balance refresh --- .github/workflows/rust-ci.yml | 7 + .../adapters/postgres/src/wallet.rs | 231 +++++++++++++++++- frontend/src/api/wallet.ts | 21 +- frontend/src/views/user/WalletCenter.vue | 107 +++++++- .../__tests__/WalletCenter.recharge.spec.ts | 225 +++++++++++++++++ 5 files changed, 557 insertions(+), 34 deletions(-) create mode 100644 frontend/src/views/user/__tests__/WalletCenter.recharge.spec.ts diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index cdbd699a4..a01af779f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -549,6 +549,13 @@ jobs: AETHER_TEST_POSTGRES_URL: postgres://aether:aether@127.0.0.1:5432/aether_test run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture + - name: Run Postgres payment callback regression tests + env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + AETHER_TEST_DATABASE_URL: postgres://aether:aether@127.0.0.1:5432/aether_test + run: cargo test -p aether-data-postgres live_payment_callback --lib -- --ignored --nocapture + - name: Run Postgres API key lifecycle tests env: RUSTC_WRAPPER: sccache diff --git a/crates/aether-data/adapters/postgres/src/wallet.rs b/crates/aether-data/adapters/postgres/src/wallet.rs index 8e4cf3b4b..eb9311f8f 100644 --- a/crates/aether-data/adapters/postgres/src/wallet.rs +++ b/crates/aether-data/adapters/postgres/src/wallet.rs @@ -3460,7 +3460,7 @@ FOR UPDATE } // A payment order may only credit a wallet owned by the - // same live user. Lock the wallet and joined API-key row + // same live user. Lock the wallet and associated API-key row // before any gateway binding, entitlement, wallet, or // order mutation. Reject legacy rows with an ambiguous // owner shape instead of guessing an owner. @@ -3484,12 +3484,10 @@ FOR UPDATE let Some(wallet_owner_row) = sqlx::query( r#" SELECT - w.user_id AS wallet_user_id, - w.api_key_id AS wallet_api_key_id, - api_keys.user_id AS api_key_user_id -FROM wallets AS w -LEFT JOIN api_keys ON api_keys.id = w.api_key_id -WHERE w.id = $1 + user_id AS wallet_user_id, + api_key_id AS wallet_api_key_id +FROM wallets +WHERE id = $1 LIMIT 1 FOR UPDATE "#, @@ -3515,8 +3513,21 @@ FOR UPDATE row_get(&wallet_owner_row, "wallet_user_id")?; let wallet_api_key_id: Option = row_get(&wallet_owner_row, "wallet_api_key_id")?; + // PostgreSQL cannot lock the nullable side of a LEFT JOIN. + // Read API-key ownership under its own lock while retaining + // the wallet lock so both identities remain stable. let api_key_user_id: Option = - row_get(&wallet_owner_row, "api_key_user_id")?; + if let Some(api_key_id) = wallet_api_key_id.as_deref() { + sqlx::query_scalar( + "SELECT user_id FROM api_keys WHERE id = $1 FOR UPDATE", + ) + .bind(api_key_id) + .fetch_optional(&mut **tx) + .await + .map_postgres_err()? + } else { + None + }; let wallet_owner_matches = match ( wallet_user_id.as_deref(), wallet_api_key_id.as_deref(), @@ -8478,9 +8489,9 @@ VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $ #[cfg(test)] mod tests { use aether_data_contracts::repository::wallet::{ - CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput, RedeemWalletCodeInput, - RedeemWalletCodeOutcome, WalletLookupKey, WalletMutationOutcome, WalletReadRepository, - WalletWriteRepository, + CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput, ProcessPaymentCallbackInput, + ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome, + WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository, }; use sqlx::Row; @@ -8559,8 +8570,10 @@ mod tests { .await .expect("test database should connect"); for table in [ + "api_keys", "wallets", "payment_orders", + "payment_callbacks", "wallet_transactions", "user_plan_entitlements", "redeem_code_batches", @@ -8610,7 +8623,7 @@ mod tests { }) }); sqlx::query( - "INSERT INTO payment_orders (id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency, payment_method, payment_provider, order_kind, product_id, product_snapshot, status, created_at, expires_at) VALUES ($1, $2, $3, $4, 5, 5, 'USD', 'stripe', 'stripe', $5, $6, $7, 'pending', NOW(), NOW() + INTERVAL '1 hour')", + "INSERT INTO payment_orders (id, order_no, wallet_id, user_id, amount_usd, pay_amount, pay_currency, payment_method, payment_provider, payment_channel, order_kind, product_id, product_snapshot, status, created_at, expires_at) VALUES ($1, $2, $3, $4, 5, 5, 'USD', 'stripe', 'stripe', 'card', $5, $6, $7, 'pending', NOW(), NOW() + INTERVAL '1 hour')", ) .bind(&order_id) .bind(format!("order-{order_id}")) @@ -8625,6 +8638,200 @@ mod tests { order_id } + fn payment_callback_input(order_id: &str) -> ProcessPaymentCallbackInput { + ProcessPaymentCallbackInput { + payment_method: "stripe".to_string(), + payment_provider: Some("stripe".to_string()), + payment_channel: Some("card".to_string()), + callback_key: format!("stripe:event-{order_id}"), + order_no: Some(format!("order-{order_id}")), + gateway_order_id: Some(format!("gateway-{order_id}")), + amount_usd: 5.0, + pay_amount: Some(5.0), + pay_currency: Some("USD".to_string()), + exchange_rate: Some(1.0), + payload_hash: "payment-callback-regression".to_string(), + payload: serde_json::json!({"status": "success"}), + signature_valid: true, + } + } + + async fn make_api_key_wallet(pool: &sqlx::PgPool, wallet_id: &str, user_id: &str) -> String { + let api_key_id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO api_keys (id, user_id, key_hash, created_at, updated_at) VALUES ($1, $2, $1, NOW(), NOW())", + ) + .bind(&api_key_id) + .bind(user_id) + .execute(pool) + .await + .expect("test API key should be created"); + sqlx::query("UPDATE wallets SET user_id = NULL, api_key_id = $2 WHERE id = $1") + .bind(wallet_id) + .bind(&api_key_id) + .execute(pool) + .await + .expect("test wallet should belong to the API key"); + api_key_id + } + + async fn assert_payment_callback_credits_once(api_key_wallet: bool) { + let pool = isolated_wallet_test_pool().await; + let (wallet_id, user_id) = seed_wallet(&pool).await; + if api_key_wallet { + make_api_key_wallet(&pool, &wallet_id, &user_id).await; + } + let order_id = seed_pending_order(&pool, &wallet_id, &user_id, "wallet_recharge").await; + let repository = SqlxWalletRepository::new(pool.clone()); + let input = payment_callback_input(&order_id); + let outcome = repository + .process_payment_callback(input.clone()) + .await + .expect("verified payment callback should commit"); + let ProcessPaymentCallbackOutcome::Applied { + order, duplicate, .. + } = outcome + else { + panic!("pending payment order should be credited: {outcome:?}"); + }; + assert!(!duplicate); + assert_eq!(order.status, "credited"); + assert_eq!(order.wallet_id, wallet_id); + assert_eq!(order.amount_usd, 5.0); + assert!(order.credited_at_unix_secs.is_some()); + assert_eq!( + repository + .process_payment_callback(input.clone()) + .await + .expect("duplicate callback should succeed"), + ProcessPaymentCallbackOutcome::DuplicateProcessed { + order_id: Some(order_id.clone()), + } + ); + let another_event = ProcessPaymentCallbackInput { + callback_key: format!("stripe:another-event-{order_id}"), + ..input + }; + assert!(matches!( + repository + .process_payment_callback(another_event) + .await + .expect("another event for a credited order should succeed"), + ProcessPaymentCallbackOutcome::AlreadyCredited { .. } + )); + let wallet = repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .expect("wallet should be readable") + .expect("wallet should persist"); + assert_eq!(wallet.balance, 15.0); + assert_eq!(wallet.gift_balance, 3.0); + assert_eq!(wallet.total_recharged, 25.0); + let transactions = sqlx::query( + "SELECT reason_code, CAST(amount AS DOUBLE PRECISION) AS amount, CAST(balance_before AS DOUBLE PRECISION) AS balance_before, CAST(balance_after AS DOUBLE PRECISION) AS balance_after, link_id FROM wallet_transactions WHERE wallet_id = $1", + ) + .bind(&wallet_id) + .fetch_all(&pool) + .await + .expect("payment transactions should be readable"); + assert_eq!(transactions.len(), 1); + assert_eq!( + transactions[0].get::("reason_code"), + "topup_gateway" + ); + assert_eq!(transactions[0].get::("amount"), 5.0); + assert_eq!(transactions[0].get::("balance_before"), 13.0); + assert_eq!(transactions[0].get::("balance_after"), 18.0); + assert_eq!(transactions[0].get::("link_id"), order_id); + let processed_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM payment_callbacks WHERE payment_order_id = $1 AND status = 'processed'", + ) + .bind(&order_id) + .fetch_one(&pool) + .await + .expect("processed callbacks should persist"); + assert_eq!(processed_count, 2); + pool.close().await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_payment_callback_user_wallet_credits_once() { + assert_payment_callback_credits_once(false).await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_payment_callback_api_key_wallet_credits_once() { + assert_payment_callback_credits_once(true).await; + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] + async fn live_payment_callback_rejects_wrong_or_missing_wallet_owner() { + for owner_case in ["wrong_user", "wrong_api_key_user", "missing_api_key"] { + let pool = isolated_wallet_test_pool().await; + let (wallet_id, user_id) = seed_wallet(&pool).await; + let order_id = seed_pending_order(&pool, &wallet_id, &user_id, "wallet_recharge").await; + let other_user_id = uuid::Uuid::new_v4().to_string(); + if owner_case == "wrong_user" { + sqlx::query("UPDATE wallets SET user_id = $2 WHERE id = $1") + .bind(&wallet_id) + .bind(&other_user_id) + .execute(&pool) + .await + .expect("wallet owner should change for the rejection test"); + } else { + let api_key_id = make_api_key_wallet(&pool, &wallet_id, &other_user_id).await; + if owner_case == "missing_api_key" { + sqlx::query("DELETE FROM api_keys WHERE id = $1") + .bind(api_key_id) + .execute(&pool) + .await + .expect("test API key should be removed"); + } + } + let repository = SqlxWalletRepository::new(pool.clone()); + assert_eq!( + repository + .process_payment_callback(payment_callback_input(&order_id)) + .await + .expect("owner mismatch should return a durable rejection"), + ProcessPaymentCallbackOutcome::Failed { + duplicate: false, + error: "payment order wallet owner mismatch".to_string(), + }, + "owner case: {owner_case}" + ); + let wallet = repository + .find(WalletLookupKey::WalletId(&wallet_id)) + .await + .expect("wallet should be readable") + .expect("wallet should persist"); + assert_eq!(wallet.balance, 10.0); + assert_eq!(wallet.total_recharged, 20.0); + let order = repository + .find_admin_payment_order(&order_id) + .await + .expect("order should be readable") + .expect("order should persist"); + assert_eq!(order.status, "pending"); + assert!(order.gateway_order_id.is_none()); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions") + .fetch_one(&pool) + .await + .expect("transactions should be readable"); + assert_eq!(count, 0); + let callback_status: String = + sqlx::query_scalar("SELECT status FROM payment_callbacks") + .fetch_one(&pool) + .await + .expect("rejected callback should persist"); + assert_eq!(callback_status, "failed"); + pool.close().await; + } + } + #[tokio::test] #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"] async fn live_manual_recharge_commits_wallet_order_and_transaction() { diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts index 2f460513b..2fd55595f 100644 --- a/frontend/src/api/wallet.ts +++ b/frontend/src/api/wallet.ts @@ -132,6 +132,13 @@ export interface PaymentOrder { expires_at: string | null } +export interface WalletRechargeOrdersResponse extends WalletBalanceResponse { + items: PaymentOrder[] + total: number + limit: number + offset: number +} + export interface RefundRequest { id: string refund_no: string @@ -235,18 +242,8 @@ export const walletApi = { return response.data }, - async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{ - items: PaymentOrder[] - total: number - limit: number - offset: number - }> { - const response = await apiClient.get<{ - items: PaymentOrder[] - total: number - limit: number - offset: number - }>('/api/wallet/recharge', { params }) + async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise { + const response = await apiClient.get('/api/wallet/recharge', { params }) return response.data }, diff --git a/frontend/src/views/user/WalletCenter.vue b/frontend/src/views/user/WalletCenter.vue index 8c986c40c..4014b6e05 100644 --- a/frontend/src/views/user/WalletCenter.vue +++ b/frontend/src/views/user/WalletCenter.vue @@ -745,7 +745,7 @@ import { walletTransactionReasonLabel, } from '@/utils/walletDisplay' -const { success, error: showError } = useToast() +const { success, info, error: showError } = useToast() const ENABLE_WALLET_ACTION_FORMS = true @@ -787,6 +787,10 @@ const loadedTabs = new Set() const tabLoadPromises = new Map>() let refundEligibilityLoaded = false let todayCostPollTimer: ReturnType | null = null +let orderPollTimer: ReturnType | null = null +let orderLoadVersion = 0 +let unmounted = false +const pendingOrders = new Map() const rechargeForm = reactive({ amount_usd: 10, @@ -922,6 +926,8 @@ onMounted(async () => { loadTransactions(), loadRechargeOptions(), ]) + if (unmounted) return + await loadOrders() syncTodayCostPolling() } finally { loadingInitial.value = false @@ -929,7 +935,10 @@ onMounted(async () => { }) onBeforeUnmount(() => { + unmounted = true + orderLoadVersion += 1 stopTodayCostPolling() + stopOrderPolling() document.removeEventListener('visibilitychange', handleVisibilityChange) }) @@ -943,7 +952,8 @@ watch(refundableOrders, () => { }) async function refreshWallet() { - await Promise.all([loadBalance(), loadOrders(), loadTransactions()]) + await loadOrders() + await Promise.all([loadBalance(), loadTransactions()]) } async function loadBalance() { @@ -991,7 +1001,7 @@ async function loadTodayCost() { } function syncTodayCostPolling() { - if (activeTab.value === 'transactions' && !document.hidden) { + if (!unmounted && activeTab.value === 'transactions' && !document.hidden) { startTodayCostPolling() } else { stopTodayCostPolling() @@ -1013,22 +1023,96 @@ function stopTodayCostPolling() { function handleVisibilityChange() { syncTodayCostPolling() + if (document.hidden) { + stopOrderPolling() + } else if (!loadingInitial.value) { + void reloadOrders(true) + } } -async function loadOrders() { - loadingOrders.value = true +function isAwaitingCredit(order: PaymentOrder): boolean { + return order.status === 'paid' + || (order.status === 'pending' + && (!order.expires_at || new Date(order.expires_at).getTime() > Date.now())) +} + +function stopOrderPolling() { + if (orderPollTimer === null) return + clearTimeout(orderPollTimer) + orderPollTimer = null +} + +function scheduleOrderPolling() { + stopOrderPolling() + if (unmounted || document.hidden || ![...pendingOrders.values()].some(isAwaitingCredit)) return + orderPollTimer = setTimeout(() => { + orderPollTimer = null + void reloadOrders(true) + }, 5_000) +} + +function loadOrders() { + return reloadOrders(false) +} + +async function reloadOrders(silent: boolean) { + if (unmounted) return + stopOrderPolling() + const version = ++orderLoadVersion + loadingOrders.value = !silent try { const offset = (orderPage.value - 1) * orderPageSize.value const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset }) + if (unmounted || version !== orderLoadVersion) return + const orders = [...resp.items] + const visibleIds = new Set(orders.map(order => order.id)) + const missingOrders = [...pendingOrders.values()].filter(order => + isAwaitingCredit(order) && !visibleIds.has(order.id) + ) + const details = await Promise.allSettled(missingOrders.map(order => walletApi.getRechargeOrder(order.id))) + if (unmounted || version !== orderLoadVersion) return + for (const detail of details) { + if (detail.status === 'fulfilled') orders.push(detail.value.order) + else log.error('加载待到账订单失败:', detail.reason) + } + const newlyCredited = orders.filter(order => + order.status === 'credited' && pendingOrders.has(order.id) + ) + for (const order of orders) { + if (isAwaitingCredit(order)) pendingOrders.set(order.id, order) + else if (order.status !== 'credited') pendingOrders.delete(order.id) + if (latestRecharge.value?.order.id === order.id) latestRecharge.value.order = order + } + + // Order lists include the wallet snapshot, but omit the daily package quota. + const { items: _items, total: _total, limit: _limit, offset: _offset, ...balance } = resp + const currentPackageBalance = packageBalance.value + const currentWalletBalance = Number(balance.wallet?.balance ?? balance.balance ?? 0) + walletBalance.value = { + ...walletBalance.value, + ...balance, + wallet_balance: Math.max(0, currentWalletBalance), + total_available_balance: balance.unlimited ? null : Math.max(0, currentWalletBalance + currentPackageBalance), + } rechargeOrders.value = resp.items orderTotal.value = resp.total loadedTabs.add('orders') syncRefundOrderSelection() + if (newlyCredited.length > 0) { + // Read after the credited status so a pre-credit list snapshot cannot win. + await Promise.all([loadBalance(), loadTransactions()]) + for (const order of newlyCredited) pendingOrders.delete(order.id) + if (!unmounted && version === orderLoadVersion) success('充值已到账,余额已更新') + } } catch (error) { + if (unmounted || version !== orderLoadVersion) return log.error('加载充值订单失败:', error) - showError(parseApiError(error, '加载充值订单失败')) + if (!silent) showError(parseApiError(error, '加载充值订单失败')) } finally { - loadingOrders.value = false + if (!unmounted && version === orderLoadVersion) { + loadingOrders.value = false + scheduleOrderPolling() + } } } @@ -1164,8 +1248,11 @@ async function submitRecharge() { payment_channel: option.payment_channel, idempotency_key: rechargeForm.idempotency_key, }) + if (isAwaitingCredit(latestRecharge.value.order)) { + pendingOrders.set(latestRecharge.value.order.id, latestRecharge.value.order) + } success('充值订单创建成功') - await Promise.all([loadOrders(), loadBalance()]) + await loadOrders() activeTab.value = 'orders' submitPaymentInstructions(latestRecharge.value.payment_instructions) rechargeForm.idempotency_key = '' @@ -1234,8 +1321,8 @@ function isSafariBrowser(): boolean { } async function handleStripePaymentSuccess() { - success('支付已完成,正在刷新钱包余额') - await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()]) + info('支付已提交,正在等待充值到账') + await loadOrders() activeTab.value = 'orders' } diff --git a/frontend/src/views/user/__tests__/WalletCenter.recharge.spec.ts b/frontend/src/views/user/__tests__/WalletCenter.recharge.spec.ts new file mode 100644 index 000000000..380483b88 --- /dev/null +++ b/frontend/src/views/user/__tests__/WalletCenter.recharge.spec.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, nextTick, type App } from 'vue' +import type { PaymentOrder, WalletBalanceResponse } from '@/api/wallet' +import WalletCenter from '../WalletCenter.vue' + +const walletApiMock = vi.hoisted(() => ({ + getBalance: vi.fn(), + getFlow: vi.fn(), + getTodayCost: vi.fn(), + listRechargeOptions: vi.fn(), + listRechargeOrders: vi.fn(), + getRechargeOrder: vi.fn(), + createRechargeOrder: vi.fn(), +})) +const toastMock = vi.hoisted(() => ({ success: vi.fn(), info: vi.fn(), error: vi.fn() })) + +vi.mock('@/api/wallet', () => ({ walletApi: walletApiMock })) +vi.mock('@/composables/useToast', () => ({ useToast: () => toastMock })) +vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } })) +vi.mock('@/components/ui', async () => { + const { defineComponent, h } = await import('vue') + const passthrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }) + const button = defineComponent({ setup: (_, { slots }) => () => h('button', slots.default?.()) }) + return { + ...Object.fromEntries([ + 'Badge', 'Card', 'Input', 'Label', 'Select', 'SelectContent', 'SelectItem', + 'SelectTrigger', 'SelectValue', 'Table', 'TableBody', 'TableCell', 'TableHead', + 'TableHeader', 'TableRow', 'Tabs', 'TabsContent', 'TabsList', 'TabsTrigger', 'Textarea', + ].map(name => [name, passthrough])), + Button: button, + RefreshButton: defineComponent({ setup: () => () => h('button', { 'data-refresh': true }, '刷新') }), + Pagination: defineComponent({ + props: { current: Number }, + emits: ['update:current'], + setup: (_, { emit }) => () => h('button', { + 'data-next-page': true, + onClick: () => emit('update:current', 2), + }, '下一页'), + }), + } +}) +vi.mock('@/components/common', async () => { + const { defineComponent, h } = await import('vue') + const empty = defineComponent({ setup: () => () => h('div') }) + return { + EmptyState: empty, + LoadingState: empty, + StripePaymentDialog: defineComponent({ + emits: ['success'], + setup: (_, { emit }) => () => h('button', { + 'data-stripe-success': true, + onClick: () => emit('success', { intentId: 'pi-1', status: 'processing' }), + }, 'Stripe 提交'), + }), + } +}) + +const mountedApps: Array<{ app: App; root: HTMLElement }> = [] +let hidden = false + +function walletBalance(amount: number): WalletBalanceResponse { + return { + wallet: { + id: 'wallet-1', balance: amount, recharge_balance: amount, gift_balance: 0, + refundable_balance: amount, currency: 'USD', status: 'active', total_recharged: amount, + total_consumed: 0, total_refunded: 0, total_adjusted: 0, updated_at: '2026-09-11T00:00:00Z', + }, + balance: amount, unlimited: false, limit_mode: 'finite', currency: 'USD', + wallet_balance: amount, package_balance: 3, total_available_balance: amount + 3, + daily_quota: { has_active: true, total_usd: 5, used_usd: 2, remaining_usd: 3, allow_wallet_overage: true }, + } +} + +function paymentOrder(status = 'pending', overrides: Partial = {}): PaymentOrder { + return { + id: 'order-1', order_no: 'RECHARGE-1', wallet_id: 'wallet-1', user_id: 'user-1', + amount_usd: 10, pay_amount: 10, pay_currency: 'USD', exchange_rate: 1, + refunded_amount_usd: 0, refundable_amount_usd: status === 'credited' ? 10 : 0, + payment_method: 'epay', gateway_order_id: 'gateway-1', gateway_response: null, + status, created_at: '2026-09-11T00:00:00Z', paid_at: null, + credited_at: status === 'credited' ? '2026-09-11T00:01:00Z' : null, expires_at: null, + ...overrides, + } +} + +function orderResponse(items: PaymentOrder[], amount = 2, offset = 0) { + const { wallet_balance: _wallet, package_balance: _package, total_available_balance: _total, daily_quota: _quota, ...balance } = walletBalance(amount) + return { ...balance, items, total: items.length, limit: 20, offset } +} + +async function flushPromises() { + for (let i = 0; i < 10; i += 1) await Promise.resolve() + await nextTick() +} + +async function mountWallet() { + const root = document.createElement('div') + document.body.append(root) + const app = createApp(WalletCenter) + app.mount(root) + mountedApps.push({ app, root }) + await flushPromises() + return { app, root } +} + +function setHidden(value: boolean) { + hidden = value + document.dispatchEvent(new Event('visibilitychange')) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + hidden = false + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + walletApiMock.getBalance.mockResolvedValue(walletBalance(2)) + walletApiMock.getFlow.mockResolvedValue({ items: [], total: 0, today_entry: null }) + walletApiMock.getTodayCost.mockResolvedValue(null) + walletApiMock.listRechargeOptions.mockResolvedValue({ items: [] }) + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder()])) +}) + +afterEach(() => { + for (const { app, root } of mountedApps.splice(0)) { + app.unmount() + root.remove() + } + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('WalletCenter recharge synchronization', () => { + it('waits for server credit after Stripe submission and refreshes the credited balance and flow', async () => { + const { root } = await mountWallet() + root.querySelector('[data-stripe-success]')!.click() + await flushPromises() + expect(toastMock.info).toHaveBeenCalledWith('支付已提交,正在等待充值到账') + expect(toastMock.success).not.toHaveBeenCalled() + + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('paid')])) + await vi.advanceTimersByTimeAsync(5_000) + expect(toastMock.success).not.toHaveBeenCalled() + + // The order query can observe the credit after its wallet snapshot was read. + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2)) + walletApiMock.getBalance.mockResolvedValue(walletBalance(12)) + await vi.advanceTimersByTimeAsync(5_000) + expect(root.textContent).toContain('$12.00') + expect(root.textContent).toContain('$15.00') + expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2) + expect(toastMock.success).toHaveBeenCalledWith('充值已到账,余额已更新') + + const calls = walletApiMock.listRechargeOrders.mock.calls.length + await vi.advanceTimersByTimeAsync(15_000) + expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(calls) + }) + + it('updates the wallet and preserves package quota when the orders refresh button is clicked', async () => { + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2)) + const { root } = await mountWallet() + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12)) + + root.querySelectorAll('[data-refresh]')[4]!.click() + await flushPromises() + + expect(root.textContent).toContain('$12.00') + expect(root.textContent).toContain('$15.00') + expect(root.textContent).toContain('已用 $2.00 / 每日 $5.00') + expect(walletApiMock.getBalance).toHaveBeenCalledTimes(1) + }) + + it('pauses polling while hidden and checks credit as soon as the page becomes visible', async () => { + const { root } = await mountWallet() + setHidden(true) + await vi.advanceTimersByTimeAsync(20_000) + expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(1) + + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12)) + walletApiMock.getBalance.mockResolvedValue(walletBalance(12)) + setHidden(false) + await flushPromises() + + expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(2) + expect(root.textContent).toContain('$12.00') + expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2) + }) + + it('keeps tracking a pending recharge after the user changes the order page', async () => { + const { root } = await mountWallet() + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([], 2, 20)) + walletApiMock.getRechargeOrder.mockResolvedValue({ order: paymentOrder('credited') }) + walletApiMock.getBalance.mockResolvedValue(walletBalance(12)) + root.querySelectorAll('[data-next-page]')[1]!.click() + await flushPromises() + + expect(walletApiMock.getRechargeOrder).toHaveBeenCalledWith('order-1') + expect(root.textContent).toContain('$12.00') + expect(toastMock.success).toHaveBeenCalledTimes(1) + }) + + it('retries a temporary polling failure without showing an error toast', async () => { + const { root } = await mountWallet() + walletApiMock.listRechargeOrders.mockRejectedValueOnce(new Error('network unavailable')) + await vi.advanceTimersByTimeAsync(5_000) + expect(toastMock.error).not.toHaveBeenCalled() + + walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12)) + walletApiMock.getBalance.mockResolvedValue(walletBalance(12)) + await vi.advanceTimersByTimeAsync(5_000) + expect(root.textContent).toContain('$12.00') + expect(toastMock.success).toHaveBeenCalledTimes(1) + }) + + it('does not restart polling when an in-flight response completes after unmount', async () => { + const { app } = await mountWallet() + let resolve!: (value: ReturnType) => void + walletApiMock.listRechargeOrders.mockReturnValue(new Promise(complete => { resolve = complete })) + await vi.advanceTimersByTimeAsync(5_000) + app.unmount() + mountedApps.splice(0).forEach(({ root }) => root.remove()) + resolve(orderResponse([paymentOrder()])) + await flushPromises() + expect(vi.getTimerCount()).toBe(0) + }) +})