feat(billing): Tasks 2-3 — config, plan seed, entitlement sync

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 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-06-06 12:14:47 +05:30
parent 714909d7ee
commit 6a1aaa38a2
3 changed files with 182 additions and 0 deletions

View file

@ -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] });
}

54
src/lib/billing/config.ts Normal file
View file

@ -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<RazorpayConfig, "keyId" | "keySecret">): string {
return "Basic " + Buffer.from(`${cfg.keyId}:${cfg.keySecret}`).toString("base64");
}

View file

@ -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<void> {
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<void> {
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"]);