Compare commits
5 commits
87e795c837
...
3604f7314d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3604f7314d | |||
| 99b5543eb9 | |||
| 2bd45bd4fd | |||
| 6a1aaa38a2 | |||
| 714909d7ee |
15 changed files with 954 additions and 1 deletions
56
drizzle/0012_billing.sql
Normal file
56
drizzle/0012_billing.sql
Normal 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()
|
||||
);
|
||||
|
|
@ -85,6 +85,13 @@
|
|||
"when": 1780000000000,
|
||||
"tag": "0011_user_phone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1780100000000,
|
||||
"tag": "0012_billing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { useTheme } from "@/app/ThemeProvider";
|
|||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||||
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
interface Member {
|
||||
|
|
@ -45,6 +46,21 @@ export default function SettingsPage() {
|
|||
const [pedEditing, setPedEditing] = useState(false);
|
||||
const [pedSaved, setPedSaved] = useState(false);
|
||||
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)
|
||||
const canInvite = tier === "pro" || memberCount < 2;
|
||||
|
|
@ -192,6 +208,45 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<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 */}
|
||||
<a href="/settings/profile"
|
||||
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>
|
||||
{tier === "free" && (
|
||||
<Button size="sm">Upgrade</Button>
|
||||
<a href="#upgrade">
|
||||
<Button size="sm">Upgrade</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
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] });
|
||||
}
|
||||
|
|
@ -103,6 +103,13 @@ export async function POST(req: Request) {
|
|||
`CREATE INDEX IF NOT EXISTS idx_error_events_message ON error_events (message)`,
|
||||
// 0011 — optional user phone number
|
||||
`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[] = [];
|
||||
|
|
|
|||
67
src/app/api/subscriptions/cancel/route.ts
Normal file
67
src/app/api/subscriptions/cancel/route.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
108
src/app/api/subscriptions/create/route.ts
Normal file
108
src/app/api/subscriptions/create/route.ts
Normal 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 });
|
||||
}
|
||||
76
src/app/api/subscriptions/verify/route.ts
Normal file
76
src/app/api/subscriptions/verify/route.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
165
src/app/api/webhooks/razorpay/route.ts
Normal file
165
src/app/api/webhooks/razorpay/route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
134
src/components/UpgradeButton.tsx
Normal file
134
src/components/UpgradeButton.tsx
Normal 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 — it’ll 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
90
src/db/schema/billing.ts
Normal 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;
|
||||
|
|
@ -16,3 +16,4 @@ export * from "./support";
|
|||
export * from "./ai";
|
||||
export * from "./affiliate";
|
||||
export * from "./wardrobe";
|
||||
export * from "./billing";
|
||||
|
|
|
|||
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"]);
|
||||
|
|
@ -52,6 +52,9 @@ const protectedApiRoutes = [
|
|||
"/api/chat",
|
||||
"/api/history",
|
||||
"/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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue