diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 9d013051..4f6701f6 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -217,6 +217,13 @@ export function runTests() { hookSource.includes("retriedBackendJson"), "account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated", ); + assert( + hookSource.includes("refreshEntitlementAfterPayment") && + paymentFlowSource.includes("refreshEntitlementAfterPayment") && + paymentFlowSource.includes("await refreshEntitlementAfterPayment();") && + hookSource.includes("subscription_active === true"), + "successful payment flows must automatically poll /api/auth/me until the paid subscription is visible instead of requiring logout or manual refresh", + ); assert( !hookSource.includes(".auth.getUser()") && hookSource.includes(".auth.getSession()"), diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index 2d14a512..c1d93f47 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -146,10 +146,9 @@ export function useAccountPayment(params: UseAccountPaymentParams) { [supabaseReady, getValidAccessToken], ); - // ── loadSnapshot ────────────────────────────────────────── - const loadSnapshot = useCallback(async () => { - setErrorText(""); - const attempt = async (retry: boolean): Promise => { + // ── Auth snapshot refresh ──────────────────────────────── + const fetchAuthSnapshot = useCallback( + async (retry: boolean): Promise => { const userPromise = supabaseReady ? getSupabaseBrowserClient() .auth.getSession() @@ -165,7 +164,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { if (!backendResult.ok) { if (retry && backendResult.status === 401) { await new Promise((r) => setTimeout(r, 1200)); - return attempt(false); + return fetchAuthSnapshot(false); } const raw = (await backendResult.text()).slice(0, 260); throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); @@ -202,10 +201,41 @@ export function useAccountPayment(params: UseAccountPaymentParams) { } setBackend(backendJson); setUpdatedAt(new Date().toISOString()); - }; - try { await attempt(true); } + return backendJson; + }, + [ + buildAuthedHeaders, + copy.httpError, + setBackend, + setUpdatedAt, + setUser, + supabaseReady, + ], + ); + + // ── loadSnapshot ────────────────────────────────────────── + const loadSnapshot = useCallback(async () => { + setErrorText(""); + try { await fetchAuthSnapshot(true); } catch (error) { setErrorText(String(error)); } - }, [buildAuthedHeaders, supabaseReady, copy.httpError]); + }, [fetchAuthSnapshot, setErrorText]); + + const refreshEntitlementAfterPayment = useCallback(async () => { + setErrorText(""); + const maxAttempts = 5; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const backendJson = await fetchAuthSnapshot(true); + if (backendJson.subscription_active === true) return; + } catch (error) { + if (attempt === maxAttempts - 1) { + setErrorText(String(error)); + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 1200)); + } + }, [fetchAuthSnapshot, setErrorText]); // ── Shared state for sub-hooks ─────────────────────────── // These state variables are managed in the master hook and passed @@ -383,6 +413,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { getValidAccessToken, buildAuthedHeaders, loadSnapshot, + refreshEntitlementAfterPayment, loadPaymentSnapshot: loadPaymentSnapshotImpl, user, }); @@ -438,6 +469,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { getValidAccessToken, buildAuthedHeaders, loadSnapshot, + refreshEntitlementAfterPayment, loadPaymentSnapshot: loadPaymentSnapshotImpl, waitForReceipt: walletBind.waitForReceipt, ensureTargetChain: walletBind.ensureTargetChain, diff --git a/frontend/components/account/useBilling.ts b/frontend/components/account/useBilling.ts index 9adfce89..0f94e92d 100644 --- a/frontend/components/account/useBilling.ts +++ b/frontend/components/account/useBilling.ts @@ -53,6 +53,7 @@ export interface UseBillingParams { getValidAccessToken: () => Promise; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; loadSnapshot: () => Promise; + refreshEntitlementAfterPayment: () => Promise; loadPaymentSnapshot: () => Promise; // User for metadata points @@ -86,6 +87,7 @@ export function useBilling(params: UseBillingParams) { getValidAccessToken, buildAuthedHeaders, loadSnapshot, + refreshEntitlementAfterPayment, loadPaymentSnapshot, user, } = params; @@ -203,7 +205,7 @@ export function useBilling(params: UseBillingParams) { if (json.ok) { setPaymentInfo(copy.walletRecoveryDone); setPaymentError(""); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); return true; } @@ -215,7 +217,7 @@ export function useBilling(params: UseBillingParams) { } }, [ authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone, - loadPaymentSnapshot, loadSnapshot, reconcileBusy, + loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileBusy, ]); // ── handleSubmit409 ──────────────────────────────────── @@ -226,7 +228,7 @@ export function useBilling(params: UseBillingParams) { const ok = await reconcileLatestPayment(); if (ok) return; setPaymentInfo(copy.orderAlreadyPaid); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); return; } @@ -246,7 +248,7 @@ export function useBilling(params: UseBillingParams) { const txHash = intentJson.intent?.tx_hash || txHashNorm; setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`); setPaymentError(""); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); return; } @@ -257,7 +259,7 @@ export function useBilling(params: UseBillingParams) { } throw new Error(copy.submitTxFailed.replace("{raw}", raw)); }, - [buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment], + [buildAuthedHeaders, copy, loadPaymentSnapshot, refreshEntitlementAfterPayment, reconcileLatestPayment], ); // ── openTelegramBotBindLink ────────────────────────────── diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 32ef21ce..5f1f6540 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -101,6 +101,7 @@ export interface UsePaymentFlowParams { getValidAccessToken: () => Promise; buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; loadSnapshot: () => Promise; + refreshEntitlementAfterPayment: () => Promise; loadPaymentSnapshot: () => Promise; waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise; ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise; @@ -160,6 +161,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { getValidAccessToken, buildAuthedHeaders, loadSnapshot, + refreshEntitlementAfterPayment, loadPaymentSnapshot, waitForReceipt, ensureTargetChain, @@ -349,7 +351,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { intent_id: intentId, tx_hash: txHash || null, }); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); return; } @@ -361,7 +363,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { } throw new Error(copy.paymentPendingTimeout); }, - [loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code], + [loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code], ); // ── createIntentAndPay ────────────────────────────────── @@ -586,7 +588,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { intent_id: intentId, tx_hash: txHashNorm, }); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); } catch (error) { const normalized = normalizePaymentError(error); @@ -743,7 +745,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { intent_id: intentIdVal, tx_hash: txHashNorm, }); - await loadSnapshot(); + await refreshEntitlementAfterPayment(); await loadPaymentSnapshot(); } catch (error) { setPaymentError(normalizePaymentError(error).message);