From 6a1aaa38a2bb47fd439f425840a35daf1b9d92e1 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sat, 6 Jun 2026 12:14:47 +0530 Subject: [PATCH] =?UTF-8?q?feat(billing):=20Tasks=202-3=20=E2=80=94=20conf?= =?UTF-8?q?ig,=20plan=20seed,=20entitlement=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2: - lib/billing/config.ts: reads 4 Razorpay env vars (throws at call time, not boot), premium grant constants (50GB / 6 members / 3 children / ₹199), razorpayAuthHeader() Basic-auth helper - POST /api/admin/seed-plan: admin-only idempotent upsert of the Premium plan row from env + constants (GET shows current plans). Re-runnable, no DB shell Task 3: - lib/billing/entitlements.ts: grantPremium() / revokeToFree() sync onto families.tier + max_members + max_children. Existing quota.ts guards UNCHANGED — they already read these via isPaidFamily(). revoke = limit downgrade only, data untouched (freeze-not-demote) - ENTITLED_STATUSES (active/authenticated/pending=grace) + TERMINAL_STATUSES No guard refactor needed: chosen "sync to families.tier" approach means the 3 existing guards (storage/member/child) keep working as-is. Co-Authored-By: Claude Opus 4.8 --- src/app/api/admin/seed-plan/route.ts | 59 ++++++++++++++++++++++++ src/lib/billing/config.ts | 54 ++++++++++++++++++++++ src/lib/billing/entitlements.ts | 69 ++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/app/api/admin/seed-plan/route.ts create mode 100644 src/lib/billing/config.ts create mode 100644 src/lib/billing/entitlements.ts diff --git a/src/app/api/admin/seed-plan/route.ts b/src/app/api/admin/seed-plan/route.ts new file mode 100644 index 0000000..5ea5b26 --- /dev/null +++ b/src/app/api/admin/seed-plan/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireAdmin } from "@/lib/admin-auth"; +import { + getRazorpayConfig, + PREMIUM_PLAN_NAME, + PREMIUM_PRICE_PAISE, + PREMIUM_STORAGE_BYTES, + PREMIUM_MEMBER_LIMIT, + PREMIUM_CHILD_LIMIT, +} from "@/lib/billing/config"; + +/** + * POST /api/admin/seed-plan — idempotent upsert of the Tia Premium plan row. + * + * Admin-only. Reads RAZORPAY_PLAN_ID + the premium grant constants and writes + * one subscription_plans row. Re-runnable: ON CONFLICT updates the grant values + * so you can tweak storage/member limits and re-seed without a DB shell. + * + * GET shows the current plan row(s) for verification. + */ +export async function GET(request: Request) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const rows = await sql`SELECT * FROM subscription_plans ORDER BY created_at DESC`; + return NextResponse.json({ plans: rows }); +} + +export async function POST(request: Request) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + let cfg; + try { + cfg = getRazorpayConfig(); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + const rows = await sql` + INSERT INTO subscription_plans + (razorpay_plan_id, name, price_paise, storage_bytes, member_limit, child_limit, is_active) + VALUES ( + ${cfg.planId}, ${PREMIUM_PLAN_NAME}, ${PREMIUM_PRICE_PAISE}, + ${PREMIUM_STORAGE_BYTES}, ${PREMIUM_MEMBER_LIMIT}, ${PREMIUM_CHILD_LIMIT}, true + ) + ON CONFLICT (razorpay_plan_id) DO UPDATE SET + name = EXCLUDED.name, + price_paise = EXCLUDED.price_paise, + storage_bytes = EXCLUDED.storage_bytes, + member_limit = EXCLUDED.member_limit, + child_limit = EXCLUDED.child_limit, + is_active = true + RETURNING * + `; + + return NextResponse.json({ success: true, plan: rows[0] }); +} diff --git a/src/lib/billing/config.ts b/src/lib/billing/config.ts new file mode 100644 index 0000000..a3e2505 --- /dev/null +++ b/src/lib/billing/config.ts @@ -0,0 +1,54 @@ +/** + * billing/config.ts — Razorpay configuration + premium grant constants. + * + * Reads the four required env vars. Helpers throw at call time (not module load) + * so the rest of the app still boots if billing isn't configured yet — only the + * billing routes fail loudly. + */ + +// ─── Premium grant (what an active subscription gives a family) ─────────────── +// These are written onto families.tier / max_members / max_children by the +// webhook. The existing quota.ts guards then enforce them via isPaidFamily(). +export const PREMIUM_TIER = "pro"; +export const PREMIUM_STORAGE_BYTES = 50 * 1024 ** 3; // 50 GiB +export const PREMIUM_MEMBER_LIMIT = 6; +export const PREMIUM_CHILD_LIMIT = 3; +export const PREMIUM_PRICE_PAISE = 19900; // ₹199.00 +export const PREMIUM_PLAN_NAME = "Tia Premium"; + +export const RAZORPAY_API_BASE = "https://api.razorpay.com/v1"; + +interface RazorpayConfig { + keyId: string; + keySecret: string; + webhookSecret: string; + planId: string; +} + +/** + * Returns the Razorpay config, throwing if any required var is missing. + * Call inside route handlers so a misconfiguration is a clear 500, not a + * boot crash for the whole app. + */ +export function getRazorpayConfig(): RazorpayConfig { + const keyId = process.env.RAZORPAY_KEY_ID; + const keySecret = process.env.RAZORPAY_KEY_SECRET; + const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET; + const planId = process.env.RAZORPAY_PLAN_ID; + + const missing: string[] = []; + if (!keyId) missing.push("RAZORPAY_KEY_ID"); + if (!keySecret) missing.push("RAZORPAY_KEY_SECRET"); + if (!webhookSecret) missing.push("RAZORPAY_WEBHOOK_SECRET"); + if (!planId) missing.push("RAZORPAY_PLAN_ID"); + if (missing.length) { + throw new Error(`Razorpay not configured — missing env: ${missing.join(", ")}`); + } + + return { keyId: keyId!, keySecret: keySecret!, webhookSecret: webhookSecret!, planId: planId! }; +} + +/** HTTP Basic auth header value for Razorpay REST calls. */ +export function razorpayAuthHeader(cfg: Pick): string { + return "Basic " + Buffer.from(`${cfg.keyId}:${cfg.keySecret}`).toString("base64"); +} diff --git a/src/lib/billing/entitlements.ts b/src/lib/billing/entitlements.ts new file mode 100644 index 0000000..215782d --- /dev/null +++ b/src/lib/billing/entitlements.ts @@ -0,0 +1,69 @@ +/** + * billing/entitlements.ts — apply / revoke premium on a family. + * + * Design decision (see billing handoff): instead of a second source of truth, + * the Razorpay webhook SYNCS entitlement onto families.tier / max_members / + * max_children. All existing enforcement (src/lib/quota.ts → isPaidFamily) + * keeps working unchanged. These helpers are the only writers of that sync. + * + * - grantPremium(): called when a subscription becomes active/charged. + * - revokeToFree(): called when it halts/cancels/completes/expires. + * + * Revoke is a *downgrade of limits only* — it never deletes data. The existing + * freeze-not-demote policy in quota.ts handles an over-limit family correctly + * (existing members/children/storage stay; only new additions are blocked). + */ + +import { sql } from "@/db"; +import { + PREMIUM_TIER, + PREMIUM_MEMBER_LIMIT, + PREMIUM_CHILD_LIMIT, +} from "./config"; +import { FREE_MEMBER_LIMIT } from "@/lib/quota"; + +const FREE_TIER = "free"; +const FREE_CHILD_LIMIT = 1; // matches families.max_children default + +/** + * Flip a family to premium: tier + raised member/child caps. + * subscriptionStatus mirrors the Razorpay status string for at-a-glance admin. + */ +export async function grantPremium(familyId: string, razorpayStatus: string): Promise { + await sql` + UPDATE families SET + tier = ${PREMIUM_TIER}, + subscription_status = ${razorpayStatus}, + max_members = ${PREMIUM_MEMBER_LIMIT}, + max_children = ${PREMIUM_CHILD_LIMIT}, + updated_at = NOW() + WHERE id = ${familyId} + `; +} + +/** + * Return a family to the free tier. Limits drop back to free values; data is + * untouched (freeze-not-demote). subscriptionStatus records why (halted/etc). + */ +export async function revokeToFree(familyId: string, razorpayStatus: string): Promise { + await sql` + UPDATE families SET + tier = ${FREE_TIER}, + subscription_status = ${razorpayStatus}, + max_members = ${FREE_MEMBER_LIMIT}, + max_children = ${FREE_CHILD_LIMIT}, + updated_at = NOW() + WHERE id = ${familyId} + `; +} + +/** + * Razorpay statuses that should keep a family entitled. + * - active: charged & live + * - authenticated: mandate set up (future-dated start) — treat as entitled + * - pending: a charge failed but Razorpay is retrying — GRACE, keep premium + */ +export const ENTITLED_STATUSES = new Set(["active", "authenticated", "pending"]); + +/** Razorpay statuses that end entitlement → downgrade to free. */ +export const TERMINAL_STATUSES = new Set(["halted", "cancelled", "completed", "expired"]);