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>
135 lines
4.7 KiB
TypeScript
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 });
|
|
}
|