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:
parent
714909d7ee
commit
6a1aaa38a2
3 changed files with 182 additions and 0 deletions
59
src/app/api/admin/seed-plan/route.ts
Normal file
59
src/app/api/admin/seed-plan/route.ts
Normal 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
54
src/lib/billing/config.ts
Normal 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");
|
||||||
|
}
|
||||||
69
src/lib/billing/entitlements.ts
Normal file
69
src/lib/billing/entitlements.ts
Normal 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"]);
|
||||||
Loading…
Add table
Reference in a new issue