tia/src/app/api/webhooks/razorpay/route.ts
Mannu 756f5d6cfb feat(admin/billing): grant-logic tier change, churn alerts, webhook health
#1 Admin tier change uses real grant logic:
- families PATCH calls grantPremium()/revokeToFree() on tier change instead of
  hardcoded maxMembers:10. Manual/comp upgrades now match real grant (50GB/6/3).
  subscription_status records 'admin_comp'/'admin_downgrade'. Explicit
  maxChildren/maxMembers overrides still honored. Client sends tier only.

#2 Failed-payment / churn Telegram alerts (webhook):
- subscription.pending  -> warn "Payment failing (grace)" — reach out pre-churn
- subscription.halted   -> error "Subscription HALTED (churn)"
- subscription.cancelled-> warn; activated -> "New subscriber"; charged -> silent
  All include family name + sub id.

#3 Webhook freshness in admin health:
- New "Razorpay Webhooks" check: last event age (Xm/Xh/Xd ago). warn if >35d
  silence while subs exist (renewals should keep it fresh). Also added a
  "Razorpay" config-presence check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:28:23 +05:30

204 lines
8.3 KiB
TypeScript

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<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",
};
// 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<string, unknown> } };
};
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
}
}