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",
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE families
|
||||
SET tier = COALESCE(${tier}, tier),
|
||||
max_children = COALESCE(${maxChildren}, max_children),
|
||||
max_members = COALESCE(${maxMembers}, max_members)
|
||||
WHERE id = ${familyId}
|
||||
`;
|
||||
// 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 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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue