Compare commits

...

2 commits

Author SHA1 Message Date
4d29ef89a0 feat(admin): dunning, admin cancel, CSV, real revenue trend + churn
Subscriptions page:
- Dunning banner: lists 'pending' (grace) subs at top — failing payments to
  chase before they halt/churn
- Per-row admin Cancel button (cancel_at_cycle_end via Razorpay; any family) —
  POST /api/admin/subscriptions {action:"cancel", subscriptionId}
- Export CSV (family/plan/status/dates/rzp id/price), quoted
- Reconcile button now sends {action:"reconcile"}

Revenue page:
- Real monthly revenue chart from subscription.charged events (valued by plan
  price, grouped by IST month) — replaces the fabricated chart
- Churn rate card = cancelled+halted+expired ÷ ever-live subs (red if >10%)

subscriptions API: added revenueTrend + churn to GET; POST routes
reconcile|cancel actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 15:32:16 +05:30
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
7 changed files with 316 additions and 31 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

@ -8,22 +8,32 @@ interface RevenueData {
mrr: number; // rupees
}
interface TrendPoint { month: string; paise: number; charges: number }
const inr = (rupees: number) =>
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
export default function AdminRevenue() {
const [data, setData] = useState<RevenueData | null>(null);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [churnRate, setChurnRate] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/admin/stats", { credentials: "include" })
.then((r) => r.json())
.then((stats) => {
Promise.all([
fetch("/api/admin/stats", { credentials: "include" }).then((r) => r.json()),
fetch("/api/admin/subscriptions", { credentials: "include" }).then((r) => r.json()).catch(() => null),
])
.then(([stats, subs]) => {
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
});
if (subs) {
setTrend(subs.revenueTrend || []);
setChurnRate(subs.summary?.churnRate ?? null);
}
})
.catch((err) => console.error("Failed to fetch revenue:", err))
.finally(() => setLoading(false));
@ -33,6 +43,8 @@ export default function AdminRevenue() {
return <div className="p-6 text-white">Loading...</div>;
}
const maxPaise = Math.max(1, ...trend.map((t) => t.paise));
// ARPU across paying families only (avoids a misleading blended number).
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
@ -61,11 +73,35 @@ export default function AdminRevenue() {
<div className="text-gray-400 text-sm">Paying Families</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div>
<div className="text-gray-400 text-sm">Free Families</div>
<div className={`text-3xl font-bold ${churnRate != null && churnRate > 10 ? "text-red-400" : "text-gray-400"}`}>
{churnRate != null ? `${churnRate}%` : "—"}
</div>
<div className="text-gray-400 text-sm">Churn rate</div>
</div>
</div>
{/* Real monthly revenue trend (from subscription.charged events) */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-1">Monthly Revenue (charged)</h3>
<p className="text-xs text-gray-500 mb-4">Actual collections from Razorpay subscription.charged events</p>
{trend.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">No charges recorded yet</p>
) : (
<div className="h-48 flex items-end gap-2">
{trend.map((t) => (
<div key={t.month} className="flex-1 flex flex-col items-center gap-1" title={`${inr(t.paise / 100)} · ${t.charges} charge(s)`}>
<div className="text-[10px] text-gray-400">{inr(t.paise / 100)}</div>
<div
className="w-full bg-emerald-500 rounded-t"
style={{ height: `${(t.paise / maxPaise) * 100}%`, minHeight: t.paise > 0 ? "4px" : "0" }}
/>
<div className="text-[9px] text-gray-500">{t.month.slice(5)}/{t.month.slice(2, 4)}</div>
</div>
))}
</div>
)}
</div>
{/* Revenue Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">

View file

@ -89,7 +89,12 @@ export default function AdminSubscriptions() {
setReconciling(true);
setMsg(null);
try {
const res = await fetch("/api/admin/subscriptions", { method: "POST", credentials: "include" });
const res = await fetch("/api/admin/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "reconcile" }),
});
const data = await res.json();
const ok = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok).length;
const failed = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok === false).length;
@ -101,6 +106,49 @@ export default function AdminSubscriptions() {
setReconciling(false);
};
const cancelSub = async (subId: string, familyName: string | null) => {
if (!window.confirm(`Cancel subscription for ${familyName || "this family"}? They keep premium until the cycle ends.`)) return;
setMsg(null);
try {
const res = await fetch("/api/admin/subscriptions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "cancel", subscriptionId: subId }),
});
const data = await res.json();
setMsg(res.ok ? (data.message || "Cancellation scheduled.") : (data.error || "Cancel failed"));
if (res.ok) fetchData();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Cancel failed");
}
};
const exportCSV = () => {
const headers = ["Family", "Plan", "Status", "Started", "Renews/Expires", "Cancelled", "RazorpaySubId", "PricePaise"];
const rows = subs.map((s) => [
s.family_name || s.family_id,
s.plan_name || "",
s.status,
s.current_start || "",
s.current_end || "",
s.cancelled_at || "",
s.razorpay_subscription_id,
String(s.price_paise ?? ""),
]);
const csv = [headers, ...rows]
.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(","))
.join("\n");
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
const a = document.createElement("a");
a.href = url;
a.download = `subscriptions-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
};
// Dunning: subs in 'pending' (a charge failed, Razorpay retrying) need attention.
const pendingSubs = subs.filter((s) => s.status === "pending");
if (loading) return <div className="p-6 text-white">Loading</div>;
return (
@ -110,16 +158,44 @@ export default function AdminSubscriptions() {
<h1 className="text-2xl font-bold">Subscriptions</h1>
<p className="text-gray-400">Razorpay subscription state &amp; webhook monitoring</p>
</div>
<button
onClick={reconcile}
disabled={reconciling}
className="px-4 py-2 bg-rose-500 hover:bg-rose-600 disabled:opacity-50 rounded-lg text-sm font-medium"
title="Replay latest webhook events to re-sync entitlement"
>
{reconciling ? "Reconciling…" : "↻ Reconcile"}
</button>
<div className="flex gap-2">
<button
onClick={exportCSV}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium"
>
Export CSV
</button>
<button
onClick={reconcile}
disabled={reconciling}
className="px-4 py-2 bg-rose-500 hover:bg-rose-600 disabled:opacity-50 rounded-lg text-sm font-medium"
title="Replay latest webhook events to re-sync entitlement"
>
{reconciling ? "Reconciling…" : "↻ Reconcile"}
</button>
</div>
</div>
{/* Dunning — failing payments that need attention before they churn */}
{pendingSubs.length > 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="font-semibold text-amber-400 mb-2">
{pendingSubs.length} payment{pendingSubs.length > 1 ? "s" : ""} failing (grace period)
</div>
<p className="text-xs text-amber-300/80 mb-3">
A charge failed and Razorpay is retrying. Reach out before retries exhaust and they churn (halted).
</p>
<div className="space-y-1">
{pendingSubs.map((s) => (
<div key={s.id} className="flex items-center justify-between text-sm">
<span>{s.family_name || s.family_id.slice(0, 8)} · {s.plan_name || "—"}</span>
<span className="text-gray-400">renews {fmt(s.current_end)}</span>
</div>
))}
</div>
</div>
)}
{msg && (
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
{msg}
@ -172,6 +248,7 @@ export default function AdminSubscriptions() {
<th className="px-4 py-2 text-left">Started</th>
<th className="px-4 py-2 text-left">Renews / Expires</th>
<th className="px-4 py-2 text-left">Razorpay ID</th>
<th className="px-4 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
@ -192,6 +269,18 @@ export default function AdminSubscriptions() {
{s.cancelled_at ? <span className="text-amber-400">cancels {fmt(s.current_end)}</span> : fmt(s.current_end)}
</td>
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{s.razorpay_subscription_id}</td>
<td className="px-4 py-2">
{["active", "authenticated", "pending"].includes(s.status) && !s.cancelled_at ? (
<button
onClick={() => cancelSub(s.razorpay_subscription_id, s.family_name)}
className="text-xs text-red-400 hover:text-red-300 underline"
>
Cancel
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
</tr>
))}
</tbody>

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 });
}
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);

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

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* GET /api/admin/subscriptions subscription monitoring data:
@ -58,6 +59,41 @@ export async function GET(request: Request) {
}
}
// Real monthly revenue trend from subscription.charged webhook events.
// Each charged event = one plan-price collection. We join back to the sub's
// plan to value it (price_paise), grouped by IST month.
let revenueTrend: { month: string; paise: number; charges: number }[] = [];
try {
const trend = await sql`
SELECT
to_char((e.received_at AT TIME ZONE 'Asia/Kolkata'), 'YYYY-MM') AS month,
COUNT(*)::int AS charges,
COALESCE(SUM(p.price_paise), 0)::bigint AS paise
FROM razorpay_webhook_events e
JOIN family_subscriptions fs
ON fs.razorpay_subscription_id = e.payload->'payload'->'subscription'->'entity'->>'id'
JOIN subscription_plans p ON p.id = fs.plan_id
WHERE e.event_type = 'subscription.charged'
AND e.received_at > NOW() - INTERVAL '12 months'
GROUP BY 1
ORDER BY 1
`;
revenueTrend = (trend as Record<string, unknown>[]).map((r) => ({
month: r.month as string,
paise: Number(r.paise) || 0,
charges: Number(r.charges) || 0,
}));
} catch { /* billing tables absent */ }
// Churn: cancelled+halted+expired ÷ all subs that ever became live.
const everLive = (subscriptions as Record<string, unknown>[]).filter(
(s) => s.status !== "created",
).length;
const churned = (subscriptions as Record<string, unknown>[]).filter((s) =>
["cancelled", "halted", "expired"].includes(s.status as string),
).length;
const churnRate = everLive > 0 ? Math.round((churned / everLive) * 1000) / 10 : 0;
return NextResponse.json({
subscriptions,
webhookEvents,
@ -66,10 +102,58 @@ export async function GET(request: Request) {
byStatus,
mrrPaise,
arrPaise: mrrPaise * 12,
churnRate, // %
churned,
everLive,
},
revenueTrend,
});
}
// POST /api/admin/subscriptions?action=reconcile — re-run entitlement sync.
// Delegates to the existing reconcile endpoint logic via internal import.
export { POST } from "../reconcile-subscriptions/route";
/**
* POST /api/admin/subscriptions
* body { action: "reconcile" } re-run entitlement sync
* body { action: "cancel", subscriptionId } cancel a sub (admin, any family)
*/
export async function POST(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const body = await request.json().catch(() => ({}));
const action = body.action || "reconcile";
if (action === "cancel") {
const subId = body.subscriptionId as string | undefined;
if (!subId) return NextResponse.json({ error: "subscriptionId required" }, { status: 400 });
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions/${subId}/cancel`, {
method: "POST",
headers: { Authorization: razorpayAuthHeader(cfg), "Content-Type": "application/json" },
body: JSON.stringify({ cancel_at_cycle_end: 1 }),
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
{ error: data?.error?.description || "Cancel failed", razorpay: data },
{ status: 502 },
);
}
// The subscription.cancelled webhook will sync state; this just initiates.
return NextResponse.json({ success: true, message: "Cancellation scheduled at cycle end." });
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 502 });
}
}
// Default: reconcile. Delegate to the recovery endpoint.
const { POST: reconcilePOST } = await import("../reconcile-subscriptions/route");
return reconcilePOST(request);
}

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 });