#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>
204 lines
8.3 KiB
TypeScript
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
|
|
}
|
|
}
|