Subscriptions page:
- Dunning banner: lists 'pending' (grace) subs at top — failing payments to
chase before they halt/churn
- Per-row admin Cancel button (cancel_at_cycle_end via Razorpay; any family) —
POST /api/admin/subscriptions {action:"cancel", subscriptionId}
- Export CSV (family/plan/status/dates/rzp id/price), quoted
- Reconcile button now sends {action:"reconcile"}
Revenue page:
- Real monthly revenue chart from subscription.charged events (valued by plan
price, grouped by IST month) — replaces the fabricated chart
- Churn rate card = cancelled+halted+expired ÷ ever-live subs (red if >10%)
subscriptions API: added revenueTrend + churn to GET; POST routes
reconcile|cancel actions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
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<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;
|
|
}
|
|
}
|
|
|
|
// 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<string, unknown>[]).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<string, unknown>[]).filter(
|
|
(s) => s.status !== "created",
|
|
).length;
|
|
const churned = (subscriptions as Record<string, unknown>[]).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);
|
|
}
|