diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx
index b5864f8..bf422ac 100644
--- a/src/app/admin/AdminSidebar.tsx
+++ b/src/app/admin/AdminSidebar.tsx
@@ -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: "🤖" },
diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx
index d6f3801..c381348 100644
--- a/src/app/admin/families/page.tsx
+++ b/src/app/admin/families/page.tsx
@@ -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() {
{family.tier}
+ {family.subscription && (
+
+ {family.subscription.status}
+ {family.subscription.startedAt && (
+ since {family.subscription.startedAt.slice(0, 10)}
+ )}
+ {family.subscription.expiresAt && (
+
+ {family.subscription.cancelledAt ? "ends " : "renews "}
+ {family.subscription.expiresAt.slice(0, 10)}
+
+ )}
+
+ )}
|
-
+
REVENUE OVERVIEW
- ${overview.mrr.toFixed(2)}
+ ₹{(overview.mrr || 0).toLocaleString("en-IN")}
Monthly Recurring
diff --git a/src/app/admin/revenue/page.tsx b/src/app/admin/revenue/page.tsx
index 797037e..5fc0a15 100644
--- a/src/app/admin/revenue/page.tsx
+++ b/src/app/admin/revenue/page.tsx
@@ -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 (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 Loading... ;
}
+ // ARPU across paying families only (avoids a misleading blended number).
+ const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
+
return (
Revenue
- Subscription revenue analytics
- Pro: ${PRO_PRICE}/month per family
+ Real subscription revenue, in ₹ (from active Razorpay subscriptions)
+
+ For per-subscription detail, see the Subscriptions page.
+
{/* Key Metrics */}
- ${data.mrr.toFixed(2)}
+ {inr(data.mrr)}
Monthly Recurring Revenue
- ${(data.mrr * 12).toFixed(2)}
+ {inr(data.mrr * 12)}
Annual Run Rate
{data.proFamilies}
- Pro Families
+ Paying Families
{data.freeFamilies}
@@ -80,65 +66,41 @@ export default function AdminRevenue() {
- {/* Revenue Chart */}
-
- Monthly Revenue
-
- {data.history.map((h, i) => (
-
- 0 ? `${(h.revenue / (data.mrr * 1.2)) * 100}%` : "0%",
- minHeight: h.revenue > 0 ? "4px" : "0"
- }}
- />
- {h.month}
-
- ))}
-
-
-
{/* Revenue Breakdown */}
Revenue by Tier
- Pro
- ${(data.proFamilies * PRO_PRICE).toFixed(2)}/mo
+ Premium
+ {inr(data.mrr)}/mo
Free
- $0.00/mo
+ ₹0/mo
+
+
+ ARPU (paying)
+ {inr(arpu)}/mo
Growth Potential
+ If free families convert at premium price
-
- If 10% convert
-
- ${((data.freeFamilies * 0.1) * PRO_PRICE).toFixed(2)}/mo
-
-
-
- If 25% convert
-
- ${((data.freeFamilies * 0.25) * PRO_PRICE).toFixed(2)}/mo
-
-
-
- If 50% convert
-
- ${((data.freeFamilies * 0.5) * PRO_PRICE).toFixed(2)}/mo
-
-
+ {[0.1, 0.25, 0.5].map((rate) => (
+
+ If {rate * 100}% convert
+ = 0.5 ? "text-emerald-400" : "text-amber-400"}`}>
+ {inr(data.freeFamilies * rate * arpu)}/mo
+
+
+ ))}
);
-}
\ No newline at end of file
+}
diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx
new file mode 100644
index 0000000..931e8c5
--- /dev/null
+++ b/src/app/admin/subscriptions/page.tsx
@@ -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 ;
+ 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 = {
+ 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([]);
+ const [events, setEvents] = useState([]);
+ const [summary, setSummary] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [reconciling, setReconciling] = useState(false);
+ const [msg, setMsg] = useState(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 Loading… ;
+
+ return (
+
+
+
+ Subscriptions
+ Razorpay subscription state & webhook monitoring
+
+
+
+
+ {msg && (
+
+ {msg}
+
+ )}
+
+ {/* Summary cards */}
+ {summary && (
+
+
+ {inr(summary.mrrPaise)}
+ MRR (active)
+
+
+ {inr(summary.arrPaise)}
+ Annual run rate
+
+
+ {summary.byStatus.active || 0}
+ Active
+
+
+ {summary.total}
+ Total subscriptions
+
+
+ )}
+
+ {/* Status breakdown chips */}
+ {summary && Object.keys(summary.byStatus).length > 0 && (
+
+ {Object.entries(summary.byStatus).map(([status, count]) => (
+
+ {status}: {count}
+
+ ))}
+
+ )}
+
+ {/* Subscriptions table */}
+
+ Subscriptions
+
+
+
+
+ | Family |
+ Plan |
+ Status |
+ Started |
+ Renews / Expires |
+ Razorpay ID |
+
+
+
+ {subs.map((s) => (
+
+ | {s.family_name || {s.family_id.slice(0, 8)}…} |
+
+ {s.plan_name || "—"}
+ {s.price_paise ? · {inr(s.price_paise)} : null}
+ |
+
+
+ {s.status}
+
+ |
+ {fmt(s.current_start)} |
+
+ {s.cancelled_at ? cancels {fmt(s.current_end)} : fmt(s.current_end)}
+ |
+ {s.razorpay_subscription_id} |
+
+ ))}
+
+
+ {subs.length === 0 && No subscriptions yet }
+
+
+
+ {/* Webhook events log */}
+
+ Recent Webhook Events
+
+
+
+
+ | Received |
+ Event |
+ Subscription |
+ Status |
+
+
+
+ {events.map((e) => (
+
+ | {fmt(e.received_at)} |
+ {e.event_type} |
+ {e.sub_id || "—"} |
+ {e.sub_status || "—"} |
+
+ ))}
+
+
+ {events.length === 0 && No webhook events received yet }
+
+
+
+ );
+}
diff --git a/src/app/api/admin/families/route.ts b/src/app/api/admin/families/route.ts
index 4bc0186..fd92340 100644
--- a/src/app/api/admin/families/route.ts
+++ b/src/app/api/admin/families/route.ts
@@ -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();
+ 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) {
diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts
index dc722a2..f4db924 100644
--- a/src/app/api/admin/stats/route.ts
+++ b/src/app/api/admin/stats/route.ts
@@ -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`
diff --git a/src/app/api/admin/subscriptions/route.ts b/src/app/api/admin/subscriptions/route.ts
new file mode 100644
index 0000000..177f817
--- /dev/null
+++ b/src/app/api/admin/subscriptions/route.ts
@@ -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 = {};
+ let mrrPaise = 0;
+ for (const s of subscriptions as Record[]) {
+ 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";
|