feat: subscription tiers, branch-with-manager flow, platform command center
This commit is contained in:
+2
-1
@@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"seed": "tsx scripts/seed.ts"
|
"seed": "tsx scripts/seed.ts",
|
||||||
|
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"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 [branchName, setBranchName] = useState("");
|
||||||
const [locationCity, setLocationCity] = 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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -58,15 +65,45 @@ export default function BranchesPage() {
|
|||||||
return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/branches", {
|
await apiFetch("/api/branches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ branchName: branchName.trim(), locationCity: locationCity.trim() }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
show(branchName.trim() + " created", "success");
|
if (withManager) {
|
||||||
|
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
|
||||||
|
} else {
|
||||||
|
show(branchName.trim() + " created", "success");
|
||||||
|
}
|
||||||
setBranchName("");
|
setBranchName("");
|
||||||
setLocationCity("");
|
setLocationCity("");
|
||||||
|
setMgrName("");
|
||||||
|
setMgrEmail("");
|
||||||
|
setMgrPhone("");
|
||||||
|
setMgrPassword("");
|
||||||
await loadBranches();
|
await loadBranches();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ApiError ? err.message : "Could not create branch";
|
const message = err instanceof ApiError ? err.message : "Could not create branch";
|
||||||
@@ -187,6 +224,81 @@ export default function BranchesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -198,4 +310,4 @@ export default function BranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,9 +133,9 @@ export default function AdminDashboardPage() {
|
|||||||
onChange={setSelectedBranchId}
|
onChange={setSelectedBranchId}
|
||||||
isSuperAdmin={me.role === "SUPER_ADMIN"}
|
isSuperAdmin={me.role === "SUPER_ADMIN"}
|
||||||
/>
|
/>
|
||||||
{me.role === "SUPER_ADMIN" && (
|
{me.isPlatformAdmin && (
|
||||||
<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">
|
<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">
|
||||||
Branches
|
Platform
|
||||||
</Link>
|
</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">
|
<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 { Business } from "@/lib/server/models/Business";
|
||||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
|
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||||
import { getEnv } from "@/lib/server/env";
|
import { getEnv } from "@/lib/server/env";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -116,9 +117,10 @@ export async function POST(req: NextRequest) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||||
|
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||||
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@ import { User } from "@/lib/server/models/User";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { getAuth } from "@/lib/server/auth";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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 branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
||||||
const business = await Business.findById(user.businessId).lean();
|
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({
|
return NextResponse.json({
|
||||||
user: {
|
user: {
|
||||||
id: user._id,
|
id: user._id,
|
||||||
@@ -30,6 +37,17 @@ export async function GET(req: NextRequest) {
|
|||||||
branchName: branch?.branchName ?? null,
|
branchName: branch?.branchName ?? null,
|
||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
email: user.email,
|
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 { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
|
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
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({
|
const signupSchema = z.object({
|
||||||
businessName: z.string().min(2).max(120),
|
businessName: z.string().min(2).max(120),
|
||||||
ownerName: 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();
|
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();
|
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
||||||
if (emailTaken) {
|
if (emailTaken) {
|
||||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
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);
|
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 ownerId = new mongoose.Types.ObjectId();
|
||||||
const businessId = 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({
|
const owner = new User({
|
||||||
_id: ownerId,
|
_id: ownerId,
|
||||||
businessId,
|
businessId,
|
||||||
@@ -100,8 +90,6 @@ export async function POST(req: NextRequest) {
|
|||||||
await business.save();
|
await business.save();
|
||||||
await owner.save();
|
await owner.save();
|
||||||
} catch (e) {
|
} 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 Business.deleteOne({ _id: businessId }).catch(() => {});
|
||||||
await User.deleteOne({ _id: ownerId }).catch(() => {});
|
await User.deleteOne({ _id: ownerId }).catch(() => {});
|
||||||
const msg = (e as Error).message || "";
|
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 });
|
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 })));
|
await Product.insertMany(DEFAULT_CATALOG.map((p) => ({ ...p, businessId })));
|
||||||
|
|
||||||
// Auto-login: create the session + issue cookies, exactly like /login.
|
|
||||||
const refreshToken = generateRefreshToken();
|
const refreshToken = generateRefreshToken();
|
||||||
const refreshTokenHash = hashRefreshToken(refreshToken);
|
const refreshTokenHash = hashRefreshToken(refreshToken);
|
||||||
const expiresAt = new Date(Date.now() + refreshTtlMs());
|
const expiresAt = new Date(Date.now() + refreshTtlMs());
|
||||||
@@ -149,4 +135,4 @@ export async function POST(req: NextRequest) {
|
|||||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||||
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,8 +21,6 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ branches });
|
return NextResponse.json({ branches });
|
||||||
}
|
}
|
||||||
if (!auth.branchId) 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();
|
const branch = await Branch.findOne({ _id: auth.branchId, businessId: auth.businessId }).lean();
|
||||||
return NextResponse.json({ branches: branch ? [branch] : [] });
|
return NextResponse.json({ branches: branch ? [branch] : [] });
|
||||||
}
|
}
|
||||||
@@ -26,6 +28,14 @@ export async function GET(req: NextRequest) {
|
|||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
branchName: z.string().trim().min(1).max(120),
|
branchName: z.string().trim().min(1).max(120),
|
||||||
locationCity: 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) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -40,6 +50,61 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
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 });
|
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 { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
|
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,7 +19,6 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
|
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 };
|
const filter: Record<string, unknown> = { businessId: auth!.businessId };
|
||||||
if (branchId) filter.branchId = branchId;
|
if (branchId) filter.branchId = branchId;
|
||||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
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 });
|
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) {
|
if (branchId) {
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
@@ -79,6 +76,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
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);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -108,4 +110,4 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-17
@@ -6,13 +6,6 @@ import { apiFetch, ApiError } from "@/lib/api";
|
|||||||
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
||||||
import VesselLogo from "@/components/ui/VesselLogo";
|
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() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [businessName, setBusinessName] = useState("");
|
const [businessName, setBusinessName] = useState("");
|
||||||
@@ -58,7 +51,6 @@ export default function SignupPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-4 py-10">
|
<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">
|
<div className="w-full max-w-sm animate-fade-up">
|
||||||
{/* Brand lockup */}
|
|
||||||
<div className="mb-8 flex items-center justify-center gap-2.5">
|
<div className="mb-8 flex items-center justify-center gap-2.5">
|
||||||
<VesselLogo size={34} />
|
<VesselLogo size={34} />
|
||||||
<span className="font-display text-lg font-bold tracking-tight text-depth-900">
|
<span className="font-display text-lg font-bold tracking-tight text-depth-900">
|
||||||
@@ -73,11 +65,11 @@ export default function SignupPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm leading-relaxed text-depth-500">
|
<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
|
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>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
|
||||||
{/* Business name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="businessName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
<label htmlFor="businessName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
Business name
|
Business name
|
||||||
@@ -93,7 +85,6 @@ export default function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ownerName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
<label htmlFor="ownerName" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
Your name
|
Your name
|
||||||
@@ -110,7 +101,6 @@ export default function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-depth-700">
|
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
Email
|
Email
|
||||||
@@ -127,7 +117,6 @@ export default function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="phoneNumber" className="mb-1.5 block text-sm font-medium text-depth-700">
|
<label htmlFor="phoneNumber" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
Phone number
|
Phone number
|
||||||
@@ -144,7 +133,6 @@ export default function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
Password
|
Password
|
||||||
@@ -199,7 +187,6 @@ export default function SignupPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary CTA — copper gradient pill */}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -209,7 +196,7 @@ export default function SignupPage() {
|
|||||||
{loading && (
|
{loading && (
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -224,4 +211,4 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
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 {
|
export interface IBusiness extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
name: string;
|
name: string;
|
||||||
ownerId: Types.ObjectId; // the SUPER_ADMIN who created the business
|
ownerId: Types.ObjectId;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
tier: TierId;
|
||||||
|
subscriptionStatus: SubscriptionStatus;
|
||||||
|
trialEndsAt: Date | null;
|
||||||
|
currentPeriodEnd: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -25,9 +19,21 @@ const businessSchema = new Schema<IBusiness>(
|
|||||||
name: { type: String, required: true, trim: true },
|
name: { type: String, required: true, trim: true },
|
||||||
ownerId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
ownerId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
isActive: { type: Boolean, default: 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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Business =
|
export const Business =
|
||||||
(models.Business as Model<IBusiness>) || model<IBusiness>("Business", businessSchema);
|
(models.Business as Model<IBusiness>) || model<IBusiness>("Business", businessSchema);
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
+2
-1
@@ -19,6 +19,7 @@ export interface AuthUser {
|
|||||||
branchName?: string | null;
|
branchName?: string | null;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
@@ -82,4 +83,4 @@ export interface ReconciliationRow {
|
|||||||
totalSold: number;
|
totalSold: number;
|
||||||
expectedRemaining: number;
|
expectedRemaining: number;
|
||||||
reconciledOk: boolean | null;
|
reconciledOk: boolean | null;
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user