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:
parent
fbcfff47bd
commit
756f5d6cfb
4 changed files with 90 additions and 14 deletions
|
|
@ -170,12 +170,9 @@ export default function AdminFamilies() {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
// Send only the tier — the server applies the real grant/revoke
|
||||||
familyId,
|
// (50GB / 6 members / 3 children for pro; free defaults otherwise).
|
||||||
tier: newTier,
|
body: JSON.stringify({ familyId, tier: newTier }),
|
||||||
maxChildren: newTier === "pro" ? 10 : 1,
|
|
||||||
maxMembers: newTier === "pro" ? 10 : 2,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
|
||||||
// GET all families with members
|
// GET all families with members
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
@ -118,14 +119,28 @@ export async function PATCH(request: Request) {
|
||||||
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`
|
// Tier change → apply the SAME grant/revoke logic the webhook uses, so a
|
||||||
UPDATE families
|
// manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
|
||||||
SET tier = COALESCE(${tier}, tier),
|
// hardcoded limits. subscription_status records it was set by admin.
|
||||||
max_children = COALESCE(${maxChildren}, max_children),
|
if (tier === "pro") {
|
||||||
max_members = COALESCE(${maxMembers}, max_members)
|
await grantPremium(familyId, "admin_comp");
|
||||||
WHERE id = ${familyId}
|
} 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 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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin families error:", error);
|
console.error("Admin families error:", error);
|
||||||
|
|
|
||||||
|
|
@ -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("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("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("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")
|
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
|
||||||
? "down"
|
? "down"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import crypto from "crypto";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { getRazorpayConfig } from "@/lib/billing/config";
|
import { getRazorpayConfig } from "@/lib/billing/config";
|
||||||
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webhooks/razorpay — THE source of truth for entitlement.
|
* 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 grantStatus = GRANT_EVENTS[eventType];
|
||||||
const revokeStatus = REVOKE_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) {
|
if (grantStatus) {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE family_subscriptions SET
|
UPDATE family_subscriptions SET
|
||||||
|
|
@ -144,8 +149,24 @@ export async function POST(req: Request) {
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = ${row.id}
|
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);
|
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) {
|
} else if (revokeStatus) {
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
||||||
|
|
@ -159,6 +180,17 @@ export async function POST(req: Request) {
|
||||||
WHERE id = ${row.id}
|
WHERE id = ${row.id}
|
||||||
`;
|
`;
|
||||||
await revokeToFree(row.family_id, revokeStatus);
|
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 {
|
} else {
|
||||||
// Unhandled event type — already logged, ack it.
|
// Unhandled event type — already logged, ack it.
|
||||||
return new NextResponse("ok (unhandled event)", { status: 200 });
|
return new NextResponse("ok (unhandled event)", { status: 200 });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue