Compare commits

...

5 commits

Author SHA1 Message Date
3604f7314d feat(billing): Task 8 — checkout UI (UpgradeButton + settings Plan section)
- components/UpgradeButton.tsx: client component running the full flow —
  lazy-loads checkout.js (warmed on mount), POST /create, opens Razorpay with
  subscription_id, handler POSTs to /verify for UX, terracotta theme #C26B4E.
  Shows "activating shortly" success state; never touches entitlement.
- settings page: dedicated #upgrade Plan section (anchor target for all the
  existing /settings#upgrade CTAs from StorageMeter/MemberLimitBanner/etc):
    free  -> pitch + UpgradeButton
    pro   -> shows grants + Cancel subscription (cancel_at_cycle_end)
  Replaced the old dead "Upgrade" button in Family section with #upgrade anchor.

Completes the 8-task build. Live acceptance gates (create returns sub_id,
webhook flips tier, full test-mode checkout) run after Task 0 (dashboard +
env vars) per the handoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:23:12 +05:30
99b5543eb9 feat(billing): Tasks 6-7 — verify + cancel routes
Task 6 — POST /api/subscriptions/verify (UX only):
- HMAC of payment_id|subscription_id (SUBSCRIPTION order, not order_id flavour)
- uses the subscription_id WE stored for the family, not the client's
- grants NOTHING — webhook is source of truth; returns "activating shortly"
- 200 valid / 400 tampered

Task 7 — POST /api/subscriptions/cancel:
- family_id from session (IDOR-safe), cancels family's own live sub only
- RZP cancel with cancel_at_cycle_end=1 — keep premium until period end
- actual downgrade happens later via subscription.cancelled webhook

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:20:01 +05:30
2bd45bd4fd feat(billing): Tasks 4-5 — create-subscription + webhook routes
Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client

Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
    authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
    halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)

middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).

Verified locally: HMAC valid/tampered, unix->date, event routing maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:18:00 +05:30
6a1aaa38a2 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>
2026-06-06 12:14:47 +05:30
714909d7ee feat(billing): Task 1 — Razorpay subscription schema + migration
Three tables + lifecycle enum for Razorpay subscriptions:
- subscription_plans: maps razorpay_plan_id -> grants (price/storage/member/child)
- family_subscriptions: per-family sub state mirrored from Razorpay
- razorpay_webhook_events: append-only log, razorpay_event_id = idempotency key
- subscription_status_enum: mirrors Razorpay's lifecycle states exactly
- partial unique index family_live_sub_idx: at most one non-terminal sub/family

Notes:
- Raw-SQL + Drizzle schema both added (repo uses raw sql`` at runtime;
  schema file keeps drizzle-kit + type inference working)
- child_limit added to plan (not in original handoff) since premium lifts the
  free 1-baby cap to 3 per product decision
- Migration 0012 idempotent; also added to debug-migration hot-apply steps
- when=1780100000000 (> last entry, per journal drift rule)

Entitlement will sync onto families.tier (Task 3/5) so existing quota.ts
guards stay unchanged — these tables are audit + Razorpay state mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 12:10:53 +05:30
15 changed files with 954 additions and 1 deletions

56
drizzle/0012_billing.sql Normal file
View file

@ -0,0 +1,56 @@
-- Billing / Razorpay subscriptions.
-- Idempotent: safe to re-run (used by debug-migration hot-apply too).
-- Subscription lifecycle enum (mirrors Razorpay states).
DO $$ BEGIN
CREATE TYPE subscription_status_enum AS ENUM (
'created','authenticated','active','pending',
'halted','cancelled','completed','expired','paused'
);
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Plans: maps a Razorpay plan_id -> what it grants.
CREATE TABLE IF NOT EXISTS subscription_plans (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
razorpay_plan_id text NOT NULL UNIQUE,
name text NOT NULL,
price_paise integer NOT NULL,
storage_bytes bigint NOT NULL,
member_limit integer NOT NULL,
child_limit integer NOT NULL DEFAULT 3,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- One subscription row per family (append on upgrade).
CREATE TABLE IF NOT EXISTS family_subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE,
plan_id uuid NOT NULL REFERENCES subscription_plans(id),
razorpay_subscription_id text NOT NULL UNIQUE,
razorpay_customer_id text,
status subscription_status_enum NOT NULL DEFAULT 'created',
current_start timestamptz,
current_end timestamptz,
cancelled_at timestamptz,
ended_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx
ON family_subscriptions (family_id);
-- At most one LIVE (non-terminal) subscription per family.
CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx
ON family_subscriptions (family_id)
WHERE status IN ('created','authenticated','active','pending','halted');
-- Append-only webhook log. razorpay_event_id = idempotency key.
CREATE TABLE IF NOT EXISTS razorpay_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
razorpay_event_id text NOT NULL UNIQUE,
event_type text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);

View file

@ -85,6 +85,13 @@
"when": 1780000000000, "when": 1780000000000,
"tag": "0011_user_phone", "tag": "0011_user_phone",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780100000000,
"tag": "0012_billing",
"breakpoints": true
} }
] ]
} }

View file

@ -7,6 +7,7 @@ import { useTheme } from "@/app/ThemeProvider";
import { useFamily } from "@/app/FamilyProvider"; import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, Select, Badge } from "@/components/ui"; import { Button, Card, Input, Select, Badge } from "@/components/ui";
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter"; import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
import { UpgradeButton } from "@/components/UpgradeButton";
import { fmtDate } from "@/lib/date-ist"; import { fmtDate } from "@/lib/date-ist";
interface Member { interface Member {
@ -45,6 +46,21 @@ export default function SettingsPage() {
const [pedEditing, setPedEditing] = useState(false); const [pedEditing, setPedEditing] = useState(false);
const [pedSaved, setPedSaved] = useState(false); const [pedSaved, setPedSaved] = useState(false);
const [pedError, setPedError] = useState(""); const [pedError, setPedError] = useState("");
const [cancelling, setCancelling] = useState(false);
const [cancelMsg, setCancelMsg] = useState("");
const handleCancelSubscription = async () => {
if (!window.confirm("Cancel your subscription? You'll keep premium until the end of your current billing period.")) return;
setCancelling(true);
try {
const res = await fetch("/api/subscriptions/cancel", { method: "POST" });
const data = await res.json();
setCancelMsg(res.ok ? data.message : (data.error || "Could not cancel."));
} catch {
setCancelMsg("Could not reach the server. Try again.");
}
setCancelling(false);
};
// Check if can invite more members (client-side pre-check; server enforces) // Check if can invite more members (client-side pre-check; server enforces)
const canInvite = tier === "pro" || memberCount < 2; const canInvite = tier === "pro" || memberCount < 2;
@ -192,6 +208,45 @@ export default function SettingsPage() {
</div> </div>
<div className="px-4 space-y-3"> <div className="px-4 space-y-3">
{/* Plan / Upgrade — anchor target for all "upgrade" CTAs across the app */}
<div id="upgrade" className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm scroll-mt-20">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-gray-900 dark:text-white">
{tier === "pro" ? "✨ Tia Premium" : "Your Plan"}
</p>
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${tier === "pro" ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700"}`}>
{tier === "pro" ? "Premium" : "Free"}
</span>
</div>
{tier === "pro" ? (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
50 GB storage · up to 6 members · up to 3 baby profiles
</p>
{cancelMsg ? (
<p className="text-sm text-amber-600 dark:text-amber-400">{cancelMsg}</p>
) : (
<button
onClick={handleCancelSubscription}
disabled={cancelling}
className="text-sm text-gray-400 hover:text-red-500 disabled:opacity-50 underline"
>
{cancelling ? "Cancelling…" : "Cancel subscription"}
</button>
)}
</>
) : (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Upgrade for <strong>50 GB</strong> media storage, up to <strong>6 family members</strong>,
and <strong>3 baby profiles</strong>. 199/month, cancel anytime.
</p>
<UpgradeButton />
</>
)}
</div>
{/* My Profile Page */} {/* My Profile Page */}
<a href="/settings/profile" <a href="/settings/profile"
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3"> className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
@ -244,7 +299,9 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{tier === "free" && ( {tier === "free" && (
<Button size="sm">Upgrade</Button> <a href="#upgrade">
<Button size="sm">Upgrade</Button>
</a>
)} )}
</div> </div>

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

View file

@ -103,6 +103,13 @@ export async function POST(req: Request) {
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`, `CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
// 0011 — optional user phone number // 0011 — optional user phone number
`ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`, `ALTER TABLE users ADD COLUMN IF NOT EXISTS phone text`,
// 0012 — billing / Razorpay subscriptions
`DO $$ BEGIN CREATE TYPE subscription_status_enum AS ENUM ('created','authenticated','active','pending','halted','cancelled','completed','expired','paused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
`CREATE TABLE IF NOT EXISTS subscription_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_plan_id text NOT NULL UNIQUE, name text NOT NULL, price_paise integer NOT NULL, storage_bytes bigint NOT NULL, member_limit integer NOT NULL, child_limit integer NOT NULL DEFAULT 3, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now())`,
`CREATE TABLE IF NOT EXISTS family_subscriptions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), family_id uuid NOT NULL REFERENCES families(id) ON DELETE CASCADE, plan_id uuid NOT NULL REFERENCES subscription_plans(id), razorpay_subscription_id text NOT NULL UNIQUE, razorpay_customer_id text, status subscription_status_enum NOT NULL DEFAULT 'created', current_start timestamptz, current_end timestamptz, cancelled_at timestamptz, ended_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now())`,
`CREATE INDEX IF NOT EXISTS family_subscriptions_family_idx ON family_subscriptions (family_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS family_live_sub_idx ON family_subscriptions (family_id) WHERE status IN ('created','authenticated','active','pending','halted')`,
`CREATE TABLE IF NOT EXISTS razorpay_webhook_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), razorpay_event_id text NOT NULL UNIQUE, event_type text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now())`,
]; ];
const results: string[] = []; const results: string[] = [];

View file

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* POST /api/subscriptions/cancel
*
* Initiates cancellation at Razorpay with cancel_at_cycle_end=1 the family
* keeps premium until the paid period ends. The actual state change happens
* later via the subscription.cancelled webhook; this route only initiates.
*
* family_id from session (IDOR-safe). Cancels the family's own live sub only.
*/
export async function POST() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Find the family's live subscription.
const rows = await sql`
SELECT razorpay_subscription_id, status FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending')
ORDER BY created_at DESC
LIMIT 1
`;
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
if (!subId) {
return NextResponse.json({ error: "No active subscription to cancel" }, { status: 404 });
}
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 }), // keep premium until period end
});
const data = await res.json();
if (!res.ok) {
console.error("Razorpay cancel failed:", data);
return NextResponse.json(
{ error: data?.error?.description || "Failed to cancel subscription" },
{ status: 502 },
);
}
} catch (e) {
console.error("Razorpay cancel error:", e);
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
}
return NextResponse.json({
success: true,
message: "Subscription will cancel at the end of your current billing period. You keep premium until then.",
});
}

View file

@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig, razorpayAuthHeader, RAZORPAY_API_BASE } from "@/lib/billing/config";
/**
* POST /api/subscriptions/create
*
* Creates a Razorpay subscription for the caller's family and returns the
* subscription_id + key_id for Razorpay Checkout. Grants NOTHING entitlement
* is applied only by the webhook (subscription.charged/activated).
*
* Security:
* - family_id comes from the session (requireFamily), never from the request
* body so a user can only create a sub for their own family (IDOR-safe).
* - key_secret never leaves the server; only key_id is returned.
*/
export async function POST() {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const userId = auth.session!.userId;
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Resolve the plan row (must be seeded — see /api/admin/seed-plan).
const planRows = await sql`
SELECT id, razorpay_plan_id FROM subscription_plans
WHERE razorpay_plan_id = ${cfg.planId} AND is_active = true
LIMIT 1
`;
if (!planRows[0]) {
return NextResponse.json(
{ error: "Plan not seeded. Run POST /api/admin/seed-plan first." },
{ status: 500 },
);
}
const planRowId = planRows[0].id as string;
// Reject if a live (non-terminal) subscription already exists for this family.
// The partial unique index also enforces this at the DB level.
const live = await sql`
SELECT id, razorpay_subscription_id, status FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending','halted')
LIMIT 1
`;
if (live[0]) {
return NextResponse.json(
{ error: "An active subscription already exists for this family.", status: live[0].status },
{ status: 409 },
);
}
// Create the subscription at Razorpay.
let rzpSub: { id?: string; status?: string; error?: { description?: string } };
try {
const res = await fetch(`${RAZORPAY_API_BASE}/subscriptions`, {
method: "POST",
headers: {
Authorization: razorpayAuthHeader(cfg),
"Content-Type": "application/json",
},
body: JSON.stringify({
plan_id: cfg.planId,
total_count: 120, // ~10 yrs of monthly cycles; avoids auto-complete
customer_notify: 1,
quantity: 1,
notes: { family_id: familyId, user_id: userId }, // correlation backup for webhook
}),
});
rzpSub = await res.json();
if (!res.ok || !rzpSub.id) {
console.error("Razorpay create sub failed:", rzpSub);
return NextResponse.json(
{ error: rzpSub?.error?.description || "Failed to create subscription" },
{ status: 502 },
);
}
} catch (e) {
console.error("Razorpay create sub error:", e);
return NextResponse.json({ error: "Failed to reach payment provider" }, { status: 502 });
}
// Record our side as 'created'. The webhook drives all later state.
try {
await sql`
INSERT INTO family_subscriptions
(family_id, plan_id, razorpay_subscription_id, status)
VALUES (${familyId}, ${planRowId}, ${rzpSub.id}, 'created')
`;
} catch (e) {
// Unique index race (double-click) — treat as the existing-sub case.
console.error("Insert family_subscriptions failed:", e);
return NextResponse.json(
{ error: "Subscription already in progress for this family." },
{ status: 409 },
);
}
return NextResponse.json({ subscriptionId: rzpSub.id, keyId: cfg.keyId });
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { getRazorpayConfig } from "@/lib/billing/config";
/**
* POST /api/subscriptions/verify UX feedback ONLY.
*
* Called by the Checkout success handler so we can show "you're in!". It verifies
* the checkout signature but GRANTS NOTHING entitlement is applied solely by
* the webhook (/api/webhooks/razorpay). This route just confirms the handshake
* looks authentic so the UI can show an optimistic "activating shortly" screen.
*
* Subscription signature order is payment_id|subscription_id (NOT the order_id
* flavour). We HMAC against the subscription_id WE stored, not whatever the
* client sends, so a client can't verify against an arbitrary subscription.
*/
export async function POST(req: Request) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
let body: { razorpay_payment_id?: string; razorpay_signature?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const paymentId = body.razorpay_payment_id;
const signature = body.razorpay_signature;
if (!paymentId || !signature) {
return NextResponse.json({ error: "Missing payment id or signature" }, { status: 400 });
}
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
// Use the subscription_id we recorded for this family — never the client's.
const rows = await sql`
SELECT razorpay_subscription_id FROM family_subscriptions
WHERE family_id = ${familyId}
AND status IN ('created','authenticated','active','pending')
ORDER BY created_at DESC
LIMIT 1
`;
const subId = rows[0]?.razorpay_subscription_id as string | undefined;
if (!subId) {
return NextResponse.json({ error: "No pending subscription found" }, { status: 404 });
}
// SUBSCRIPTION order: payment_id|subscription_id
const expected = crypto
.createHmac("sha256", cfg.keySecret)
.update(`${paymentId}|${subId}`)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const valid = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
if (!valid) {
return NextResponse.json({ error: "Signature verification failed" }, { status: 400 });
}
// Looks authentic. Do NOT grant here — the webhook is the source of truth.
return NextResponse.json({
success: true,
message: "Payment received — your premium is activating shortly.",
});
}

View file

@ -0,0 +1,165 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { sql } from "@/db";
import { getRazorpayConfig } from "@/lib/billing/config";
import { grantPremium, revokeToFree } from "@/lib/billing/entitlements";
/**
* POST /api/webhooks/razorpay THE source of truth for entitlement.
*
* Razorpay calls this on every subscription lifecycle change. We:
* 1. Verify the HMAC signature (timing-safe) over the RAW body.
* 2. Idempotency: unique-insert on x-razorpay-event-id. Duplicate 200 & bail.
* 3. Update family_subscriptions by razorpay_subscription_id.
* 4. Sync entitlement onto families.tier (grant/revoke) so the existing
* quota.ts guards enforce the new limits.
*
* Status codes (Razorpay retries on non-2xx):
* 400 bad signature (do NOT retry)
* 200 processed OK, or duplicate (already processed)
* 500 processing error (Razorpay WILL retry that's what we want)
*/
// Razorpay event → how it affects entitlement.
const GRANT_EVENTS: Record<string, string> = {
"subscription.authenticated": "authenticated",
"subscription.activated": "active",
"subscription.charged": "active",
"subscription.resumed": "active",
"subscription.pending": "pending", // grace — keep entitled
};
const REVOKE_EVENTS: Record<string, string> = {
"subscription.halted": "halted",
"subscription.cancelled": "cancelled",
"subscription.completed": "completed",
"subscription.expired": "expired",
"subscription.paused": "paused",
};
function unixToDate(v: unknown): Date | null {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null;
}
export async function POST(req: Request) {
// RAW body — never req.json() here; the signature is over the exact bytes.
const rawBody = await req.text();
const signature = req.headers.get("x-razorpay-signature") ?? "";
const eventId = req.headers.get("x-razorpay-event-id") ?? "";
let cfg;
try {
cfg = getRazorpayConfig();
} catch (e) {
console.error("Razorpay webhook: not configured", e);
return new NextResponse("not configured", { status: 500 });
}
// 1. Verify signature (timing-safe).
const expected = crypto
.createHmac("sha256", cfg.webhookSecret)
.update(rawBody)
.digest("hex");
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
const validSig = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
if (!validSig) {
return new NextResponse("bad signature", { status: 400 });
}
if (!eventId) {
// No event id to dedupe on — refuse so Razorpay retries with headers.
return new NextResponse("missing event id", { status: 400 });
}
let event: {
event?: string;
payload?: { subscription?: { entity?: Record<string, unknown> } };
};
try {
event = JSON.parse(rawBody);
} catch {
return new NextResponse("bad json", { status: 400 });
}
const eventType = event.event ?? "unknown";
// 2. Idempotency — unique insert on event id. If it already exists, bail 200.
try {
const inserted = await sql`
INSERT INTO razorpay_webhook_events (razorpay_event_id, event_type, payload)
VALUES (${eventId}, ${eventType}, ${rawBody}::jsonb)
ON CONFLICT (razorpay_event_id) DO NOTHING
RETURNING id
`;
if (inserted.length === 0) {
return new NextResponse("ok (duplicate)", { status: 200 });
}
} catch (e) {
console.error("Razorpay webhook: log insert failed", e);
return new NextResponse("log error", { status: 500 }); // retry
}
// 3 + 4. Process the event. Errors here → 500 so Razorpay retries.
try {
const sub = event.payload?.subscription?.entity;
const subId = sub?.id as string | undefined;
// Events without a subscription entity (e.g. payment.* if ever subscribed)
// are logged above; nothing to sync.
if (!subId) return new NextResponse("ok (no subscription entity)", { status: 200 });
// Find our row for this Razorpay subscription.
const rows = await sql`
SELECT id, family_id FROM family_subscriptions
WHERE razorpay_subscription_id = ${subId}
LIMIT 1
`;
const row = rows[0] as { id: string; family_id: string } | undefined;
if (!row) {
// Unknown subscription (e.g. created outside this app). Logged; ack so
// Razorpay stops retrying — there's nothing for us to update.
console.warn("Razorpay webhook: no family_subscriptions row for", subId);
return new NextResponse("ok (unknown subscription)", { status: 200 });
}
const currentStart = unixToDate(sub?.current_start);
const currentEnd = unixToDate(sub?.current_end);
const customerId = (sub?.customer_id as string) ?? null;
const grantStatus = GRANT_EVENTS[eventType];
const revokeStatus = REVOKE_EVENTS[eventType];
if (grantStatus) {
await sql`
UPDATE family_subscriptions SET
status = ${grantStatus}::subscription_status_enum,
razorpay_customer_id = COALESCE(${customerId}, razorpay_customer_id),
current_start = COALESCE(${currentStart}, current_start),
current_end = COALESCE(${currentEnd}, current_end),
updated_at = NOW()
WHERE id = ${row.id}
`;
// paused is a GRANT_EVENTS key? No — paused is in REVOKE. resumed→active.
await grantPremium(row.family_id, grantStatus);
} else if (revokeStatus) {
const endedAt = revokeStatus === "paused" ? null : new Date();
await sql`
UPDATE family_subscriptions SET
status = ${revokeStatus}::subscription_status_enum,
cancelled_at = ${revokeStatus === "cancelled" ? new Date() : null},
ended_at = COALESCE(${endedAt}, ended_at),
updated_at = NOW()
WHERE id = ${row.id}
`;
await revokeToFree(row.family_id, revokeStatus);
} else {
// Unhandled event type — already logged, ack it.
return new NextResponse("ok (unhandled event)", { status: 200 });
}
return new NextResponse("ok", { status: 200 });
} catch (e) {
console.error("Razorpay webhook: processing error", e);
return new NextResponse("processing error", { status: 500 }); // retry
}
}

View file

@ -0,0 +1,134 @@
"use client";
import { useEffect, useState } from "react";
// Minimal shape of the Razorpay Checkout global we use.
interface RazorpayOptions {
key: string;
subscription_id: string;
name: string;
description: string;
handler: (resp: RazorpayResponse) => void;
prefill?: { email?: string; contact?: string };
theme?: { color?: string };
modal?: { ondismiss?: () => void };
}
interface RazorpayResponse {
razorpay_payment_id: string;
razorpay_subscription_id: string;
razorpay_signature: string;
}
interface RazorpayInstance {
open: () => void;
on: (event: string, cb: (resp: unknown) => void) => void;
}
declare global {
interface Window {
Razorpay?: new (options: RazorpayOptions) => RazorpayInstance;
}
}
const CHECKOUT_SRC = "https://checkout.razorpay.com/v1/checkout.js";
function loadCheckout(): Promise<boolean> {
return new Promise((resolve) => {
if (window.Razorpay) return resolve(true);
const existing = document.querySelector(`script[src="${CHECKOUT_SRC}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(true));
existing.addEventListener("error", () => resolve(false));
return;
}
const s = document.createElement("script");
s.src = CHECKOUT_SRC;
s.onload = () => resolve(true);
s.onerror = () => resolve(false);
document.body.appendChild(s);
});
}
export function UpgradeButton({
email,
className,
label = "Upgrade to Premium — ₹199/month",
onActivating,
}: {
email?: string;
className?: string;
label?: string;
onActivating?: () => void;
}) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
// Warm the checkout script so the first click is instant.
useEffect(() => { loadCheckout(); }, []);
const handleUpgrade = async () => {
setLoading(true);
setError(null);
try {
const ok = await loadCheckout();
if (!ok || !window.Razorpay) throw new Error("Could not load payment checkout. Check your connection.");
// 1. Create the subscription server-side.
const createRes = await fetch("/api/subscriptions/create", { method: "POST" });
const createData = await createRes.json();
if (!createRes.ok) throw new Error(createData.error || "Could not start checkout.");
const { subscriptionId, keyId } = createData as { subscriptionId: string; keyId: string };
// 2. Open Razorpay Checkout for the mandate.
const rzp = new window.Razorpay({
key: keyId, // id only — never the secret
subscription_id: subscriptionId,
name: "Tia",
description: "Tia Premium — ₹199/month",
prefill: email ? { email } : undefined,
theme: { color: "#C26B4E" }, // heirloom terracotta
handler: async (resp) => {
// 3. Verify for UX feedback only — entitlement comes via webhook.
try {
await fetch("/api/subscriptions/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(resp),
});
} catch { /* non-fatal — webhook still grants */ }
setDone(true);
onActivating?.();
},
modal: { ondismiss: () => setLoading(false) },
});
rzp.open();
} catch (e) {
setError(e instanceof Error ? e.message : "Something went wrong.");
setLoading(false);
}
};
if (done) {
return (
<div className="rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/40 px-4 py-3 text-sm text-green-700 dark:text-green-300">
🎉 Payment received! Your premium is activating itll be live within a minute.
</div>
);
}
return (
<div className="space-y-2">
<button
onClick={handleUpgrade}
disabled={loading}
className={
className ??
"w-full bg-rose-500 hover:bg-rose-600 disabled:opacity-60 text-white font-semibold px-5 py-3 rounded-xl text-sm transition-colors active:scale-[0.98]"
}
>
{loading ? "Opening checkout…" : label}
</button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}

