feat(admin): subscriptions page, real ₹ revenue, family sub dates

Subscriptions monitoring (NEW):
- GET /api/admin/subscriptions: live subs (family+plan+status+dates+rzp id),
  recent webhook events, summary (status counts, MRR/ARR paise). POST = reconcile
- /admin/subscriptions page: summary cards, status chips, subs table, webhook
  log, one-click Reconcile button. Added to sidebar (💳)

Real revenue in INR (was mock $9.99):
- stats API: MRR now SUM(plan price_paise) of active/authenticated/pending subs
- revenue page: all ₹, real MRR/ARR/ARPU, growth potential off real ARPU,
  links to subscriptions page (removed fabricated monthly history)
- dashboard MRR card + revenue overview: $ -> ₹ with en-IN formatting

Family subscription dates:
- families API: joins latest family_subscriptions -> {status, plan, startedAt,
  expiresAt, cancelledAt} (try/catch safe if billing tables absent)
- families page: tier cell shows status + "since" date + renews/ends date
  (amber when cancelled)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 15:18:43 +05:30
parent 9dcd0fc854
commit fbcfff47bd
8 changed files with 422 additions and 82 deletions

View file

@ -20,6 +20,7 @@ const navItems: NavItem[] = [
{ name: "Users", href: "/admin/users", icon: "👥" },
{ name: "Children", href: "/admin/children", icon: "👶" },
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
{ name: "Subscriptions", href: "/admin/subscriptions", icon: "💳" },
{ name: "Storage", href: "/admin/storage", icon: "💾" },
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },

View file

