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:
parent
1577303582
commit
082956adea
2 changed files with 58 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue