tia/src/app/api/subscriptions/create/route.ts
Mannu 082956adea 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>
2026-06-06 13:24:09 +05:30

135 lines
4.7 KiB
TypeScript

import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* POST /api/subscriptions/create
*
* Creates a Razorpay subscription for the caller's family and returns the
* subscription_id + key_id for Razorpay Checkout. Grants NOTHING — entitlement
* is applied only by the webhook (subscription.charged/activated).
*
* Security:
* - family_id comes from the session (requireFamily), never from the request
* body — so a user can only create a sub for their own family (IDOR-safe).
* - key_secret never leaves the server; only key_id is returned.
*/
export async function POST() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const userId = auth.session!.userId;
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Resolve the plan row (must be seeded — see /api/admin/seed-plan).
const planRows = await sql`
SELECT id, razorpay_plan_id FROM subscription_plans
WHERE razorpay_plan_id = ${cfg.planId} AND is_active = true
LIMIT 1
`;
if (!planRows[0]) {
return NextResponse.json(
{ error: "Plan not seeded. Run POST /api/admin/seed-plan first." },
{ status: 500 },
);
}
const planRowId = planRows[0].id as string;
// 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]) {
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.
let rzpSub: { id?: string; status?: string; error?: { description?: string } };
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions`, {
method: "POST",
headers: {
Authorization: razorpayAuthHeader(cfg),
"Content-Type": "application/json",
},
body: JSON.stringify({
plan_id: cfg.planId,
total_count: 120, // ~10 yrs of monthly cycles; avoids auto-complete
customer_notify: 1,
quantity: 1,
notes: { family_id: familyId, user_id: userId }, // correlation backup for webhook
}),
});
rzpSub = await res.json();
if (!res.ok || !rzpSub.id) {
console.error("Razorpay create sub failed:", rzpSub);
return NextResponse.json(
{ error: rzpSub?.error?.description || "Failed to create subscription" },
{ status: 502 },
);
}
} catch (e) {
console.error("Razorpay create sub error:", e);
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
}
// Record our side as 'created'. The webhook drives all later state.
try {
await sql`
INSERT INTO family_subscriptions
(family_id, plan_id, razorpay_subscription_id, status)
VALUES (${familyId}, ${planRowId}, ${rzpSub.id}, 'created')
`;
} catch (e) {
// Unique index race (double-click) — treat as the existing-sub case.
console.error("Insert family_subscriptions failed:", e);
return NextResponse.json(
{ error: "Subscription already in progress for this family." },
{ status: 409 },
);
}
return NextResponse.json({ subscriptionId: rzpSub.id, keyId: cfg.keyId });
}