diff --git a/src/app/admin/families/page.tsx b/src/app/admin/families/page.tsx index c381348..00d8b7f 100644 --- a/src/app/admin/families/page.tsx +++ b/src/app/admin/families/page.tsx @@ -170,12 +170,9 @@ export default function AdminFamilies() { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ - familyId, - tier: newTier, - maxChildren: newTier === "pro" ? 10 : 1, - maxMembers: newTier === "pro" ? 10 : 2, - }), + // Send only the tier — the server applies the real grant/revoke + // (50GB / 6 members / 3 children for pro; free defaults otherwise). + body: JSON.stringify({ familyId, tier: newTier }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Failed to update tier"); diff --git a/src/app/api/admin/families/route.ts b/src/app/api/admin/families/route.ts index fd92340..34e8218 100644 --- a/src/app/api/admin/families/route.ts +++ b/src/app/api/admin/families/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; import { requireAdmin } from "@/lib/admin-auth"; +import { grantPremium, revokeToFree } from "@/lib/billing/entitlements"; // GET all families with members export async function GET(request: Request) { @@ -118,14 +119,28 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "familyId required" }, { status: 400 }); } - await sql` - UPDATE families - SET tier = COALESCE(${tier}, tier), - max_children = COALESCE(${maxChildren}, max_children), - max_members = COALESCE(${maxMembers}, max_members) - WHERE id = ${familyId} - `; + // 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` + UPDATE families + SET max_children = COALESCE(${maxChildren ?? null}, max_children), + max_members = COALESCE(${maxMembers ?? null}, max_members), + updated_at = NOW() + 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 }); } catch (error) { console.error("Admin families error:", error); diff --git a/src/app/api/admin/health/route.ts b/src/app/api/admin/health/route.ts index 6aea23b..350c999 100644 --- a/src/app/api/admin/health/route.ts +++ b/src/app/api/admin/health/route.ts @@ -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("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("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") ? "down" diff --git a/src/app/api/webhooks/razorpay/route.ts b/src/app/api/webhooks/razorpay/route.ts index 6cc2186..14b0a7f 100644 --- a/src/app/api/webhooks/razorpay/route.ts +++ b/src/app/api/webhooks/razorpay/route.ts @@ -3,6 +3,7 @@ import crypto from "crypto"; import { sql } from "@/db"; import { getRazorpayConfig } from "@/lib/billing/config"; import { grantPremium, revokeToFree } from "@/lib/billing/entitlements"; +import { sendAlert } from "@/lib/alert"; /** * 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 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) { await sql` UPDATE family_subscriptions SET @@ -144,8 +149,24 @@ export async function POST(req: Request) { updated_at = NOW() 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); + + 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) { const nowIso = new Date().toISOString(); const endedAt = revokeStatus === "paused" ? null : nowIso; @@ -159,6 +180,17 @@ export async function POST(req: Request) { WHERE id = ${row.id} `; 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 { // Unhandled event type — already logged, ack it. return new NextResponse("ok (unhandled event)", { status: 200 });