feat(billing): Tasks 4-5 — create-subscription + webhook routes

Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client

Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
    authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
    halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)

middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).

Verified locally: HMAC valid/tampered, unix->date, event routing maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 12:18:00 +05:30
parent 6a1aaa38a2
commit 2bd45bd4fd
3 changed files with 276 additions and 0 deletions

View file

@ -0,0 +1,108 @@
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;
// Reject if a live (non-terminal) subscription already exists for this family.
// The partial unique index also enforces this at the DB level.
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')
LIMIT 1
`;
if (live[0]) {
return NextResponse.json(
{ error: "An active subscription already exists for this family.", status: live[0].status },
{ status: 409 },
);
}
// 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 });
}

View file

@ -0,0 +1,165 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { sql } from "@/db";
import { getRazorpayConfig } from "@/lib/billing/config";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
/**
* POST /api/webhooks/razorpay THE source of truth for entitlement.
*
* Razorpay calls this on every subscription lifecycle change. We:
* 1. Verify the HMAC signature (timing-safe) over the RAW body.
* 2. Idempotency: unique-insert on x-razorpay-event-id. Duplicate 200 & bail.
* 3. Update family_subscriptions by razorpay_subscription_id.
* 4. Sync entitlement onto families.tier (grant/revoke) so the existing
* quota.ts guards enforce the new limits.
*
* Status codes (Razorpay retries on non-2xx):
* 400 bad signature (do NOT retry)
* 200 processed OK, or duplicate (already processed)
* 500 processing error (Razorpay WILL retry that's what we want)
*/
// Razorpay event → how it affects entitlement.
const GRANT_EVENTS: Record<string, string> = {
"subscription.authenticated": "authenticated",
"subscription.activated": "active",
"subscription.charged": "active",
"subscription.resumed": "active",
"subscription.pending": "pending", // grace — keep entitled
};
const REVOKE_EVENTS: Record<string, string> = {
"subscription.halted": "halted",
"subscription.cancelled": "cancelled",
"subscription.completed": "completed",
"subscription.expired": "expired",
"subscription.paused": "paused",
};
function unixToDate(v: unknown): Date | null {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null;
}
export async function POST(req: Request) {
// RAW body — never req.json() here; the signature is over the exact bytes.
const rawBody = await req.text();
const signature = req.headers.get("x-razorpay-signature") ?? "";
const eventId = req.headers.get("x-razorpay-event-id") ?? "";
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
console.error("Razorpay webhook: not configured", e);
return new NextResponse("not configured", { status: 500 });
}
// 1. Verify signature (timing-safe).
const expected = crypto
.createHmac("sha256", cfg.webhookSecret)
.update(rawBody)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const validSig = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
if (!validSig) {
return new NextResponse("bad signature", { status: 400 });
}
if (!eventId) {
// No event id to dedupe on — refuse so Razorpay retries with headers.
return new NextResponse("missing event id", { status: 400 });
}
let event: {
event?: string;
payload?: { subscription?: { entity?: Record<string, unknown> } };
};
try {
event = JSON.parse(rawBody);
} catch {
return new NextResponse("bad json", { status: 400 });
}
const eventType = event.event ?? "unknown";
// 2. Idempotency — unique insert on event id. If it already exists, bail 200.
try {
const inserted = await sql`
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
ON CONFLICT (razorpay_event_id) DO NOTHING
RETURNING id
`;
if (inserted.length === 0) {
return new NextResponse("ok (duplicate)", { status: 200 });
}
} catch (e) {
console.error("Razorpay webhook: log insert failed", e);
return new NextResponse("log error", { status: 500 }); // retry
}
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries.
try {
const sub = event.payload?.subscription?.entity;
const subId = sub?.id as string | undefined;
// Events without a subscription entity (e.g. payment.* if ever subscribed)
// are logged above; nothing to sync.
if (!subId) return new NextResponse("ok (no subscription entity)", { status: 200 });
// Find our row for this Razorpay subscription.
const rows = await sql`
SELECT id, family_id FROM family_subscriptions
WHERE razorpay_subscription_id = ${subId}
LIMIT 1
`;
const row = rows[0] as { id: string; family_id: string } | undefined;
if (!row) {
// Unknown subscription (e.g. created outside this app). Logged; ack so
// Razorpay stops retrying — there's nothing for us to update.
console.warn("Razorpay webhook: no family_subscriptions row for", subId);
return new NextResponse("ok (unknown subscription)", { status: 200 });
}
const currentStart = unixToDate(sub?.current_start);
const currentEnd = unixToDate(sub?.current_end);
const customerId = (sub?.customer_id as string) ?? null;
const grantStatus = GRANT_EVENTS[eventType];
const revokeStatus = REVOKE_EVENTS[eventType];
if (grantStatus) {
await sql`
UPDATE family_subscriptions SET
status = ${grantStatus}::subscription_status_enum,
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
current_start = COALESCE(${currentStart}, current_start),
current_end = COALESCE(${currentEnd}, current_end),
updated_at = NOW()
WHERE id = ${row.id}
`;
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
await grantPremium(row.family_id, grantStatus);
} else if (revokeStatus) {
const endedAt = revokeStatus === "paused" ? null : new Date();
await sql`
UPDATE family_subscriptions SET
status = ${revokeStatus}::subscription_status_enum,
cancelled_at = ${revokeStatus === "cancelled" ? new Date() : null},
ended_at = COALESCE(${endedAt}, ended_at),
updated_at = NOW()
WHERE id = ${row.id}
`;
await revokeToFree(row.family_id, revokeStatus);
} else {
// Unhandled event type — already logged, ack it.
return new NextResponse("ok (unhandled event)", { status: 200 });
}
return new NextResponse("ok", { status: 200 });
} catch (e) {
console.error("Razorpay webhook: processing error", e);
return new NextResponse("processing error", { status: 500 }); // retry
}
}

View file

@ -52,6 +52,9 @@ const protectedApiRoutes = [
"/api/chat", "/api/chat",
"/api/history", "/api/history",
"/api/family/members", "/api/family/members",
"/api/subscriptions",
// NOTE: /api/webhooks/razorpay is intentionally NOT here — Razorpay calls it
// with no session cookie; it authenticates via HMAC signature instead.
]; ];
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {