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:
parent
9dcd0fc854
commit
fbcfff47bd
8 changed files with 422 additions and 82 deletions
|
|
@ -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: "🤖" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,62 +66,38 @@ 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>
|
||||
|
|
|
|||
232
src/app/admin/subscriptions/page.tsx
Normal file
232
src/app/admin/subscriptions/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
75
src/app/api/admin/subscriptions/route.ts
Normal file
75
src/app/api/admin/subscriptions/route.ts
Normal 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";
|
||||
Loading…
Add table
Reference in a new issue