diff --git a/src/lib/billing/checkout-return.ts b/src/lib/billing/checkout-return.ts new file mode 100644 index 000000000..dd1552f3b --- /dev/null +++ b/src/lib/billing/checkout-return.ts @@ -0,0 +1,33 @@ +import type { SubscriptionTier } from '@/hooks/use-subscription'; + +/** How long Settings keeps asking after Stripe redirects back. */ +export const CHECKOUT_CONFIRM_WINDOW_MS = 20_000; +export const CHECKOUT_CONFIRM_INTERVAL_MS = 2_000; + +export type CheckoutReturnAction = 'ignore' | 'wait' | 'poll' | 'confirmed' | 'slow' | 'canceled'; + +/** + * What Settings should do with `?checkout=` after Stripe. + * The first subscription read often lands before the webhook, and that read + * stays fresh for a minute, so a successful payment can look expired. + */ +export function nextCheckoutConfirmation(input: { + checkout: string | null; + tier: SubscriptionTier; + elapsedMs: number; + loading: boolean; +}): CheckoutReturnAction { + if (input.checkout === 'canceled') return 'canceled'; + if (input.checkout !== 'success') return 'ignore'; + if (input.loading) return 'wait'; + if (input.tier === 'pro' || input.tier === 'legacy_free') return 'confirmed'; + if (input.elapsedMs >= CHECKOUT_CONFIRM_WINDOW_MS) return 'slow'; + return 'poll'; +} + +export function stripCheckoutParams(params: URLSearchParams): URLSearchParams { + const next = new URLSearchParams(params); + next.delete('checkout'); + next.delete('session_id'); + return next; +} diff --git a/src/locales/en.json b/src/locales/en.json index 7a2b4ac96..81111fc50 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -445,6 +445,13 @@ "manageSubscription": "Manage Subscription", "setUpBilling": "Set Up Billing", "upgradeToPro": "Upgrade to Pro", + "confirmingSubscription": "Confirming your Pro subscription…", + "checkoutCanceled": "Checkout canceled", + "checkoutCanceledDesc": "No charge was made.", + "checkoutConfirmed": "You're on Pro", + "checkoutConfirmedDesc": "Your subscription is active.", + "checkoutConfirmSlowTitle": "Still waiting on Stripe", + "checkoutConfirmSlow": "Stripe hasn't confirmed the payment yet. This page will catch up after a refresh.", "accessActive": "AI access is active. Usage limits may apply.", "accessExpired": "Your access has expired. Upgrade to continue using AI tools.", "globalSettings": "Global Settings", diff --git a/src/locales/es.json b/src/locales/es.json index e03254f8e..e4210e5a6 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -445,6 +445,13 @@ "manageSubscription": "Gestionar suscripción", "setUpBilling": "Configurar facturación", "upgradeToPro": "Mejorar a Pro", + "confirmingSubscription": "Confirmando tu suscripción Pro…", + "checkoutCanceled": "Pago cancelado", + "checkoutCanceledDesc": "No se hizo ningún cargo.", + "checkoutConfirmed": "Ya tienes Pro", + "checkoutConfirmedDesc": "Tu suscripción está activa.", + "checkoutConfirmSlowTitle": "Seguimos esperando a Stripe", + "checkoutConfirmSlow": "Stripe todavía no ha confirmado el pago. Esta página se actualizará al recargar.", "accessActive": "El acceso a la IA está activo. Pueden aplicarse límites de uso.", "accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.", "globalSettings": "Ajustes globales", diff --git a/src/locales/hu.json b/src/locales/hu.json index 46a0e4953..a3c8a46dc 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -445,6 +445,13 @@ "manageSubscription": "Előfizetés kezelése", "setUpBilling": "Számlázás beállítása", "upgradeToPro": "Váltás Pro-ra", + "confirmingSubscription": "A Pro előfizetés megerősítése…", + "checkoutCanceled": "Fizetés megszakítva", + "checkoutCanceledDesc": "Nem történt terhelés.", + "checkoutConfirmed": "Pro csomagod aktív", + "checkoutConfirmedDesc": "Az előfizetésed él.", + "checkoutConfirmSlowTitle": "Még várunk a Stripe-ra", + "checkoutConfirmSlow": "A Stripe még nem erősítette meg a fizetést. Frissítés után ez az oldal utoléri.", "accessActive": "Az MI-hozzáférés aktív. Használati korlátok érvényesek lehetnek.", "accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.", "globalSettings": "Globális beállítások", diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0cc2e101e..271dd7e23 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,7 +1,7 @@ // src/pages/SettingsPage.tsx -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -33,6 +33,11 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Input } from '@/components/ui/input'; import { PageHeader } from '@/components/layout/PageHeader'; import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages'; +import { + CHECKOUT_CONFIRM_INTERVAL_MS, + nextCheckoutConfirmation, + stripCheckoutParams, +} from '@/lib/billing/checkout-return'; // Tinted badges keep their label readable (white on amber or green was about 2:1). @@ -62,6 +67,45 @@ export default function SettingsPage() { const [deletingData, setDeletingData] = useState(false); const [signingOut, setSigningOut] = useState(false); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const checkout = searchParams.get('checkout'); + const refreshSubscription = useRef(subscription.refresh); + refreshSubscription.current = subscription.refresh; + const checkoutStarted = useRef(null); + const checkoutSettled = useRef(false); + const [checkoutPoll, setCheckoutPoll] = useState(0); + const confirmingCheckout = checkout === 'success' + && subscription.tier !== 'pro' + && subscription.tier !== 'legacy_free'; + + useEffect(() => { + const elapsed = checkoutStarted.current === null ? 0 : Date.now() - checkoutStarted.current; + const action = nextCheckoutConfirmation({ + checkout, + tier: subscription.tier, + elapsedMs: elapsed, + loading: subscription.loading, + }); + if (action === 'ignore' || action === 'wait' || checkoutSettled.current) return; + + if (action === 'canceled' || action === 'confirmed' || action === 'slow') { + checkoutSettled.current = true; + const notice = action === 'canceled' + ? { title: t('settings.checkoutCanceled'), description: t('settings.checkoutCanceledDesc') } + : action === 'confirmed' + ? { title: t('settings.checkoutConfirmed'), description: t('settings.checkoutConfirmedDesc') } + : { title: t('settings.checkoutConfirmSlowTitle'), description: t('settings.checkoutConfirmSlow') }; + toast(notice); + setSearchParams(stripCheckoutParams(searchParams), { replace: true }); + return; + } + + if (checkoutStarted.current === null) checkoutStarted.current = Date.now(); + const timer = window.setTimeout(() => { + void refreshSubscription.current().finally(() => setCheckoutPoll((count) => count + 1)); + }, CHECKOUT_CONFIRM_INTERVAL_MS); + return () => window.clearTimeout(timer); + }, [checkout, checkoutPoll, searchParams, setSearchParams, subscription.loading, subscription.tier, t, toast]); // Must match deletionConfirmation in supabase/functions/account/index.ts. const expectedAccountConfirmation = user?.email || 'DELETE'; @@ -199,8 +243,10 @@ export default function SettingsPage() { - {subscription.loading ? ( -

