feat: subscription tiers, branch-with-manager flow, platform command center

This commit is contained in:
EOH Developer
2026-10-05 10:20:04 -07:00
parent 04e3051eb6
commit fa375bde91
19 changed files with 958 additions and 75 deletions
+2 -1
View File
@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"seed": "tsx scripts/seed.ts"
"seed": "tsx scripts/seed.ts",
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts"
},
"dependencies": {
"axios": "^1.7.7",
+81
View File
@@ -0,0 +1,81 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
import mongoose from "mongoose";
import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User";
import { Branch } from "../src/lib/server/models/Branch";
import { Product, BranchPricing } from "../src/lib/server/models/Product";
import { Transaction } from "../src/lib/server/models/Transaction";
import { DailyInventory } from "../src/lib/server/models/DailyInventory";
import { Expense } from "../src/lib/server/models/Expense";
import { Purchase } from "../src/lib/server/models/Purchase";
import { Business } from "../src/lib/server/models/Business";
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
async function migrate() {
await connectDb();
const owner = await User.findOne({ role: "SUPER_ADMIN" }).sort({ createdAt: 1 });
if (!owner) {
console.error(
"No SUPER_ADMIN found. Create one via the /signup page, or run `npm run seed` first, then re-run this migration."
);
await mongoose.disconnect();
process.exit(1);
}
const businessName = process.env.SEED_BUSINESS_NAME || "My Business";
let business = await Business.findOne({ ownerId: owner._id });
if (!business) {
business = await Business.create({
name: businessName,
ownerId: owner._id,
tier: DEFAULT_TIER,
subscriptionStatus: "TRIALING",
trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000),
});
console.log(`Created business "${business.name}" (${business._id}) owned by ${owner.email}`);
} else {
const patch: Record<string, unknown> = {};
if (!business.tier) patch.tier = DEFAULT_TIER;
if (!business.subscriptionStatus) patch.subscriptionStatus = "TRIALING";
if (!business.trialEndsAt) patch.trialEndsAt = new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000);
if (Object.keys(patch).length > 0) {
await Business.updateOne({ _id: business._id }, { $set: patch });
console.log(`Patched subscription fields on existing business "${business.name}"`);
}
console.log(`Reusing existing business "${business.name}" (${business._id})`);
}
const businessId = business._id;
const filter = { businessId: { $exists: false } } as Record<string, unknown>;
const set = { $set: { businessId } };
const collections: [string, mongoose.Model<any>][] = [
["users", User],
["branches", Branch],
["products", Product],
["branchpricings", BranchPricing],
["transactions", Transaction],
["dailyinventories", DailyInventory],
["expenses", Expense],
["purchases", Purchase],
];
for (const [label, Model] of collections) {
const res = await Model.updateMany(filter, set);
console.log(` ${label}: updated ${res.modifiedCount} document(s)`);
}
console.log("Migration complete. All legacy data now belongs to:", business.name);
await mongoose.disconnect();
process.exit(0);
}
migrate().catch((err) => {
console.error("migration failed:", err.message);
process.exit(1);
});
+113 -1
View File
@@ -21,6 +21,13 @@ export default function BranchesPage() {
const [branchName, setBranchName] = useState("");
const [locationCity, setLocationCity] = useState("");
// Optional: create the branch manager's login in the same step.
const [withManager, setWithManager] = useState(true);
const [mgrName, setMgrName] = useState("");
const [mgrEmail, setMgrEmail] = useState("");
const [mgrPhone, setMgrPhone] = useState("");
const [mgrPassword, setMgrPassword] = useState("");
useEffect(() => {
(async () => {
try {
@@ -58,15 +65,45 @@ export default function BranchesPage() {
return;
}
const body: Record<string, unknown> = {
branchName: branchName.trim(),
locationCity: locationCity.trim(),
};
if (withManager) {
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim() || !mgrPassword) {
setError("Fill in the manager's name, email, phone and password — or switch off the manager login.");
return;
}
if (mgrPassword.length < 8) {
setError("The manager password must be at least 8 characters.");
return;
}
body.manager = {
name: mgrName.trim(),
email: mgrEmail.trim(),
phoneNumber: mgrPhone.trim(),
password: mgrPassword,
};
}
setSaving(true);
try {
await apiFetch("/api/branches", {
method: "POST",
body: JSON.stringify({ branchName: branchName.trim(), locationCity: locationCity.trim() }),
body: JSON.stringify(body),
});
if (withManager) {
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
} else {
show(branchName.trim() + " created", "success");
}
setBranchName("");
setLocationCity("");
setMgrName("");
setMgrEmail("");
setMgrPhone("");
setMgrPassword("");
await loadBranches();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Could not create branch";
@@ -187,6 +224,81 @@ export default function BranchesPage() {
/>
</div>
</div>
<div className="mt-4 rounded-xl2 border border-depth-100 bg-sand-50 p-4">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={withManager}
onChange={(e) => setWithManager(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-depth-300"
/>
<span>
<span className="block font-medium text-depth-900">
Create this branch&apos;s manager login now
</span>
<span className="block text-xs text-depth-500">
The manager signs in with this email &amp; password, then adds their own delivery
staff. You can leave this off and assign a manager later from Staff.
</span>
</span>
</label>
{withManager && (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-name">
Manager name
</label>
<input
id="mgr-name"
value={mgrName}
onChange={(e) => setMgrName(e.target.value)}
placeholder="e.g. Jane Wambui"
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-phone">
Manager phone
</label>
<input
id="mgr-phone"
value={mgrPhone}
onChange={(e) => setMgrPhone(e.target.value)}
placeholder="e.g. 0712 345 678"
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-email">
Manager email (login)
</label>
<input
id="mgr-email"
type="email"
value={mgrEmail}
onChange={(e) => setMgrEmail(e.target.value)}
placeholder="manager@business.co.ke"
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
Temporary password
</label>
<input
id="mgr-password"
type="text"
value={mgrPassword}
onChange={(e) => setMgrPassword(e.target.value)}
placeholder="At least 8 characters"
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
/>
</div>
</div>
)}
</div>
<button
type="submit"
disabled={saving}
+3 -3
View File
@@ -133,9 +133,9 @@ export default function AdminDashboardPage() {
onChange={setSelectedBranchId}
isSuperAdmin={me.role === "SUPER_ADMIN"}
/>
{me.role === "SUPER_ADMIN" && (
<Link href="/admin/branches" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Branches
{me.isPlatformAdmin && (
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
Platform
</Link>
)}
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
+2
View File
@@ -7,6 +7,7 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { getEnv } from "@/lib/server/env";
export const runtime = "nodejs";
@@ -116,6 +117,7 @@ export async function POST(req: NextRequest) {
email: user.email,
},
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
isPlatformAdmin: isPlatformAdminEmail(user.email),
});
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
+18
View File
@@ -4,6 +4,8 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -19,6 +21,11 @@ export async function GET(req: NextRequest) {
}
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const business = await Business.findById(user.businessId).lean();
const tier = (business?.tier as TierId) ?? "STARTER";
const status = (business?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
const tierDef = TIERS[tier];
return NextResponse.json({
user: {
id: user._id,
@@ -30,6 +37,17 @@ export async function GET(req: NextRequest) {
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
isPlatformAdmin: isPlatformAdminEmail(user.email),
},
subscription: {
tier,
tierName: tierDef.name,
priceKsh: tierDef.priceKsh,
status,
trialEndsAt: business?.trialEndsAt ?? null,
currentPeriodEnd: business?.currentPeriodEnd ?? null,
maxBranches: tierDef.maxBranches,
maxUsers: tierDef.maxUsers,
},
});
}
+10 -24
View File
@@ -10,21 +10,10 @@ import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
export const runtime = "nodejs";
/**
* Owner self-signup — the ONLY way a new tenant (Business) enters the
* system. It atomically:
* 1. creates the Business,
* 2. creates the owner as a SUPER_ADMIN (branchId = null) bound to it,
* 3. seeds a default water + eggs product catalog scoped to the business,
* 4. logs the owner straight in (session + cookies), same as /login.
*
* Branches and branch managers are created LATER by the owner from the
* admin UI; delivery staff are then added by branch managers. Signup only
* bootstraps the tenant and its first user.
*/
const signupSchema = z.object({
businessName: z.string().min(2).max(120),
ownerName: z.string().min(2).max(120),
@@ -63,9 +52,6 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase();
// Email + phone are globally unique across all tenants (login is by
// email alone, with no tenant selector), so reject collisions early
// with a friendly message rather than a raw duplicate-key error.
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
@@ -77,14 +63,18 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12);
// Create owner first (with a temporary businessId), then the business,
// then point the owner at the real business id and persist. This avoids
// a chicken-and-egg between User.businessId (required) and
// Business.ownerId (required).
const ownerId = new mongoose.Types.ObjectId();
const businessId = new mongoose.Types.ObjectId();
const business = new Business({ _id: businessId, name: businessName.trim(), ownerId });
const business = new Business({
_id: businessId,
name: businessName.trim(),
ownerId,
tier: DEFAULT_TIER,
subscriptionStatus: "TRIALING",
trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000),
currentPeriodEnd: null,
});
const owner = new User({
_id: ownerId,
businessId,
@@ -100,8 +90,6 @@ export async function POST(req: NextRequest) {
await business.save();
await owner.save();
} catch (e) {
// Roll back a partial create so a failed signup never leaves an
// orphaned business or user behind.
await Business.deleteOne({ _id: businessId }).catch(() => {});
await User.deleteOne({ _id: ownerId }).catch(() => {});
const msg = (e as Error).message || "";
@@ -111,10 +99,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Could not create your business. Please try again." }, { status: 500 });
}
// Seed the default catalog for the new tenant.
await Product.insertMany(DEFAULT_CATALOG.map((p) => ({ ...p, businessId })));
// Auto-login: create the session + issue cookies, exactly like /login.
const refreshToken = generateRefreshToken();
const refreshTokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + refreshTtlMs());
+68 -3
View File
@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { getAuth, requireRole } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,8 +21,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
// Branch-scoped users still get filtered by businessId as a backstop,
// so a stale/forged branchId from another tenant can never resolve.
const branch = await Branch.findOne({ _id: auth.branchId, businessId: auth.businessId }).lean();
return NextResponse.json({ branches: branch ? [branch] : [] });
}
@@ -26,6 +28,14 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({
branchName: z.string().trim().min(1).max(120),
locationCity: z.string().trim().min(1).max(120),
manager: z
.object({
name: z.string().trim().min(1).max(120),
email: z.string().email(),
phoneNumber: z.string().min(9).max(15),
password: z.string().min(8, "Password must be at least 8 characters"),
})
.optional(),
});
export async function POST(req: NextRequest) {
@@ -40,6 +50,61 @@ export async function POST(req: NextRequest) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.create({ ...parsed.data, businessId: auth!.businessId });
const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier);
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
let managerPhone: string | null = null;
if (manager) {
const userErr = await userLimitError(businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
try {
managerPhone = normalizeKenyanPhone(manager.phoneNumber);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
const lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
}
const branch = await Branch.create({ branchName, locationCity, businessId });
if (manager && managerPhone) {
try {
const passwordHash = await bcrypt.hash(manager.password, 12);
const managerUser = await User.create({
businessId,
name: manager.name,
role: "BRANCH_MANAGER",
branchId: branch._id,
phoneNumber: managerPhone,
email: manager.email.toLowerCase(),
passwordHash,
});
branch.managerId = managerUser._id;
await branch.save();
return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
}
}
return NextResponse.json({ branch }, { status: 201 });
}
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Business } from "@/lib/server/models/Business";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { TIERS, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const patchSchema = z.object({
tier: z.enum(["STARTER", "GROWTH", "ENTERPRISE"]).optional(),
subscriptionStatus: z.enum(["TRIALING", "ACTIVE", "PAST_DUE", "SUSPENDED"]).optional(),
// Extend/renew the paid period by N days (operator convenience, since
// there's no gateway). Sets currentPeriodEnd = now + days.
extendDays: z.number().int().min(1).max(366).optional(),
});
/**
* Platform operator updates a single business's subscription — change
* plan tier, flip status (e.g. ACTIVE / SUSPENDED), or extend the billing
* period. Operator-only, cross-tenant.
*/
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
await connectDb();
const admin = await requirePlatformAdmin(req);
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const json = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { tier, subscriptionStatus, extendDays } = parsed.data;
const update: Record<string, unknown> = {};
if (tier) update.tier = tier;
if (subscriptionStatus) update.subscriptionStatus = subscriptionStatus;
if (extendDays) {
update.currentPeriodEnd = new Date(Date.now() + extendDays * 24 * 60 * 60 * 1000);
// Extending the period implies the account is paid/active.
if (!subscriptionStatus) update.subscriptionStatus = "ACTIVE";
}
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
const business = await Business.findByIdAndUpdate(id, update, { new: true }).lean();
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
const t = (business.tier as TierId) ?? "STARTER";
return NextResponse.json({
business: {
id: String(business._id),
name: business.name,
tier: t,
tierName: TIERS[t].name,
priceKsh: TIERS[t].priceKsh,
subscriptionStatus: business.subscriptionStatus,
trialEndsAt: business.trialEndsAt ?? null,
currentPeriodEnd: business.currentPeriodEnd ?? null,
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { TIERS, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Platform command center — list EVERY business across all tenants with
* subscription + usage stats. Operator-only (platform admin email).
* This is the one place that intentionally crosses the tenant boundary;
* ordinary business owners can never reach it.
*/
export async function GET(req: NextRequest) {
await connectDb();
const admin = await requirePlatformAdmin(req);
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
// Usage counts per business in two grouped queries instead of N+1.
const [userCounts, branchCounts] = await Promise.all([
User.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
Branch.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
]);
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
// Owner contact lookup.
const ownerIds = businesses.map((b) => b.ownerId);
const owners = await User.find({ _id: { $in: ownerIds } })
.select("name email phoneNumber")
.lean();
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
const rows = businesses.map((b) => {
const tier = (b.tier as TierId) ?? "STARTER";
const owner = ownerMap.get(String(b.ownerId));
return {
id: String(b._id),
name: b.name,
tier,
tierName: TIERS[tier].name,
priceKsh: TIERS[tier].priceKsh,
subscriptionStatus: b.subscriptionStatus ?? "TRIALING",
trialEndsAt: b.trialEndsAt ?? null,
currentPeriodEnd: b.currentPeriodEnd ?? null,
isActive: b.isActive,
createdAt: b.createdAt,
userCount: userMap.get(String(b._id)) ?? 0,
branchCount: branchMap.get(String(b._id)) ?? 0,
maxUsers: TIERS[tier].maxUsers,
maxBranches: TIERS[tier].maxBranches,
owner: owner
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
: null,
};
});
// Headline MRR = sum of monthly price for businesses that are ACTIVE.
const mrr = rows
.filter((r) => r.subscriptionStatus === "ACTIVE")
.reduce((sum, r) => sum + r.priceKsh, 0);
return NextResponse.json({
businesses: rows,
stats: {
total: rows.length,
active: rows.filter((r) => r.subscriptionStatus === "ACTIVE").length,
trialing: rows.filter((r) => r.subscriptionStatus === "TRIALING").length,
suspended: rows.filter((r) => r.subscriptionStatus === "SUSPENDED").length,
mrr,
},
});
}
+6 -4
View File
@@ -6,6 +6,7 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { userLimitError, loadTier } from "@/lib/server/subscription";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -18,7 +19,6 @@ export async function GET(req: NextRequest) {
}
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
// businessId is the hard tenant boundary; branchId narrows within it.
const filter: Record<string, unknown> = { businessId: auth!.businessId };
if (branchId) filter.branchId = branchId;
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
@@ -62,9 +62,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
}
// Any branch supplied (by a SUPER_ADMIN creating a manager/staff for a
// specific branch) MUST belong to this business — otherwise a crafted
// branchId from another tenant could attach a user cross-tenant.
if (branchId) {
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
if (!branch) {
@@ -79,6 +76,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
// Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth!.businessId);
const userErr = await userLimitError(auth!.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
const passwordHash = await bcrypt.hash(data.password, 12);
try {
+293
View File
@@ -0,0 +1,293 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
interface BizRow {
id: string;
name: string;
tier: TierId;
tierName: string;
priceKsh: number;
subscriptionStatus: SubStatus;
trialEndsAt: string | null;
currentPeriodEnd: string | null;
isActive: boolean;
createdAt: string;
userCount: number;
branchCount: number;
maxUsers: number | null;
maxBranches: number | null;
owner: { name: string; email: string; phoneNumber: string } | null;
}
interface Stats {
total: number;
active: number;
trialing: number;
suspended: number;
mrr: number;
}
const TIER_OPTIONS: TierId[] = ["STARTER", "GROWTH", "ENTERPRISE"];
const STATUS_OPTIONS: SubStatus[] = ["TRIALING", "ACTIVE", "PAST_DUE", "SUSPENDED"];
function ksh(n: number) {
return "KSh " + n.toLocaleString("en-KE");
}
function fmtDate(d: string | null) {
if (!d) return "—";
return new Date(d).toLocaleDateString("en-KE", { year: "numeric", month: "short", day: "numeric" });
}
const STATUS_STYLES: Record<SubStatus, string> = {
ACTIVE: "bg-confirm-500/15 text-confirm-600",
TRIALING: "bg-flow-500/15 text-flow-600",
PAST_DUE: "bg-cash-500/15 text-cash-600",
SUSPENDED: "bg-alert-500/15 text-alert-600",
};
export default function PlatformPage() {
const router = useRouter();
const { show } = useToast();
const [ready, setReady] = useState(false);
const [rows, setRows] = useState<BizRow[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
useEffect(() => {
(async () => {
let me: AuthUser;
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
me = result.user;
} catch {
router.replace("/login");
return;
}
if (!me.isPlatformAdmin) {
router.replace(me.role === "DELIVERY" ? "/terminal" : "/admin/dashboard");
return;
}
setReady(true);
await load();
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function load() {
const result = await apiFetch<{ businesses: BizRow[]; stats: Stats }>("/api/platform/businesses");
setRows(result.businesses);
setStats(result.stats);
}
async function patch(id: string, update: Record<string, unknown>, label: string) {
setSavingId(id);
try {
await apiFetch("/api/platform/businesses/" + id, {
method: "PATCH",
body: JSON.stringify(update),
});
show(label, "success");
await load();
} catch (err) {
const message = err instanceof ApiError ? err.message : "Update failed";
show(message, "error");
}
setSavingId(null);
}
if (!ready) {
return (
<main className="min-h-screen bg-depth-950 pb-12">
<header className="px-5 pb-8 pt-8">
<div className="mx-auto h-14 max-w-5xl" />
</header>
<div className="mx-auto max-w-5xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
return (
<main className="min-h-screen bg-depth-950 pb-16 text-white">
<header className="border-b border-white/10 px-5 pb-6 pt-8">
<div className="mx-auto max-w-5xl">
<p className="text-xs uppercase tracking-[0.2em] text-flow-400">Operator</p>
<h1 className="font-display text-2xl font-bold">Platform Command Center</h1>
<p className="mt-1 text-sm text-white/60">
Every business on your platform, across all tenants. Change plans, extend billing periods,
and suspend accounts here.
</p>
</div>
</header>
<div className="mx-auto max-w-5xl space-y-6 px-5 pt-6">
{stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard label="Businesses" value={String(stats.total)} />
<StatCard label="Active" value={String(stats.active)} accent="text-confirm-400" />
<StatCard label="Trialing" value={String(stats.trialing)} accent="text-flow-400" />
<StatCard label="Suspended" value={String(stats.suspended)} accent="text-alert-400" />
<StatCard label="MRR (active)" value={ksh(stats.mrr)} accent="text-flow-300" />
</div>
)}
{loading ? (
<SkeletonList rows={4} />
) : rows.length === 0 ? (
<div className="rounded-xl2 border border-white/10 bg-white/5 p-8 text-center text-white/60">
No businesses have signed up yet.
</div>
) : (
<div className="space-y-4">
{rows.map((b) => (
<BusinessCard
key={b.id}
b={b}
saving={savingId === b.id}
onPatch={patch}
/>
))}
</div>
)}
</div>
</main>
);
}
function StatCard({ label, value, accent }: { label: string; value: string; accent?: string }) {
return (
<div className="rounded-xl2 border border-white/10 bg-white/5 p-4">
<p className="text-xs uppercase tracking-wide text-white/50">{label}</p>
<p className={"mt-1 font-display text-xl font-bold " + (accent ?? "text-white")}>{value}</p>
</div>
);
}
function BusinessCard({
b,
saving,
onPatch,
}: {
b: BizRow;
saving: boolean;
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
}) {
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
return (
<div className="rounded-xl2 border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h2 className="font-display text-lg font-semibold text-white">{b.name}</h2>
<span className={"rounded-full px-2.5 py-0.5 text-xs font-medium " + STATUS_STYLES[b.subscriptionStatus]}>
{b.subscriptionStatus}
</span>
</div>
{b.owner && (
<p className="mt-0.5 text-xs text-white/50">
{b.owner.name} · {b.owner.email} · {b.owner.phoneNumber}
</p>
)}
<p className="mt-0.5 text-xs text-white/40">Joined {fmtDate(b.createdAt)}</p>
</div>
<div className="text-right">
<p className="font-display text-base font-semibold text-flow-300">
{b.tierName} · {ksh(b.priceKsh)}/mo
</p>
<p className="mt-0.5 text-xs text-white/50">
Trial ends {fmtDate(b.trialEndsAt)} · Paid thru {fmtDate(b.currentPeriodEnd)}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<span className={overBranches ? "text-alert-400" : "text-white/70"}>
Branches: {b.branchCount}
{b.maxBranches === null ? " / ∞" : " / " + b.maxBranches}
</span>
<span className={overUsers ? "text-alert-400" : "text-white/70"}>
Users: {b.userCount}
{b.maxUsers === null ? " / ∞" : " / " + b.maxUsers}
</span>
</div>
<div className="mt-4 flex flex-wrap items-end gap-3 border-t border-white/10 pt-4">
<div>
<label className="mb-1 block text-xs text-white/50">Plan</label>
<select
defaultValue={b.tier}
disabled={saving}
onChange={(e) =>
onPatch(b.id, { tier: e.target.value }, b.name + " moved to " + e.target.value)
}
className="rounded-xl border border-white/15 bg-depth-900 px-3 py-2 text-sm text-white"
>
{TIER_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs text-white/50">Status</label>
<select
defaultValue={b.subscriptionStatus}
disabled={saving}
onChange={(e) =>
onPatch(b.id, { subscriptionStatus: e.target.value }, b.name + " set to " + e.target.value)
}
className="rounded-xl border border-white/15 bg-depth-900 px-3 py-2 text-sm text-white"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<button
type="button"
disabled={saving}
onClick={() => onPatch(b.id, { extendDays: 30 }, b.name + " extended 30 days (now ACTIVE)")}
className="tap-target rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 disabled:opacity-60"
>
{saving ? "Saving…" : "Mark paid +30 days"}
</button>
{b.subscriptionStatus !== "SUSPENDED" ? (
<button
type="button"
disabled={saving}
onClick={() => onPatch(b.id, { subscriptionStatus: "SUSPENDED" }, b.name + " suspended")}
className="tap-target rounded-xl2 border border-alert-500/40 px-4 text-sm font-medium text-alert-300 disabled:opacity-60"
>
Suspend
</button>
) : (
<button
type="button"
disabled={saving}
onClick={() => onPatch(b.id, { subscriptionStatus: "ACTIVE" }, b.name + " reactivated")}
className="tap-target rounded-xl2 border border-confirm-500/40 px-4 text-sm font-medium text-confirm-300 disabled:opacity-60"
>
Reactivate
</button>
)}
</div>
</div>
);
}
+3 -16
View File
@@ -6,13 +6,6 @@ import { apiFetch, ApiError } from "@/lib/api";
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
import VesselLogo from "@/components/ui/VesselLogo";
/**
* Owner self-signup — the entry point for a brand-new business (tenant).
* Submitting creates the Business + the owner (SUPER_ADMIN), seeds a
* default water/eggs catalog, and logs the owner straight into the admin
* dashboard. From there the owner creates branches and branch managers;
* branch managers in turn add delivery staff.
*/
export default function SignupPage() {
const router = useRouter();
const [businessName, setBusinessName] = useState("");
@@ -58,7 +51,6 @@ export default function SignupPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-4 py-10">
<div className="w-full max-w-sm animate-fade-up">
{/* Brand lockup */}
<div className="mb-8 flex items-center justify-center gap-2.5">
<VesselLogo size={34} />
<span className="font-display text-lg font-bold tracking-tight text-depth-900">
@@ -73,11 +65,11 @@ export default function SignupPage() {
</h1>
<p className="mt-2 text-sm leading-relaxed text-depth-500">
Set up your company account. You&apos;ll be the owner and can add branches
and branch managers next.
and branch managers next. Every new business starts with a free 14-day
trial on the Starter plan (KSh 3,500/mo after trial).
</p>
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
{/* Business name */}
<div>
<label htmlFor="businessName" className="mb-1.5 block text-sm font-medium text-depth-700">
Business name
@@ -93,7 +85,6 @@ export default function SignupPage() {
/>
</div>
{/* Owner name */}
<div>
<label htmlFor="ownerName" className="mb-1.5 block text-sm font-medium text-depth-700">
Your name
@@ -110,7 +101,6 @@ export default function SignupPage() {
/>
</div>
{/* Email */}
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-depth-700">
Email
@@ -127,7 +117,6 @@ export default function SignupPage() {
/>
</div>
{/* Phone */}
<div>
<label htmlFor="phoneNumber" className="mb-1.5 block text-sm font-medium text-depth-700">
Phone number
@@ -144,7 +133,6 @@ export default function SignupPage() {
/>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-depth-700">
Password
@@ -199,7 +187,6 @@ export default function SignupPage() {
</p>
)}
{/* Primary CTA — copper gradient pill */}
<button
type="submit"
disabled={loading}
@@ -209,7 +196,7 @@ export default function SignupPage() {
{loading && (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
)}
{loading ? "Creating\u2026" : "Create business account"}
{loading ? "Creating…" : "Create business account"}
</span>
</button>
</form>
+18 -12
View File
@@ -1,21 +1,15 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
import { DEFAULT_TIER, type TierId, type SubscriptionStatus } from "@/lib/tiers";
/**
* A Business is the top-level tenant in the system. Every other record
* (users, branches, products, sales, inventory, expenses, purchases)
* belongs to exactly one Business via a required `businessId`, and every
* query is scoped by it. This is what lets many independent companies use
* the same deployment without ever seeing each other's data.
*
* The owner is the first user created at sign-up: a SUPER_ADMIN with no
* branch assignment (branchId = null) who can see and manage ALL branches
* within their own business.
*/
export interface IBusiness extends Document {
_id: Types.ObjectId;
name: string;
ownerId: Types.ObjectId; // the SUPER_ADMIN who created the business
ownerId: Types.ObjectId;
isActive: boolean;
tier: TierId;
subscriptionStatus: SubscriptionStatus;
trialEndsAt: Date | null;
currentPeriodEnd: Date | null;
createdAt: Date;
updatedAt: Date;
}
@@ -25,6 +19,18 @@ const businessSchema = new Schema<IBusiness>(
name: { type: String, required: true, trim: true },
ownerId: { type: Schema.Types.ObjectId, ref: "User", required: true },
isActive: { type: Boolean, default: true },
tier: {
type: String,
enum: ["STARTER", "GROWTH", "ENTERPRISE"],
default: DEFAULT_TIER,
},
subscriptionStatus: {
type: String,
enum: ["TRIALING", "ACTIVE", "PAST_DUE", "SUSPENDED"],
default: "TRIALING",
},
trialEndsAt: { type: Date, default: null },
currentPeriodEnd: { type: Date, default: null },
},
{ timestamps: true }
);
+46
View File
@@ -0,0 +1,46 @@
// Platform (operator) access control. The platform operator is YOU — the
// person running this SaaS for many client businesses — not a client's
// business owner. Platform admins are identified by email via the
// PLATFORM_ADMIN_EMAILS env var (comma-separated). The JWT carries no
// email, so callers must re-read the User from the DB and pass its email
// here.
//
// We read process.env directly (not the zod-validated getEnv) so the
// platform check never couples to unrelated required env like the Daraja
// M-Pesa keys.
export function platformAdminEmails(): string[] {
const raw = process.env.PLATFORM_ADMIN_EMAILS ?? "";
return raw
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
}
export function isPlatformAdminEmail(email: string | null | undefined): boolean {
if (!email) return false;
return platformAdminEmails().includes(email.toLowerCase());
}
import type { NextRequest } from "next/server";
import { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User";
/**
* Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the
* caller is not a platform admin. The DB must already be connected.
*
* The JWT has no email, so we re-read the User and match its email
* against PLATFORM_ADMIN_EMAILS.
*/
export async function requirePlatformAdmin(
req: NextRequest
): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req);
if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive").lean();
if (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null;
return { userId: auth.sub, email: user.email };
}
+55
View File
@@ -0,0 +1,55 @@
// Server-side helpers that turn the shared tier definitions in
// `@/lib/tiers` into enforceable limits. Routes call these before
// creating a branch or a user so a tenant can never exceed what their
// plan allows. There is no real payment gateway yet; this is purely the
// tier/limit model that the platform console drives.
import { Types } from "mongoose";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { Business, IBusiness } from "@/lib/server/models/Business";
import { TIERS, type TierId } from "@/lib/tiers";
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
return (business.tier as TierId) ?? "STARTER";
}
/**
* Returns an error message string if adding one more branch would exceed
* the business's plan, or null if it's allowed.
*/
export async function branchLimitError(
businessId: Types.ObjectId | string,
tier: TierId
): Promise<string | null> {
const max = TIERS[tier].maxBranches;
if (max === null) return null; // unlimited
const count = await Branch.countDocuments({ businessId });
if (count >= max) {
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
}
return null;
}
/**
* Returns an error message string if adding one more user (any role)
* would exceed the business's plan, or null if it's allowed.
*/
export async function userLimitError(
businessId: Types.ObjectId | string,
tier: TierId
): Promise<string | null> {
const max = TIERS[tier].maxUsers;
if (max === null) return null; // unlimited
const count = await User.countDocuments({ businessId });
if (count >= max) {
return `Your ${TIERS[tier].name} plan allows ${max} user accounts. Upgrade to add more.`;
}
return null;
}
/** Load the business tier for a tenant, defaulting to STARTER. */
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
const biz = await Business.findById(businessId).select("tier").lean();
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
}
+80
View File
@@ -0,0 +1,80 @@
// Subscription tiers for the SaaS. Prices are in Kenyan Shillings (KSh)
// per month. `maxBranches` / `maxUsers` are hard caps enforced server-side
// when creating branches and staff. Use null for "unlimited".
//
// This file is intentionally free of server-only imports so both the API
// routes and the client pages can share the exact same definitions.
export type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
export interface TierDef {
id: TierId;
name: string;
priceKsh: number; // monthly price in KSh
maxBranches: number | null; // null = unlimited
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
features: string[];
}
export const TIERS: Record<TierId, TierDef> = {
STARTER: {
id: "STARTER",
name: "Starter",
priceKsh: 3500,
maxBranches: 1,
maxUsers: 6, // owner + up to 5 staff/manager
features: [
"1 branch",
"Up to 5 staff accounts",
"Sales, cash & M-Pesa capture",
"Daily stock reconciliation",
"Profit & expense reports",
],
},
GROWTH: {
id: "GROWTH",
name: "Growth",
priceKsh: 7500,
maxBranches: 5,
maxUsers: 30,
features: [
"Up to 5 branches",
"Up to 30 staff accounts",
"Everything in Starter",
"Multi-branch dashboard & ranking",
"Branch-level pricing overrides",
],
},
ENTERPRISE: {
id: "ENTERPRISE",
name: "Enterprise",
priceKsh: 15000,
maxBranches: null,
maxUsers: null,
features: [
"Unlimited branches",
"Unlimited staff accounts",
"Everything in Growth",
"Priority support",
],
},
};
export const TIER_ORDER: TierId[] = ["STARTER", "GROWTH", "ENTERPRISE"];
export const DEFAULT_TIER: TierId = "STARTER";
// Days of free trial a brand-new business gets on signup.
export const TRIAL_DAYS = 14;
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
export function formatKsh(amount: number): string {
return "KSh " + amount.toLocaleString("en-KE");
}
// Whether a subscription status currently permits using the app.
// TRIALING and ACTIVE are allowed; PAST_DUE still allowed (grace) but
// should warn; SUSPENDED is blocked.
export function isUsable(status: SubscriptionStatus): boolean {
return status !== "SUSPENDED";
}
+1
View File
@@ -19,6 +19,7 @@ export interface AuthUser {
branchName?: string | null;
phoneNumber?: string;
email?: string | null;
isPlatformAdmin?: boolean;
}
export interface Product {
+1 -1
View File
File diff suppressed because one or more lines are too long