From 4d29ef89a0d2892522721abeb1a5ad783f83c6c3 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 6 Jun 2026 15:32:16 +0530 Subject: [PATCH] feat(admin): dunning, admin cancel, CSV, real revenue trend + churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/admin/revenue/page.tsx | 46 ++++++++-- src/app/admin/subscriptions/page.tsx | 107 +++++++++++++++++++++-- src/app/api/admin/subscriptions/route.ts | 90 ++++++++++++++++++- 3 files changed, 226 insertions(+), 17 deletions(-) diff --git a/src/app/admin/revenue/page.tsx b/src/app/admin/revenue/page.tsx index 5fc0a15..5ce440c 100644 --- a/src/app/admin/revenue/page.tsx +++ b/src/app/admin/revenue/page.tsx @@ -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(null); + const [trend, setTrend] = useState([]); + const [churnRate, setChurnRate] = useState(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
Loading...
; } + 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() {
Paying Families
-
{data.freeFamilies}
-
Free Families
+
10 ? "text-red-400" : "text-gray-400"}`}> + {churnRate != null ? `${churnRate}%` : "—"} +
+
Churn rate
+ {/* Real monthly revenue trend (from subscription.charged events) */} +
+

Monthly Revenue (charged)

+

Actual collections from Razorpay subscription.charged events

+ {trend.length === 0 ? ( +

No charges recorded yet

+ ) : ( +
+ {trend.map((t) => ( +
+
{inr(t.paise / 100)}
+
0 ? "4px" : "0" }} + /> +
{t.month.slice(5)}/{t.month.slice(2, 4)}
+
+ ))} +
+ )} +
+ {/* Revenue Breakdown */}
diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index 931e8c5..2a77f73 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -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
Loading…
; return ( @@ -110,16 +158,44 @@ export default function AdminSubscriptions() {

Subscriptions

Razorpay subscription state & webhook monitoring

- +
+ + +
+ {/* Dunning — failing payments that need attention before they churn */} + {pendingSubs.length > 0 && ( +
+
+ ⚠ {pendingSubs.length} payment{pendingSubs.length > 1 ? "s" : ""} failing (grace period) +
+

+ A charge failed and Razorpay is retrying. Reach out before retries exhaust and they churn (halted). +

+
+ {pendingSubs.map((s) => ( +
+ {s.family_name || s.family_id.slice(0, 8)} · {s.plan_name || "—"} + renews {fmt(s.current_end)} +
+ ))} +
+
+ )} + {msg && (
{msg} @@ -172,6 +248,7 @@ export default function AdminSubscriptions() { Started Renews / Expires Razorpay ID + Actions @@ -192,6 +269,18 @@ export default function AdminSubscriptions() { {s.cancelled_at ? cancels {fmt(s.current_end)} : fmt(s.current_end)} {s.razorpay_subscription_id} + + {["active", "authenticated", "pending"].includes(s.status) && !s.cancelled_at ? ( + + ) : ( + + )} + ))} diff --git a/src/app/api/admin/subscriptions/route.ts b/src/app/api/admin/subscriptions/route.ts index 177f817..94bddd8 100644 --- a/src/app/api/admin/subscriptions/route.ts +++ b/src/app/api/admin/subscriptions/route.ts @@ -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[]).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[]).filter( + (s) => s.status !== "created", + ).length; + const churned = (subscriptions as Record[]).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); +}