{t('settings.loading')}

+ {subscription.loading || confirmingCheckout ? ( +

+ {confirmingCheckout ? t('settings.confirmingSubscription') : t('settings.loading')} +

) : ( <>
diff --git a/src/test/billing/checkout-return.test.ts b/src/test/billing/checkout-return.test.ts new file mode 100644 index 000000000..7728b38d6 --- /dev/null +++ b/src/test/billing/checkout-return.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { + CHECKOUT_CONFIRM_WINDOW_MS, + nextCheckoutConfirmation, + stripCheckoutParams, +} from '@/lib/billing/checkout-return'; + +describe('checkout return', () => { + it('keeps polling while Stripe has not recorded Pro yet', () => { + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'expired', + elapsedMs: 0, + loading: false, + })).toBe('poll'); + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'trial', + elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS - 1, + loading: false, + })).toBe('poll'); + }); + + it('waits for the first read, then stops once Pro or a gifted plan is visible', () => { + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'expired', + elapsedMs: 0, + loading: true, + })).toBe('wait'); + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'pro', + elapsedMs: 4_000, + loading: false, + })).toBe('confirmed'); + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'legacy_free', + elapsedMs: 4_000, + loading: false, + })).toBe('confirmed'); + }); + + it('stops polling when the webhook is still quiet after the window', () => { + expect(nextCheckoutConfirmation({ + checkout: 'success', + tier: 'expired', + elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS, + loading: false, + })).toBe('slow'); + }); + + it('treats a canceled checkout as its own ending and ignores other pages', () => { + expect(nextCheckoutConfirmation({ + checkout: 'canceled', + tier: 'trial', + elapsedMs: 0, + loading: false, + })).toBe('canceled'); + expect(nextCheckoutConfirmation({ + checkout: null, + tier: 'expired', + elapsedMs: 0, + loading: false, + })).toBe('ignore'); + }); + + it('removes the Stripe return params and leaves the rest', () => { + const next = stripCheckoutParams(new URLSearchParams('checkout=success&session_id=cs_test&tab=account')); + expect(next.toString()).toBe('tab=account'); + }); +});