Skip to content

Commit a69e96f

Browse files
Keep checking the plan after Stripe sends the student back. (#169)
Settings trusted the first subscription read, which often arrives before the webhook and then stays stale, so a successful payment looked expired. A successful return now waits and refreshes until Pro shows up, and a canceled return says no charge was made. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Gáborjáni-Szabó Ádám <Programcode@users.noreply.github.com>
1 parent f08e1f0 commit a69e96f

6 files changed

Lines changed: 177 additions & 4 deletions

File tree

‎src/lib/billing/checkout-return.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { SubscriptionTier } from '@/hooks/use-subscription';
2+
3+
/** How long Settings keeps asking after Stripe redirects back. */
4+
export const CHECKOUT_CONFIRM_WINDOW_MS = 20_000;
5+
export const CHECKOUT_CONFIRM_INTERVAL_MS = 2_000;
6+
7+
export type CheckoutReturnAction = 'ignore' | 'wait' | 'poll' | 'confirmed' | 'slow' | 'canceled';
8+
9+
/**
10+
* What Settings should do with `?checkout=` after Stripe.
11+
* The first subscription read often lands before the webhook, and that read
12+
* stays fresh for a minute, so a successful payment can look expired.
13+
*/
14+
export function nextCheckoutConfirmation(input: {
15+
checkout: string | null;
16+
tier: SubscriptionTier;
17+
elapsedMs: number;
18+
loading: boolean;
19+
}): CheckoutReturnAction {
20+
if (input.checkout === 'canceled') return 'canceled';
21+
if (input.checkout !== 'success') return 'ignore';
22+
if (input.loading) return 'wait';
23+
if (input.tier === 'pro' || input.tier === 'legacy_free') return 'confirmed';
24+
if (input.elapsedMs >= CHECKOUT_CONFIRM_WINDOW_MS) return 'slow';
25+
return 'poll';
26+
}
27+
28+
export function stripCheckoutParams(params: URLSearchParams): URLSearchParams {
29+
const next = new URLSearchParams(params);
30+
next.delete('checkout');
31+
next.delete('session_id');
32+
return next;
33+
}

‎src/locales/en.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Manage Subscription",
446446
"setUpBilling": "Set Up Billing",
447447
"upgradeToPro": "Upgrade to Pro",
448+
"confirmingSubscription": "Confirming your Pro subscription…",
449+
"checkoutCanceled": "Checkout canceled",
450+
"checkoutCanceledDesc": "No charge was made.",
451+
"checkoutConfirmed": "You're on Pro",
452+
"checkoutConfirmedDesc": "Your subscription is active.",
453+
"checkoutConfirmSlowTitle": "Still waiting on Stripe",
454+
"checkoutConfirmSlow": "Stripe hasn't confirmed the payment yet. This page will catch up after a refresh.",
448455
"accessActive": "AI access is active. Usage limits may apply.",
449456
"accessExpired": "Your access has expired. Upgrade to continue using AI tools.",
450457
"globalSettings": "Global Settings",

‎src/locales/es.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Gestionar suscripción",
446446
"setUpBilling": "Configurar facturación",
447447
"upgradeToPro": "Mejorar a Pro",
448+
"confirmingSubscription": "Confirmando tu suscripción Pro…",
449+
"checkoutCanceled": "Pago cancelado",
450+
"checkoutCanceledDesc": "No se hizo ningún cargo.",
451+
"checkoutConfirmed": "Ya tienes Pro",
452+
"checkoutConfirmedDesc": "Tu suscripción está activa.",
453+
"checkoutConfirmSlowTitle": "Seguimos esperando a Stripe",
454+
"checkoutConfirmSlow": "Stripe todavía no ha confirmado el pago. Esta página se actualizará al recargar.",
448455
"accessActive": "El acceso a la IA está activo. Pueden aplicarse límites de uso.",
449456
"accessExpired": "Tu acceso ha caducado. Mejora el plan para seguir usando las herramientas de IA.",
450457
"globalSettings": "Ajustes globales",

‎src/locales/hu.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@
445445
"manageSubscription": "Előfizetés kezelése",
446446
"setUpBilling": "Számlázás beállítása",
447447
"upgradeToPro": "Váltás Pro-ra",
448+
"confirmingSubscription": "A Pro előfizetés megerősítése…",
449+
"checkoutCanceled": "Fizetés megszakítva",
450+
"checkoutCanceledDesc": "Nem történt terhelés.",
451+
"checkoutConfirmed": "Pro csomagod aktív",
452+
"checkoutConfirmedDesc": "Az előfizetésed él.",
453+
"checkoutConfirmSlowTitle": "Még várunk a Stripe-ra",
454+
"checkoutConfirmSlow": "A Stripe még nem erősítette meg a fizetést. Frissítés után ez az oldal utoléri.",
448455
"accessActive": "Az MI-hozzáférés aktív. Használati korlátok érvényesek lehetnek.",
449456
"accessExpired": "A hozzáférésed lejárt. Válts magasabb csomagra az MI-eszközök további használatához.",
450457
"globalSettings": "Globális beállítások",

‎src/pages/SettingsPage.tsx‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/pages/SettingsPage.tsx
2-
import { useState } from 'react';
2+
import { useEffect, useRef, useState } from 'react';
33
import { useQuery } from '@tanstack/react-query';
4-
import { Link, useNavigate } from 'react-router-dom';
4+
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
55
import { useTranslation } from 'react-i18next';
66
import { Settings, Lock, Trash2, Crown, CreditCard, Zap, Shield, Download, UserX } from 'lucide-react';
77
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,6 +33,11 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
3333
import { Input } from '@/components/ui/input';
3434
import { PageHeader } from '@/components/layout/PageHeader';
3535
import { APP_LANGUAGES, isAppLanguage } from '@/lib/languages';
36+
import {
37+
CHECKOUT_CONFIRM_INTERVAL_MS,
38+
nextCheckoutConfirmation,
39+
stripCheckoutParams,
40+
} from '@/lib/billing/checkout-return';
3641

3742

3843
// Tinted badges keep their label readable (white on amber or green was about 2:1).
@@ -62,6 +67,45 @@ export default function SettingsPage() {
6267
const [deletingData, setDeletingData] = useState(false);
6368
const [signingOut, setSigningOut] = useState(false);
6469
const navigate = useNavigate();
70+
const [searchParams, setSearchParams] = useSearchParams();
71+
const checkout = searchParams.get('checkout');
72+
const refreshSubscription = useRef(subscription.refresh);
73+
refreshSubscription.current = subscription.refresh;
74+
const checkoutStarted = useRef<number | null>(null);
75+
const checkoutSettled = useRef(false);
76+
const [checkoutPoll, setCheckoutPoll] = useState(0);
77+
const confirmingCheckout = checkout === 'success'
78+
&& subscription.tier !== 'pro'
79+
&& subscription.tier !== 'legacy_free';
80+
81+
useEffect(() => {
82+
const elapsed = checkoutStarted.current === null ? 0 : Date.now() - checkoutStarted.current;
83+
const action = nextCheckoutConfirmation({
84+
checkout,
85+
tier: subscription.tier,
86+
elapsedMs: elapsed,
87+
loading: subscription.loading,
88+
});
89+
if (action === 'ignore' || action === 'wait' || checkoutSettled.current) return;
90+
91+
if (action === 'canceled' || action === 'confirmed' || action === 'slow') {
92+
checkoutSettled.current = true;
93+
const notice = action === 'canceled'
94+
? { title: t('settings.checkoutCanceled'), description: t('settings.checkoutCanceledDesc') }
95+
: action === 'confirmed'
96+
? { title: t('settings.checkoutConfirmed'), description: t('settings.checkoutConfirmedDesc') }
97+
: { title: t('settings.checkoutConfirmSlowTitle'), description: t('settings.checkoutConfirmSlow') };
98+
toast(notice);
99+
setSearchParams(stripCheckoutParams(searchParams), { replace: true });
100+
return;
101+
}
102+
103+
if (checkoutStarted.current === null) checkoutStarted.current = Date.now();
104+
const timer = window.setTimeout(() => {
105+
void refreshSubscription.current().finally(() => setCheckoutPoll((count) => count + 1));
106+
}, CHECKOUT_CONFIRM_INTERVAL_MS);
107+
return () => window.clearTimeout(timer);
108+
}, [checkout, checkoutPoll, searchParams, setSearchParams, subscription.loading, subscription.tier, t, toast]);
65109
// Must match deletionConfirmation in supabase/functions/account/index.ts.
66110
const expectedAccountConfirmation = user?.email || 'DELETE';
67111

@@ -199,8 +243,10 @@ export default function SettingsPage() {
199243
</div>
200244
</CardHeader>
201245
<CardContent className="space-y-4">
202-
{subscription.loading ? (
203-
<p role="status" className="text-sm text-muted-foreground">{t('settings.loading')}</p>
246+
{subscription.loading || confirmingCheckout ? (
247+
<p role="status" className="text-sm text-muted-foreground">
248+
{confirmingCheckout ? t('settings.confirmingSubscription') : t('settings.loading')}
249+
</p>
204250
) : (
205251
<>
206252
<div className="flex items-center justify-between">
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
CHECKOUT_CONFIRM_WINDOW_MS,
4+
nextCheckoutConfirmation,
5+
stripCheckoutParams,
6+
} from '@/lib/billing/checkout-return';
7+
8+
describe('checkout return', () => {
9+
it('keeps polling while Stripe has not recorded Pro yet', () => {
10+
expect(nextCheckoutConfirmation({
11+
checkout: 'success',
12+
tier: 'expired',
13+
elapsedMs: 0,
14+
loading: false,
15+
})).toBe('poll');
16+
expect(nextCheckoutConfirmation({
17+
checkout: 'success',
18+
tier: 'trial',
19+
elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS - 1,
20+
loading: false,
21+
})).toBe('poll');
22+
});
23+
24+
it('waits for the first read, then stops once Pro or a gifted plan is visible', () => {
25+
expect(nextCheckoutConfirmation({
26+
checkout: 'success',
27+
tier: 'expired',
28+
elapsedMs: 0,
29+
loading: true,
30+
})).toBe('wait');
31+
expect(nextCheckoutConfirmation({
32+
checkout: 'success',
33+
tier: 'pro',
34+
elapsedMs: 4_000,
35+
loading: false,
36+
})).toBe('confirmed');
37+
expect(nextCheckoutConfirmation({
38+
checkout: 'success',
39+
tier: 'legacy_free',
40+
elapsedMs: 4_000,
41+
loading: false,
42+
})).toBe('confirmed');
43+
});
44+
45+
it('stops polling when the webhook is still quiet after the window', () => {
46+
expect(nextCheckoutConfirmation({
47+
checkout: 'success',
48+
tier: 'expired',
49+
elapsedMs: CHECKOUT_CONFIRM_WINDOW_MS,
50+
loading: false,
51+
})).toBe('slow');
52+
});
53+
54+
it('treats a canceled checkout as its own ending and ignores other pages', () => {
55+
expect(nextCheckoutConfirmation({
56+
checkout: 'canceled',
57+
tier: 'trial',
58+
elapsedMs: 0,
59+
loading: false,
60+
})).toBe('canceled');
61+
expect(nextCheckoutConfirmation({
62+
checkout: null,
63+
tier: 'expired',
64+
elapsedMs: 0,
65+
loading: false,
66+
})).toBe('ignore');
67+
});
68+
69+
it('removes the Stripe return params and leaves the rest', () => {
70+
const next = stripCheckoutParams(new URLSearchParams('checkout=success&session_id=cs_test&tab=account'));
71+
expect(next.toString()).toBe('tab=account');
72+
});
73+
});

0 commit comments

Comments
 (0)