From 082956adead27cb1a5ea824656cad1c1c735db3d Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 6 Jun 2026 13:24:09 +0530 Subject: [PATCH] =?UTF-8?q?fix(billing):=20abandoned-checkout=20lockout=20?= =?UTF-8?q?+=20stuck=20"Opening=20checkout=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1 — "An active subscription already exists" lockout: Clicking Upgrade creates a 'created' row + Razorpay sub BEFORE payment. If the user closes checkout without paying, that row persisted forever and the partial unique index blocked all future upgrade attempts (Razorpay also refuses to cancel a 'created' sub — "no billing cycle"). Real users hit this on every abandoned checkout. Fix: create route now inspects existing non-terminal sub: - active/authenticated/pending -> block (genuinely subscribed) - created -> REUSE it (return same sub_id so checkout reopens) instead of lock - halted -> retire to 'expired' so a fresh sub can be created Issue 2 — iOS PWA stuck on "Opening checkout…": - loadCheckout() could hang forever if a script tag existed but its load event already fired (listeners never run). Rewrote with a polling fallback +10s timeout so it always resolves. - Button stayed loading until dismiss; now clears loading right after rzp.open() so it never sticks in an iOS standalone PWA. Co-Authored-By: Claude Opus 4.8 --- src/app/api/subscriptions/create/route.ts | 39 +++++++++++++++++++---- src/components/UpgradeButton.tsx | 34 ++++++++++++++------ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/app/api/subscriptions/create/route.ts b/src/app/api/subscriptions/create/route.ts index 35f4fe9..189614d 100644 --- a/src/app/api/subscriptions/create/route.ts +++ b/src/app/api/subscriptions/create/route.ts @@ -43,19 +43,46 @@ export async function POST() { } const planRowId = planRows[0].id as string; - // Reject if a live (non-terminal) subscription already exists for this family. - // The partial unique index also enforces this at the DB level. + // Inspect any existing non-terminal subscription for this family. const live = await sql` SELECT id, razorpay_subscription_id, status FROM family_subscriptions WHERE family_id = ${familyId} AND status IN ('created','authenticated','active','pending','halted') + ORDER BY created_at DESC LIMIT 1 `; if (live[0]) { - return NextResponse.json( - { error: "An active subscription already exists for this family.", status: live[0].status }, - { status: 409 }, - ); + const status = live[0].status as string; + + // Genuinely subscribed → block (they don't need a second subscription). + if (status === "authenticated" || status === "active" || status === "pending") { + return NextResponse.json( + { error: "You already have an active subscription.", status }, + { status: 409 }, + ); + } + + // Abandoned checkout: a 'created' row whose payment never completed. The + // Razorpay subscription is still payable, so REUSE it — reopen checkout for + // the same sub instead of locking the user out forever. + if (status === "created") { + return NextResponse.json({ + subscriptionId: live[0].razorpay_subscription_id, + keyId: cfg.keyId, + reused: true, + }); + } + + // 'halted' = retries exhausted, family already downgraded to free. Let them + // re-subscribe: retire the halted row (leaves the partial unique index) so + // a fresh subscription can be created below. + if (status === "halted") { + await sql` + UPDATE family_subscriptions + SET status = 'expired', ended_at = COALESCE(ended_at, NOW()), updated_at = NOW() + WHERE id = ${live[0].id} + `; + } } // Create the subscription at Razorpay. diff --git a/src/components/UpgradeButton.tsx b/src/components/UpgradeButton.tsx index 9eec903..9015b25 100644 --- a/src/components/UpgradeButton.tsx +++ b/src/components/UpgradeButton.tsx @@ -32,18 +32,30 @@ const CHECKOUT_SRC = "https://checkout.razorpay.com/v1/checkout.js"; function loadCheckout(): Promise { return new Promise((resolve) => { + if (typeof window === "undefined") return resolve(false); if (window.Razorpay) return resolve(true); + + // Poll for window.Razorpay as a fallback — covers the case where a script + // tag already exists but its load event already fired (listeners would + // never run), and slow iOS PWA loads. Resolves as soon as the global + // appears, or false after a timeout so the caller never hangs. + let settled = false; + const done = (ok: boolean) => { if (!settled) { settled = true; resolve(ok); } }; + const existing = document.querySelector(`script[src="${CHECKOUT_SRC}"]`); - if (existing) { - existing.addEventListener("load", () => resolve(true)); - existing.addEventListener("error", () => resolve(false)); - return; + if (!existing) { + const s = document.createElement("script"); + s.src = CHECKOUT_SRC; + s.onload = () => done(!!window.Razorpay); + s.onerror = () => done(false); + document.body.appendChild(s); } - const s = document.createElement("script"); - s.src = CHECKOUT_SRC; - s.onload = () => resolve(true); - s.onerror = () => resolve(false); - document.body.appendChild(s); + + const start = Date.now(); + const poll = setInterval(() => { + if (window.Razorpay) { clearInterval(poll); done(true); } + else if (Date.now() - start > 10000) { clearInterval(poll); done(false); } + }, 150); }); } @@ -102,6 +114,10 @@ export function UpgradeButton({ modal: { ondismiss: () => setLoading(false) }, }); rzp.open(); + // The modal is now open — stop showing "Opening checkout…" on the button. + // (ondismiss already covers cancel; this covers the open state itself so + // the button never gets stuck, e.g. in an iOS standalone PWA.) + setLoading(false); } catch (e) { setError(e instanceof Error ? e.message : "Something went wrong."); setLoading(false);