feat: subscription tiers, branch-with-manager flow, platform command center
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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's manager login now
|
||||
</span>
|
||||
<span className="block text-xs text-depth-500">
|
||||
The manager signs in with this email & 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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface AuthUser {
|
||||
branchName?: string | null;
|
||||
phoneNumber?: string;
|
||||
email?: string | null;
|
||||
isPlatformAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user