fix(billing): abandoned-checkout lockout + stuck "Opening checkout…"

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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 13:24:09 +05:30
parent 1577303582
commit 082956adea
2 changed files with 58 additions and 15 deletions

View file

@ -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.

View file

@ -32,18 +32,30 @@ const CHECKOUT_SRC = "https://checkout.razorpay.com/v1/checkout.js";
function loadCheckout(): Promise<boolean> {
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);