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>
This commit is contained in:
Manohar Gupta 2026-06-06 15:28:23 +05:30
parent fbcfff47bd
commit 756f5d6cfb
4 changed files with 90 additions and 14 deletions

View file

@ -170,12 +170,9 @@ export default function AdminFamilies() {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
familyId,
tier: newTier,
maxChildren: newTier === "pro" ? 10 : 1,
maxMembers: newTier === "pro" ? 10 : 2,
}),
// Send only the tier — the server applies the real grant/revoke
// (50GB / 6 members / 3 children for pro; free defaults otherwise).
body: JSON.stringify({ familyId, tier: newTier }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to update tier");

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
// GET all families with members
export async function GET(request: Request) {
@ -118,14 +119,28 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: "familyId required" }, { status: 400 });
}
// Tier change → apply the SAME grant/revoke logic the webhook uses, so a
// manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
// hardcoded limits. subscription_status records it was set by admin.
if (tier === "pro") {
await grantPremium(familyId, "admin_comp");
} else if (tier === "free") {
await revokeToFree(familyId, "admin_downgrade");
}
// Explicit limit overrides (optional) still win — lets admin fine-tune.
if (maxChildren != null || maxMembers != null) {
await sql`
UPDATE families
SET tier = COALESCE(${tier}, tier),
max_children = COALESCE(${maxChildren}, max_children),
max_members = COALESCE(${maxMembers}, max_members)
SET max_children = COALESCE(${maxChildren ?? null}, max_children),
max_members = COALESCE(${maxMembers ?? null}, max_members),
updated_at = NOW()
WHERE id = ${familyId}
`;
}
// If only limits were passed (no tier), nothing above ran the tier path —
// that's fine, the override block handled it.
return NextResponse.json({ success: true });
} catch (error) {
console.error("Admin families error:", error);

View file

@ -68,6 +68,38 @@ export async function GET(request: Request) {
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
checks.push(configCheck("Razorpay", !!(process.env.RAZORPAY_KEY_ID && process.env.RAZORPAY_KEY_SECRET && process.env.RAZORPAY_WEBHOOK_SECRET && process.env.RAZORPAY_PLAN_ID), "RAZORPAY_* env vars incomplete"));
// 5. Razorpay webhook freshness — if webhooks silently stop, entitlement
// (and revenue) quietly breaks. Only meaningful once subscriptions exist.
if (dbOk) {
try {
const rows = await sql`
SELECT
(SELECT COUNT(*) FROM family_subscriptions)::int AS sub_count,
(SELECT MAX(received_at) FROM razorpay_webhook_events) AS last_event
`;
const subCount = Number(rows[0]?.sub_count) || 0;
const lastEvent = rows[0]?.last_event ? new Date(rows[0].last_event as string) : null;
if (subCount === 0) {
checks.push({ name: "Razorpay Webhooks", status: "ok", detail: "No subscriptions yet" });
} else if (!lastEvent) {
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Subscriptions exist but no webhook ever received" });
} else {
const hoursAgo = (Date.now() - lastEvent.getTime()) / 3_600_000;
const rel =
hoursAgo < 1 ? `${Math.round(hoursAgo * 60)}m ago`
: hoursAgo < 48 ? `${Math.round(hoursAgo)}h ago`
: `${Math.round(hoursAgo / 24)}d ago`;
// Subscriptions renew at least monthly, so >35 days of silence is suspect.
const status = hoursAgo > 35 * 24 ? "warn" : "ok";
checks.push({ name: "Razorpay Webhooks", status, detail: `Last event ${rel}` });
}
} catch {
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Billing tables unavailable" });
}
}
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
? "down"

View file

@ -3,6 +3,7 @@ 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.
@ -134,6 +135,10 @@ export async function POST(req: Request) {
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
@ -144,8 +149,24 @@ export async function POST(req: Request) {
updated_at = NOW()
WHERE id = ${row.id}
`;
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
// 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;
@ -159,6 +180,17 @@ export async function POST(req: Request) {
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 });