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"; import { sendAlert } from "@/lib/alert"; /** * 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 = { "subscription.authenticated": "authenticated", "subscription.activated": "active", "subscription.charged": "active", "subscription.resumed": "active", "subscription.pending": "pending", // grace — keep entitled }; const REVOKE_EVENTS: Record = { "subscription.halted": "halted", "subscription.cancelled": "cancelled", "subscription.completed": "completed", "subscription.expired": "expired", "subscription.paused": "paused", }; // Razorpay sends unix seconds. Return an ISO STRING (not a Date) — postgres.js // in this repo binds timestamps as strings via the custom serializer; passing a // raw Date object throws ERR_INVALID_ARG_TYPE. function unixToISO(v: unknown): string | null { const n = typeof v === "number" ? v : Number(v); return Number.isFinite(n) && n > 0 ? new Date(n * 1000).toISOString() : 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 } }; }; try { event = JSON.parse(rawBody); } catch { return new NextResponse("bad json", { status: 400 }); } const eventType = event.event ?? "unknown"; // 2. Audit log (best-effort, never blocks processing). We intentionally do // NOT early-return on duplicate here: processing is idempotent (status // UPDATE + grant/revoke are all upserts), so re-running a redelivered event // is safe. Early-returning on duplicate BEFORE processing was a trap — a // processing error left the event logged-but-unapplied and every retry hit // the duplicate guard and skipped processing forever. try { 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 `; } catch (e) { // Logging is not critical to entitlement — carry on and still process. console.error("Razorpay webhook: log insert failed (continuing)", e); } // 3 + 4. Process the event. Errors here → 500 so Razorpay retries (and the // retry WILL reprocess, because we no longer short-circuit on duplicate). 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 = unixToISO(sub?.current_start); const currentEnd = unixToISO(sub?.current_end); const customerId = (sub?.customer_id as string) ?? null; const grantStatus = GRANT_EVENTS[eventType]; const revokeStatus = REVOKE_EVENTS[eventType]; // Family name for alert context (best-effort). const famRows = await sql`SELECT name FROM families WHERE id = ${row.family_id} LIMIT 1`; const familyName = (famRows[0]?.name as string) || row.family_id.slice(0, 8); 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} `; // resumed→active; pending is grace (kept entitled) but a payment FAILED. await grantPremium(row.family_id, grantStatus); if (eventType === "subscription.pending") { // A charge failed; Razorpay is retrying. Reach out before they churn. await sendAlert("warn", "Payment failing (grace period)", undefined, { fields: { Family: familyName, Subscription: subId, Status: "pending — retrying" }, }); } else if (eventType === "subscription.charged") { await sendAlert("info", "💸 Subscription charged", undefined, { fields: { Family: familyName, Subscription: subId }, silent: true, }); } else if (eventType === "subscription.activated") { await sendAlert("info", "🎉 New premium subscriber", undefined, { fields: { Family: familyName, Subscription: subId }, }); } } else if (revokeStatus) { const nowIso = new Date().toISOString(); const endedAt = revokeStatus === "paused" ? null : nowIso; const cancelledAt = revokeStatus === "cancelled" ? nowIso : null; await sql` UPDATE family_subscriptions SET status = ${revokeStatus}::subscription_status_enum, cancelled_at = ${cancelledAt}, ended_at = COALESCE(${endedAt}, ended_at), updated_at = NOW() WHERE id = ${row.id} `; await revokeToFree(row.family_id, revokeStatus); if (revokeStatus === "halted") { // Retries exhausted — customer just churned involuntarily. Loud alert. await sendAlert("error", "🔴 Subscription HALTED (churn)", "Payment retries exhausted — family downgraded to free.", { fields: { Family: familyName, Subscription: subId }, }); } else if (revokeStatus === "cancelled") { await sendAlert("warn", "Subscription cancelled", undefined, { fields: { Family: familyName, Subscription: subId }, }); } } 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 } }