Compare commits
2 commits
fbcfff47bd
...
4d29ef89a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d29ef89a0 | |||
| 756f5d6cfb |
7 changed files with 316 additions and 31 deletions
|
|
@ -170,12 +170,9 @@ export default function AdminFamilies() {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
// Send only the tier — the server applies the real grant/revoke
|
||||||
familyId,
|
// (50GB / 6 members / 3 children for pro; free defaults otherwise).
|
||||||
tier: newTier,
|
body: JSON.stringify({ familyId, tier: newTier }),
|
||||||
maxChildren: newTier === "pro" ? 10 : 1,
|
|
||||||
maxMembers: newTier === "pro" ? 10 : 2,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
if (!res.ok) throw new Error(data.error || "Failed to update tier");
|
||||||
|
|
|
||||||
|
|
@ -8,22 +8,32 @@ interface RevenueData {
|
||||||
mrr: number; // rupees
|
mrr: number; // rupees
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrendPoint { month: string; paise: number; charges: number }
|
||||||
|
|
||||||
const inr = (rupees: number) =>
|
const inr = (rupees: number) =>
|
||||||
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
|
"₹" + (Number(rupees) || 0).toLocaleString("en-IN", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
export default function AdminRevenue() {
|
export default function AdminRevenue() {
|
||||||
const [data, setData] = useState<RevenueData | null>(null);
|
const [data, setData] = useState<RevenueData | null>(null);
|
||||||
|
const [trend, setTrend] = useState<TrendPoint[]>([]);
|
||||||
|
const [churnRate, setChurnRate] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/stats", { credentials: "include" })
|
Promise.all([
|
||||||
.then((r) => r.json())
|
fetch("/api/admin/stats", { credentials: "include" }).then((r) => r.json()),
|
||||||
.then((stats) => {
|
fetch("/api/admin/subscriptions", { credentials: "include" }).then((r) => r.json()).catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([stats, subs]) => {
|
||||||
setData({
|
setData({
|
||||||
proFamilies: stats.overview?.proFamilies || 0,
|
proFamilies: stats.overview?.proFamilies || 0,
|
||||||
freeFamilies: stats.overview?.freeFamilies || 0,
|
freeFamilies: stats.overview?.freeFamilies || 0,
|
||||||
mrr: stats.overview?.mrr || 0,
|
mrr: stats.overview?.mrr || 0,
|
||||||
});
|
});
|
||||||
|
if (subs) {
|
||||||
|
setTrend(subs.revenueTrend || []);
|
||||||
|
setChurnRate(subs.summary?.churnRate ?? null);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to fetch revenue:", err))
|
.catch((err) => console.error("Failed to fetch revenue:", err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
@ -33,6 +43,8 @@ export default function AdminRevenue() {
|
||||||
return <div className="p-6 text-white">Loading...</div>;
|
return <div className="p-6 text-white">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxPaise = Math.max(1, ...trend.map((t) => t.paise));
|
||||||
|
|
||||||
// ARPU across paying families only (avoids a misleading blended number).
|
// ARPU across paying families only (avoids a misleading blended number).
|
||||||
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
|
const arpu = data.proFamilies > 0 ? data.mrr / data.proFamilies : 0;
|
||||||
|
|
||||||
|
|
@ -61,9 +73,33 @@ export default function AdminRevenue() {
|
||||||
<div className="text-gray-400 text-sm">Paying Families</div>
|
<div className="text-gray-400 text-sm">Paying Families</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-800 p-6 rounded-xl">
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
<div className="text-3xl font-bold text-gray-400">{data.freeFamilies}</div>
|
<div className={`text-3xl font-bold ${churnRate != null && churnRate > 10 ? "text-red-400" : "text-gray-400"}`}>
|
||||||
<div className="text-gray-400 text-sm">Free Families</div>
|
{churnRate != null ? `${churnRate}%` : "—"}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-gray-400 text-sm">Churn rate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real monthly revenue trend (from subscription.charged events) */}
|
||||||
|
<div className="bg-gray-800 p-6 rounded-xl">
|
||||||
|
<h3 className="text-lg font-semibold mb-1">Monthly Revenue (charged)</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Actual collections from Razorpay subscription.charged events</p>
|
||||||
|
{trend.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-8 text-center">No charges recorded yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="h-48 flex items-end gap-2">
|
||||||
|
{trend.map((t) => (
|
||||||
|
<div key={t.month} className="flex-1 flex flex-col items-center gap-1" title={`${inr(t.paise / 100)} · ${t.charges} charge(s)`}>
|
||||||
|
<div className="text-[10px] text-gray-400">{inr(t.paise / 100)}</div>
|
||||||
|
<div
|
||||||
|
className="w-full bg-emerald-500 rounded-t"
|
||||||
|
style={{ height: `${(t.paise / maxPaise) * 100}%`, minHeight: t.paise > 0 ? "4px" : "0" }}
|
||||||
|
/>
|
||||||
|
<div className="text-[9px] text-gray-500">{t.month.slice(5)}/{t.month.slice(2, 4)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revenue Breakdown */}
|
{/* Revenue Breakdown */}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,12 @@ export default function AdminSubscriptions() {
|
||||||
setReconciling(true);
|
setReconciling(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/subscriptions", { method: "POST", credentials: "include" });
|
const res = await fetch("/api/admin/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ action: "reconcile" }),
|
||||||
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const ok = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok).length;
|
const ok = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok).length;
|
||||||
const failed = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok === false).length;
|
const failed = (data.reconciled || []).filter((r: { ok?: boolean }) => r.ok === false).length;
|
||||||
|
|
@ -101,6 +106,49 @@ export default function AdminSubscriptions() {
|
||||||
setReconciling(false);
|
setReconciling(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelSub = async (subId: string, familyName: string | null) => {
|
||||||
|
if (!window.confirm(`Cancel subscription for ${familyName || "this family"}? They keep premium until the cycle ends.`)) return;
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/subscriptions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ action: "cancel", subscriptionId: subId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setMsg(res.ok ? (data.message || "Cancellation scheduled.") : (data.error || "Cancel failed"));
|
||||||
|
if (res.ok) fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "Cancel failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
const headers = ["Family", "Plan", "Status", "Started", "Renews/Expires", "Cancelled", "RazorpaySubId", "PricePaise"];
|
||||||
|
const rows = subs.map((s) => [
|
||||||
|
s.family_name || s.family_id,
|
||||||
|
s.plan_name || "",
|
||||||
|
s.status,
|
||||||
|
s.current_start || "",
|
||||||
|
s.current_end || "",
|
||||||
|
s.cancelled_at || "",
|
||||||
|
s.razorpay_subscription_id,
|
||||||
|
String(s.price_paise ?? ""),
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((r) => r.map((c) => `"${String(c ?? "").replace(/"/g, '""')}"`).join(","))
|
||||||
|
.join("\n");
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `subscriptions-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dunning: subs in 'pending' (a charge failed, Razorpay retrying) need attention.
|
||||||
|
const pendingSubs = subs.filter((s) => s.status === "pending");
|
||||||
|
|
||||||
if (loading) return <div className="p-6 text-white">Loading…</div>;
|
if (loading) return <div className="p-6 text-white">Loading…</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -110,6 +158,13 @@ export default function AdminSubscriptions() {
|
||||||
<h1 className="text-2xl font-bold">Subscriptions</h1>
|
<h1 className="text-2xl font-bold">Subscriptions</h1>
|
||||||
<p className="text-gray-400">Razorpay subscription state & webhook monitoring</p>
|
<p className="text-gray-400">Razorpay subscription state & webhook monitoring</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={exportCSV}
|
||||||
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={reconcile}
|
onClick={reconcile}
|
||||||
disabled={reconciling}
|
disabled={reconciling}
|
||||||
|
|
@ -119,6 +174,27 @@ export default function AdminSubscriptions() {
|
||||||
{reconciling ? "Reconciling…" : "↻ Reconcile"}
|
{reconciling ? "Reconciling…" : "↻ Reconcile"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dunning — failing payments that need attention before they churn */}
|
||||||
|
{pendingSubs.length > 0 && (
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
|
||||||
|
<div className="font-semibold text-amber-400 mb-2">
|
||||||
|
⚠ {pendingSubs.length} payment{pendingSubs.length > 1 ? "s" : ""} failing (grace period)
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-amber-300/80 mb-3">
|
||||||
|
A charge failed and Razorpay is retrying. Reach out before retries exhaust and they churn (halted).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{pendingSubs.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{s.family_name || s.family_id.slice(0, 8)} · {s.plan_name || "—"}</span>
|
||||||
|
<span className="text-gray-400">renews {fmt(s.current_end)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{msg && (
|
{msg && (
|
||||||
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
|
<div className="bg-blue-500/15 border border-blue-500/30 text-blue-300 px-4 py-2 rounded-lg text-sm">
|
||||||
|
|
@ -172,6 +248,7 @@ export default function AdminSubscriptions() {
|
||||||
<th className="px-4 py-2 text-left">Started</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">Renews / Expires</th>
|
||||||
<th className="px-4 py-2 text-left">Razorpay ID</th>
|
<th className="px-4 py-2 text-left">Razorpay ID</th>
|
||||||
|
<th className="px-4 py-2 text-left">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
|
|
@ -192,6 +269,18 @@ export default function AdminSubscriptions() {
|
||||||
{s.cancelled_at ? <span className="text-amber-400">cancels {fmt(s.current_end)}</span> : fmt(s.current_end)}
|
{s.cancelled_at ? <span className="text-amber-400">cancels {fmt(s.current_end)}</span> : fmt(s.current_end)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{s.razorpay_subscription_id}</td>
|
<td className="px-4 py-2 text-gray-500 font-mono text-xs">{s.razorpay_subscription_id}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{["active", "authenticated", "pending"].includes(s.status) && !s.cancelled_at ? (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelSub(s.razorpay_subscription_id, s.family_name)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
|
||||||
// GET all families with members
|
// GET all families with members
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
@ -118,14 +119,28 @@ export async function PATCH(request: Request) {
|
||||||
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
return NextResponse.json({ error: "familyId required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier change → apply the SAME grant/revoke logic the webhook uses, so a
|
||||||
|
// manual/comp upgrade gets the real premium grant (50GB/6/3), not ad-hoc
|
||||||
|
// hardcoded limits. subscription_status records it was set by admin.
|
||||||
|
if (tier === "pro") {
|
||||||
|
await grantPremium(familyId, "admin_comp");
|
||||||
|
} else if (tier === "free") {
|
||||||
|
await revokeToFree(familyId, "admin_downgrade");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit limit overrides (optional) still win — lets admin fine-tune.
|
||||||
|
if (maxChildren != null || maxMembers != null) {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE families
|
UPDATE families
|
||||||
SET tier = COALESCE(${tier}, tier),
|
SET max_children = COALESCE(${maxChildren ?? null}, max_children),
|
||||||
max_children = COALESCE(${maxChildren}, max_children),
|
max_members = COALESCE(${maxMembers ?? null}, max_members),
|
||||||
max_members = COALESCE(${maxMembers}, max_members)
|
updated_at = NOW()
|
||||||
WHERE id = ${familyId}
|
WHERE id = ${familyId}
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If only limits were passed (no tier), nothing above ran the tier path —
|
||||||
|
// that's fine, the override block handled it.
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin families error:", error);
|
console.error("Admin families error:", error);
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,38 @@ export async function GET(request: Request) {
|
||||||
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
|
checks.push(configCheck("AI Gateway", !!(process.env.LITELLM_BASE_URL && process.env.LITELLM_API_KEY), "LITELLM_BASE_URL / LITELLM_API_KEY not set"));
|
||||||
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
|
checks.push(configCheck("R2 Storage", !!(process.env.R2_ACCOUNT_ID && process.env.R2_ACCESS_KEY_ID && process.env.R2_BUCKET_NAME), "R2_* env vars incomplete"));
|
||||||
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
|
checks.push(configCheck("Email (Resend)", !!process.env.RESEND_API_KEY, "RESEND_API_KEY not set"));
|
||||||
|
checks.push(configCheck("Razorpay", !!(process.env.RAZORPAY_KEY_ID && process.env.RAZORPAY_KEY_SECRET && process.env.RAZORPAY_WEBHOOK_SECRET && process.env.RAZORPAY_PLAN_ID), "RAZORPAY_* env vars incomplete"));
|
||||||
|
|
||||||
|
// 5. Razorpay webhook freshness — if webhooks silently stop, entitlement
|
||||||
|
// (and revenue) quietly breaks. Only meaningful once subscriptions exist.
|
||||||
|
if (dbOk) {
|
||||||
|
try {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM family_subscriptions)::int AS sub_count,
|
||||||
|
(SELECT MAX(received_at) FROM razorpay_webhook_events) AS last_event
|
||||||
|
`;
|
||||||
|
const subCount = Number(rows[0]?.sub_count) || 0;
|
||||||
|
const lastEvent = rows[0]?.last_event ? new Date(rows[0].last_event as string) : null;
|
||||||
|
|
||||||
|
if (subCount === 0) {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "ok", detail: "No subscriptions yet" });
|
||||||
|
} else if (!lastEvent) {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Subscriptions exist but no webhook ever received" });
|
||||||
|
} else {
|
||||||
|
const hoursAgo = (Date.now() - lastEvent.getTime()) / 3_600_000;
|
||||||
|
const rel =
|
||||||
|
hoursAgo < 1 ? `${Math.round(hoursAgo * 60)}m ago`
|
||||||
|
: hoursAgo < 48 ? `${Math.round(hoursAgo)}h ago`
|
||||||
|
: `${Math.round(hoursAgo / 24)}d ago`;
|
||||||
|
// Subscriptions renew at least monthly, so >35 days of silence is suspect.
|
||||||
|
const status = hoursAgo > 35 * 24 ? "warn" : "ok";
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status, detail: `Last event ${rel}` });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
checks.push({ name: "Razorpay Webhooks", status: "warn", detail: "Billing tables unavailable" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
|
const overall: "ok" | "warn" | "down" = checks.some(c => c.status === "down")
|
||||||
? "down"
|
? "down"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { requireAdmin } from "@/lib/admin-auth";
|
import { requireAdmin } from "@/lib/admin-auth";
|
||||||
|
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/subscriptions — subscription monitoring data:
|
* GET /api/admin/subscriptions — subscription monitoring data:
|
||||||
|
|
@ -58,6 +59,41 @@ export async function GET(request: Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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({
|
return NextResponse.json({
|
||||||
subscriptions,
|
subscriptions,
|
||||||
webhookEvents,
|
webhookEvents,
|
||||||
|
|
@ -66,10 +102,58 @@ export async function GET(request: Request) {
|
||||||
byStatus,
|
byStatus,
|
||||||
mrrPaise,
|
mrrPaise,
|
||||||
arrPaise: mrrPaise * 12,
|
arrPaise: mrrPaise * 12,
|
||||||
|
churnRate, // %
|
||||||
|
churned,
|
||||||
|
everLive,
|
||||||
},
|
},
|
||||||
|
revenueTrend,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/admin/subscriptions?action=reconcile — re-run entitlement sync.
|
/**
|
||||||
// Delegates to the existing reconcile endpoint logic via internal import.
|
* POST /api/admin/subscriptions
|
||||||
export { POST } from "../reconcile-subscriptions/route";
|
* 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import crypto from "crypto";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { getRazorpayConfig } from "@/lib/billing/config";
|
import { getRazorpayConfig } from "@/lib/billing/config";
|
||||||
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
|
||||||
|
import { sendAlert } from "@/lib/alert";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/webhooks/razorpay — THE source of truth for entitlement.
|
* POST /api/webhooks/razorpay — THE source of truth for entitlement.
|
||||||
|
|
@ -134,6 +135,10 @@ export async function POST(req: Request) {
|
||||||
const grantStatus = GRANT_EVENTS[eventType];
|
const grantStatus = GRANT_EVENTS[eventType];
|
||||||
const revokeStatus = REVOKE_EVENTS[eventType];
|
const revokeStatus = REVOKE_EVENTS[eventType];
|
||||||
|
|
||||||
|
// Family name for alert context (best-effort).
|
||||||
|
const famRows = await sql`SELECT name FROM families WHERE id = ${row.family_id} LIMIT 1`;
|
||||||
|
const familyName = (famRows[0]?.name as string) || row.family_id.slice(0, 8);
|
||||||
|
|
||||||
if (grantStatus) {
|
if (grantStatus) {
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE family_subscriptions SET
|
UPDATE family_subscriptions SET
|
||||||
|
|
@ -144,8 +149,24 @@ export async function POST(req: Request) {
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = ${row.id}
|
WHERE id = ${row.id}
|
||||||
`;
|
`;
|
||||||
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
|
// resumed→active; pending is grace (kept entitled) but a payment FAILED.
|
||||||
await grantPremium(row.family_id, grantStatus);
|
await grantPremium(row.family_id, grantStatus);
|
||||||
|
|
||||||
|
if (eventType === "subscription.pending") {
|
||||||
|
// A charge failed; Razorpay is retrying. Reach out before they churn.
|
||||||
|
await sendAlert("warn", "Payment failing (grace period)", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId, Status: "pending — retrying" },
|
||||||
|
});
|
||||||
|
} else if (eventType === "subscription.charged") {
|
||||||
|
await sendAlert("info", "💸 Subscription charged", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
} else if (eventType === "subscription.activated") {
|
||||||
|
await sendAlert("info", "🎉 New premium subscriber", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (revokeStatus) {
|
} else if (revokeStatus) {
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
const endedAt = revokeStatus === "paused" ? null : nowIso;
|
||||||
|
|
@ -159,6 +180,17 @@ export async function POST(req: Request) {
|
||||||
WHERE id = ${row.id}
|
WHERE id = ${row.id}
|
||||||
`;
|
`;
|
||||||
await revokeToFree(row.family_id, revokeStatus);
|
await revokeToFree(row.family_id, revokeStatus);
|
||||||
|
|
||||||
|
if (revokeStatus === "halted") {
|
||||||
|
// Retries exhausted — customer just churned involuntarily. Loud alert.
|
||||||
|
await sendAlert("error", "🔴 Subscription HALTED (churn)", "Payment retries exhausted — family downgraded to free.", {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
} else if (revokeStatus === "cancelled") {
|
||||||
|
await sendAlert("warn", "Subscription cancelled", undefined, {
|
||||||
|
fields: { Family: familyName, Subscription: subId },
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unhandled event type — already logged, ack it.
|
// Unhandled event type — already logged, ack it.
|
||||||
return new NextResponse("ok (unhandled event)", { status: 200 });
|
return new NextResponse("ok (unhandled event)", { status: 200 });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue