Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/lib/billing/checkout-return.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 50 additions & 4 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<number | null>(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';

Expand Down Expand Up @@ -199,8 +243,10 @@ export default function SettingsPage() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{subscription.loading ? (
<p role="status" className="text-sm text-muted-foreground">{t('settings.loading')}</p>
{subscription.loading || confirmingCheckout ? (
<p role="status" className="text-sm text-muted-foreground">
{confirmingCheckout ? t('settings.confirmingSubscription') : t('settings.loading')}
</p>
) : (
<>
<div className="flex items-center justify-between">
Expand Down
73 changes: 73 additions & 0 deletions src/test/billing/checkout-return.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading