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)} +
+ )} +
+ )} + + + {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
+
+ + + + + + + + + + + + + {subs.map((s) => ( + + + + + + + + + ))} + +
FamilyPlanStatusStartedRenews / ExpiresRazorpay ID
{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
+
+ + + + + + + + + + + {events.map((e) => ( + + + + + + + ))} + +
ReceivedEventSubscriptionStatus
{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";