@ -11,6 +11,15 @@ interface Member {
displayName: string;
}
interface Subscription {
status: string;
planName: string | null;
pricePaise: number | null;
startedAt: string | null;
expiresAt: string | null;
cancelledAt: string | null;
}
interface Family {
id: string;
name: string;
@ -23,6 +32,7 @@ interface Family {
logCount: number;
memoryCount: number;
members: Member[];
subscription: Subscription | null;
}
export default function AdminFamilies() {
@ -313,6 +323,20 @@ export default function AdminFamilies() {
</td>
<td className="px-4 py-3">
<Badge variant={family.tier === "pro" ? "rose" : "default"}>{family.tier}</Badge>
{family.subscription && (
<div className="mt-1 text-[11px] leading-tight text-gray-500">
<div className="text-gray-400">{family.subscription.status}</div>
{family.subscription.startedAt && (
<div title="Subscribed since">since {family.subscription.startedAt.slice(0, 10)}</div>
)}
{family.subscription.expiresAt && (
<div title="Renews / expires" className={family.subscription.cancelledAt ? "text-amber-400" : ""}>
{family.subscription.cancelledAt ? "ends " : "renews "}
{family.subscription.expiresAt.slice(0, 10)}
</div>
)}
</div>
)}
</td>
<td className="px-4 py-3">
<Button

View file

@ -82,7 +82,7 @@ export default function AdminDashboard() {
<StatCard label="Families" value={overview.totalFamilies} icon="🏠" color="rose" href="/admin/families" />
<StatCard label="Users" value={overview.totalUsers} icon="👥" color="blue" href="/admin/users" />
<StatCard label="Children" value={overview.totalChildren} icon="👶" color="amber" href="/admin/children" />
<StatCard label="MRR" value={`$${overview.mrr.toFixed(2)}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard label="MRR" value={`${(overview.mrr || 0).toLocaleString("en-IN")}`} icon="💰" color="emerald" href="/admin/revenue" />
<StatCard
label="Active Sessions"
value={overview.activeSessions}
@ -105,7 +105,7 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-400 mb-4">REVENUE OVERVIEW</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-2xl font-bold text-emerald-400">${overview.mrr.toFixed(2)}</div>
<div className="text-2xl font-bold text-emerald-400">{(overview.mrr || 0).toLocaleString("en-IN")}</div>
<div className="text-gray-400 text-sm">Monthly Recurring</div>
</div>
<div>

View file

@ -5,74 +5,60 @@ import { useEffect, useState } from "react";
interface RevenueData {
proFamilies: number;
freeFamilies: number;
mrr: number;
history: { month: string; revenue: number }[];
mrr: number; // rupees
}
const PRO_PRICE = 9.99;
const inr = (rupees: number) =>
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
export default function AdminRevenue() {
const [data, setData] = useState<RevenueData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchRevenue();
fetch("/api/admin/stats", { credentials: "include" })
.then((r) => r.json())
.then((stats) => {
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
});
})
.catch((err) => console.error("Failed to fetch revenue:", err))
.finally(() => setLoading(false));
}, []);
const fetchRevenue = async () => {
try {
const res = await fetch("/api/admin/stats", { credentials: "include" });
const stats = await res.json();
setData({
proFamilies: stats.overview?.proFamilies || 0,
freeFamilies: stats.overview?.freeFamilies || 0,
mrr: stats.overview?.mrr || 0,
history: generateMonthlyHistory(stats.overview?.proFamilies || 0),
});
} catch (err) {
console.error("Failed to fetch revenue:", err);
}
setLoading(false);
};
const generateMonthlyHistory = (proCount: number): { month: string; revenue: number }[] => {
const months: { month: string; revenue: number }[] = [];
for (let i = 11; i >= 0; i--) {
const date = new Date();
date.setMonth(date.getMonth() - i);
months.push({
month: date.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
revenue: proCount > 0 ? proCount * PRO_PRICE : 0,
});
}
return months;
};
if (loading || !data) {
return <div className="p-6 text-white">Loading...</div>;
}
// ARPU across paying families only (avoids a misleading blended number).
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Revenue</h1>
<p className="text-gray-400">Subscription revenue analytics</p>
<p className="text-sm text-rose-400">Pro: ${PRO_PRICE}/month per family</p>
<p className="text-gray-400">Real subscription revenue, in (from active Razorpay subscriptions)</p>
<p className="text-xs text-gray-500 mt-1">
For per-subscription detail, see the <a href="/admin/subscriptions" className="text-rose-400 underline">Subscriptions</a> page.
</p>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-emerald-400">${data.mrr.toFixed(2)}</div>
<div className="text-3xl font-bold text-emerald-400">{inr(data.mrr)}</div>
<div className="text-gray-400 text-sm">Monthly Recurring Revenue</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">${(data.mrr * 12).toFixed(2)}</div>
<div className="text-3xl font-bold text-rose-400">{inr(data.mrr * 12)}</div>
<div className="text-gray-400 text-sm">Annual Run Rate</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<div className="text-3xl font-bold text-rose-400">{data.proFamilies}</div>
<div className="text-gray-400 text-sm">Pro Families</div>
<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>
@ -80,65 +66,41 @@ export default function AdminRevenue() {
</div>
</div>
{/* Revenue Chart */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Monthly Revenue</h3>
<div className="h-48 flex items-end gap-1">
{data.history.map((h, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div
className="w-full bg-emerald-500 rounded-t"
style={{
height: data.mrr > 0 ? `${(h.revenue / (data.mrr * 1.2)) * 100}%` : "0%",
minHeight: h.revenue > 0 ? "4px" : "0"
}}
/>
<div className="text-[8px] text-gray-500">{h.month}</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">
<h3 className="text-lg font-semibold mb-4">Revenue by Tier</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-emerald-400">Pro</span>
<span className="font-bold">${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo</span>
<span className="text-emerald-400">Premium</span>
<span className="font-bold">{inr(data.mrr)}/mo</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">Free</span>
<span className="font-bold">$0.00/mo</span>
<span className="font-bold">0/mo</span>
</div>
<div className="flex justify-between items-center border-t border-gray-700 pt-3">
<span className="text-gray-400">ARPU (paying)</span>
<span className="font-bold text-gray-300">{inr(arpu)}/mo</span>
</div>
</div>
</div>
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Growth Potential</h3>
<p className="text-xs text-gray-500 mb-3">If free families convert at premium price</p>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span>If 10% convert</span>
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<span>If 25% convert</span>
<span className="font-bold text-amber-400">
${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
<div className="flex justify-between items-center">
<span>If 50% convert</span>
<span className="font-bold text-emerald-400">
${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo
</span>
</div>
{[0.1, 0.25, 0.5].map((rate) => (
<div key={rate} className="flex justify-between items-center">
<span>If {rate * 100}% convert</span>
<span className={`font-bold ${rate >= 0.5 ? "text-emerald-400" : "text-amber-400"}`}>
{inr(data.freeFamilies * rate * arpu)}/mo
</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
}

View file

@ -0,0 +1,232 @@
"use client";
import { useEffect, useState, useCallback } from "react";
interface Subscription {
id: string;
family_id: string;
family_name: string | null;
plan_name: string | null;
price_paise: number | null;
status: string;
razorpay_subscription_id: string;
razorpay_customer_id: string | null;
current_start: string | null;
current_end: string | null;
cancelled_at: string | null;
ended_at: string | null;
created_at: string;
updated_at: string;
}
interface WebhookEvent {
razorpay_event_id: string;
event_type: string;
received_at: string;
sub_id: string | null;
sub_status: string | null;
}
interface Summary {
total: number;
byStatus: Record<string, number>;
mrrPaise: number;
arrPaise: number;
}
const inr = (paise: number | null | undefined) =>
"₹" + ((Number(paise) || 0) / 100).toLocaleString("en-IN", { maximumFractionDigits: 0 });
const fmt = (iso: string | null) =>
iso
? new Date(iso).toLocaleString("en-IN", {
timeZone: "Asia/Kolkata",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "—";
const STATUS_COLORS: Record<string, string> = {
active: "bg-emerald-500/15 text-emerald-400",
authenticated: "bg-emerald-500/15 text-emerald-400",
pending: "bg-amber-500/15 text-amber-400",
created: "bg-gray-500/15 text-gray-400",
halted: "bg-red-500/15 text-red-400",
cancelled: "bg-red-500/15 text-red-400",
completed: "bg-blue-500/15 text-blue-400",
expired: "bg-gray-500/15 text-gray-500",
paused: "bg-amber-500/15 text-amber-400",
};
export default function AdminSubscriptions() {
const [subs, setSubs] = useState<Subscription[]>([]);
const [events, setEvents] = useState<WebhookEvent[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(true);
const [reconciling, setReconciling] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/admin/subscriptions", { credentials: "include" });
const data = await res.json();
if (data.error) throw new Error(data.error);
setSubs(data.subscriptions || []);
setEvents(data.webhookEvents || []);
setSummary(data.summary || null);
} catch (e) {
setMsg(e instanceof Error ? e.message : "Failed to load");
}
setLoading(false);
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const reconcile = async () => {
setReconciling(true);
setMsg(null);
try {
const res = await fetch("/api/admin/subscriptions", { method: "POST", credentials: "include" });
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;
setMsg(`Reconciled ${ok} OK${failed ? `, ${failed} failed` : ""}.`);
fetchData();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Reconcile failed");
}
setReconciling(false);
};
if (loading) return <div className="p-6 text-white">Loading</div>;
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<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>
{msg && (
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
{msg}
</div>
)}
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-emerald-400">{inr(summary.mrrPaise)}</div>
<div className="text-gray-400 text-sm">MRR (active)</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-rose-400">{inr(summary.arrPaise)}</div>
<div className="text-gray-400 text-sm">Annual run rate</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold">{summary.byStatus.active || 0}</div>
<div className="text-gray-400 text-sm">Active</div>
</div>
<div className="bg-gray-800 p-5 rounded-xl">
<div className="text-2xl font-bold text-gray-300">{summary.total}</div>
<div className="text-gray-400 text-sm">Total subscriptions</div>
</div>
</div>
)}
{/* Status breakdown chips */}
{summary && Object.keys(summary.byStatus).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(summary.byStatus).map(([status, count]) => (
<span key={status} className={`text-xs px-2.5 py-1 rounded-full ${STATUS_COLORS[status] || "bg-gray-700 text-gray-300"}`}>
{status}: {count}
</span>
))}
</div>
)}
{/* Subscriptions table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Subscriptions</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700/50 text-gray-300">
<tr>
<th className="px-4 py-2 text-left">Family</th>
<th className="px-4 py-2 text-left">Plan</th>
<th className="px-4 py-2 text-left">Status</th>
<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>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{subs.map((s) => (
<tr key={s.id} className="hover:bg-gray-750">
<td className="px-4 py-2">{s.family_name || <span className="text-gray-600">{s.family_id.slice(0, 8)}</span>}</td>
<td className="px-4 py-2">
{s.plan_name || "—"}
{s.price_paise ? <span className="text-gray-500"> · {inr(s.price_paise)}</span> : null}
</td>
<td className="px-4 py-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[s.status] || "bg-gray-700 text-gray-300"}`}>
{s.status}
</span>
</td>
<td className="px-4 py-2 text-gray-400">{fmt(s.current_start)}</td>
<td className="px-4 py-2 text-gray-400">
{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>
</tr>
))}
</tbody>
</table>
{subs.length === 0 && <div className="p-8 text-center text-gray-500">No subscriptions yet</div>}
</div>
</div>
{/* Webhook events log */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 font-semibold">Recent Webhook Events</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-700/50 text-gray-300">
<tr>
<th className="px-4 py-2 text-left">Received</th>
<th className="px-4 py-2 text-left">Event</th>
<th className="px-4 py-2 text-left">Subscription</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{events.map((e) => (
<tr key={e.razorpay_event_id} className="hover:bg-gray-750">
<td className="px-4 py-2 text-gray-400">{fmt(e.received_at)}</td>
<td className="px-4 py-2 font-mono text-xs">{e.event_type}</td>
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{e.sub_id || "—"}</td>
<td className="px-4 py-2 text-gray-400">{e.sub_status || "—"}</td>
</tr>
))}
</tbody>
</table>
{events.length === 0 && <div className="p-8 text-center text-gray-500">No webhook events received yet</div>}
</div>
</div>
</div>
);
}

View file

@ -54,6 +54,35 @@ export async function GET(request: Request) {
});
});
// Subscription info per family (most recent sub). Wrapped in try/catch so
// the families page still works if the billing tables aren't present.
const subMap = new Map<string, any>();
if (familyIds.length > 0) {
try {
const subs = await sql`
SELECT DISTINCT ON (fs.family_id)
fs.family_id, fs.status, fs.current_start, fs.current_end,
fs.cancelled_at, p.name AS plan_name, p.price_paise
FROM family_subscriptions fs
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
WHERE fs.family_id = ANY(${familyIds})
ORDER BY fs.family_id, fs.created_at DESC
`;
(subs || []).forEach((s: any) => {
subMap.set(s.family_id, {
status: s.status,
planName: s.plan_name,
pricePaise: s.price_paise ? Number(s.price_paise) : null,
startedAt: s.current_start ? new Date(s.current_start).toISOString() : null,
expiresAt: s.current_end ? new Date(s.current_end).toISOString() : null,
cancelledAt: s.cancelled_at ? new Date(s.cancelled_at).toISOString() : null,
});
});
} catch {
/* billing tables not present yet */
}
}
return NextResponse.json({
families: families.map((f: any) => ({
id: f.id,
@ -67,6 +96,7 @@ export async function GET(request: Request) {
logCount: Number(f.log_count) || 0,
memoryCount: Number(f.memory_count) || 0,
members: memberMap.get(f.id) || [],
subscription: subMap.get(f.id) || null,
})),
});
} catch (error) {

View file

@ -21,7 +21,23 @@ export async function GET(request: Request) {
const proFamilies = tierStats.find((t: any) => t.tier === "pro")?.count || 0;
const freeFamilies = tierStats.find((t: any) => t.tier === "free")?.count || 0;
const totalFamilies = familyCount[0]?.count || 0;
const mrr = proFamilies * 9.99;
// Real MRR (₹) from active subscriptions — only entitled/recurring states.
// Sums the plan price (paise) of each family_subscriptions row that is
// currently active/authenticated/pending; falls back to 0 if no subs table.
let mrrPaise = 0;
try {
const mrrRow = await sql`
SELECT COALESCE(SUM(p.price_paise), 0)::bigint AS paise
FROM family_subscriptions fs
JOIN subscription_plans p ON p.id = fs.plan_id
WHERE fs.status IN ('active','authenticated','pending')
`;
mrrPaise = Number(mrrRow[0]?.paise) || 0;
} catch {
// subscriptions tables not present yet — leave MRR at 0
}
const mrr = mrrPaise / 100; // rupees
const [familiesByDay, usersByDay, childrenByAge, recentLogins, failedLogins] = await Promise.all([
sql`

View file

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireAdmin } from "@/lib/admin-auth";
/**
* GET /api/admin/subscriptions subscription monitoring data:
* - subscriptions: every family_subscriptions row joined to family + plan
* - webhookEvents: recent razorpay_webhook_events (debugging delivery)
* - summary: counts by status + MRR/ARR in paise (active+authenticated+pending)
*/
export async function GET(request: Request) {
const auth = await requireAdmin(request);
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const subscriptions = await sql`
SELECT
fs.id,
fs.family_id,
f.name AS family_name,
p.name AS plan_name,
p.price_paise,
fs.status,
fs.razorpay_subscription_id,
fs.razorpay_customer_id,
fs.current_start,
fs.current_end,
fs.cancelled_at,
fs.ended_at,
fs.created_at,
fs.updated_at
FROM family_subscriptions fs
LEFT JOIN families f ON f.id = fs.family_id
LEFT JOIN subscription_plans p ON p.id = fs.plan_id
ORDER BY fs.created_at DESC
LIMIT 200
`;
const webhookEvents = await sql`
SELECT
razorpay_event_id,
event_type,
received_at,
payload->'payload'->'subscription'->'entity'->>'id' AS sub_id,
payload->'payload'->'subscription'->'entity'->>'status' AS sub_status
FROM razorpay_webhook_events
ORDER BY received_at DESC
LIMIT 50
`;
// Summary: status counts + MRR (only entitled/recurring-active statuses).
const byStatus: Record<string, number> = {};
let mrrPaise = 0;
for (const s of subscriptions as Record<string, unknown>[]) {
const status = s.status as string;
byStatus[status] = (byStatus[status] || 0) + 1;
if (status === "active" || status === "authenticated" || status === "pending") {
mrrPaise += Number(s.price_paise) || 0;
}
}
return NextResponse.json({
subscriptions,
webhookEvents,
summary: {
total: subscriptions.length,
byStatus,
mrrPaise,
arrPaise: mrrPaise * 12,
},
});
}
// 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";