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: * - 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; } } // 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, summary: { total: subscriptions.length, byStatus, mrrPaise, arrPaise: mrrPaise * 12, churnRate, // % churned, everLive, }, revenueTrend, }); } /** * 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); }