90
src/db/schema/billing.ts Normal file
View file

@ -0,0 +1,90 @@
import {
pgTable,
text,
timestamp,
uuid,
pgEnum,
integer,
bigint,
boolean,
jsonb,
uniqueIndex,
index,
} from "drizzle-orm/pg-core";
import { families } from "./family";
// ---------------------------------------------------------------------------
// Billing schema — Razorpay subscriptions.
//
// Design:
// - Mirror Razorpay's lifecycle states exactly (don't invent a vocabulary).
// - razorpay_webhook_events is APPEND-ONLY; razorpay_event_id is the
// idempotency key (webhooks WILL be redelivered).
// - Entitlement is synced onto families.tier by the webhook — the existing
// quota.ts guards (isPaidFamily) keep working unchanged. These tables are
// the audit trail + Razorpay state mirror, not a second enforcement path.
// ---------------------------------------------------------------------------
// Mirror Razorpay's subscription lifecycle states exactly.
export const subscriptionStatusEnum = pgEnum("subscription_status_enum", [
"created", // sub created, not yet authenticated
"authenticated", // mandate set up (future-dated starts sit here)
"active", // charged & live — THE entitled state
"pending", // a charge failed; Razorpay is retrying (still entitled = grace)
"halted", // retries exhausted — downgrade
"cancelled", // user/we cancelled
"completed", // all total_count cycles done
"expired",
"paused",
]);
// Maps a Razorpay plan_id -> what it grants. Source of grant values.
export const subscriptionPlans = pgTable("subscription_plans", {
id: uuid("id").primaryKey().defaultRandom(),
razorpayPlanId: text("razorpay_plan_id").notNull().unique(), // plan_xxx from RZP
name: text("name").notNull(), // "Tia Premium"
pricePaise: integer("price_paise").notNull(), // 19900 = ₹199
storageBytes: bigint("storage_bytes", { mode: "number" }).notNull(), // the grant
memberLimit: integer("member_limit").notNull(), // the grant
childLimit: integer("child_limit").notNull().default(3), // the grant
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
// One non-terminal subscription per family (enforced by partial unique index).
export const familySubscriptions = pgTable(
"family_subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
familyId: uuid("family_id")
.notNull()
.references(() => families.id, { onDelete: "cascade" }),
planId: uuid("plan_id")
.notNull()
.references(() => subscriptionPlans.id),
razorpaySubscriptionId: text("razorpay_subscription_id").notNull().unique(),
razorpayCustomerId: text("razorpay_customer_id"), // captured from webhook
status: subscriptionStatusEnum("status").notNull().default("created"),
currentStart: timestamp("current_start", { withTimezone: true }),
currentEnd: timestamp("current_end", { withTimezone: true }), // entitlement valid until
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [index("family_subscriptions_family_idx").on(table.familyId)]
);
// Append-only. razorpay_event_id is the idempotency key.
export const razorpayWebhookEvents = pgTable("razorpay_webhook_events", {
id: uuid("id").primaryKey().defaultRandom(),
razorpayEventId: text("razorpay_event_id").notNull().unique(), // x-razorpay-event-id header
eventType: text("event_type").notNull(),
payload: jsonb("payload").notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
});
// Type exports
export type SubscriptionPlan = typeof subscriptionPlans.$inferSelect;
export type FamilySubscription = typeof familySubscriptions.$inferSelect;
export type RazorpayWebhookEvent = typeof razorpayWebhookEvents.$inferSelect;

View file

@ -16,3 +16,4 @@ export * from "./support";
export * from "./ai"; export * from "./ai";
export * from "./affiliate"; export * from "./affiliate";
export * from "./wardrobe"; export * from "./wardrobe";
export * from "./billing";

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

View file

@ -52,6 +52,9 @@ const protectedApiRoutes = [
"/api/chat", "/api/chat",
"/api/history", "/api/history",
"/api/family/members", "/api/family/members",
"/api/subscriptions",
// NOTE: /api/webhooks/razorpay is intentionally NOT here — Razorpay calls it
// with no session cookie; it authenticates via HMAC signature instead.
]; ];
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {