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

This commit is contained in:
EOH Developer
2026-10-05 10:20:04 -07:00
parent 04e3051eb6
commit fa375bde91
19 changed files with 958 additions and 75 deletions
+19 -13
View File
@@ -1,21 +1,15 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
import { DEFAULT_TIER, type TierId, type SubscriptionStatus } from "@/lib/tiers";
/**
* A Business is the top-level tenant in the system. Every other record
* (users, branches, products, sales, inventory, expenses, purchases)
* belongs to exactly one Business via a required `businessId`, and every
* query is scoped by it. This is what lets many independent companies use
* the same deployment without ever seeing each other's data.
*
* The owner is the first user created at sign-up: a SUPER_ADMIN with no
* branch assignment (branchId = null) who can see and manage ALL branches
* within their own business.
*/
export interface IBusiness extends Document {
_id: Types.ObjectId;
name: string;
ownerId: Types.ObjectId; // the SUPER_ADMIN who created the business
ownerId: Types.ObjectId;
isActive: boolean;
tier: TierId;
subscriptionStatus: SubscriptionStatus;
trialEndsAt: Date | null;
currentPeriodEnd: Date | null;
createdAt: Date;
updatedAt: Date;
}
@@ -25,9 +19,21 @@ 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 }
);
export const Business =
(models.Business as Model<IBusiness>) || model<IBusiness>("Business", businessSchema);
(models.Business as Model<IBusiness>) || model<IBusiness>("Business", businessSchema);
+46
View File
@@ -0,0 +1,46 @@
// Platform (operator) access control. The platform operator is YOU — the
// person running this SaaS for many client businesses — not a client's
// business owner. Platform admins are identified by email via the
// PLATFORM_ADMIN_EMAILS env var (comma-separated). The JWT carries no
// email, so callers must re-read the User from the DB and pass its email
// here.
//
// We read process.env directly (not the zod-validated getEnv) so the
// platform check never couples to unrelated required env like the Daraja
// M-Pesa keys.
export function platformAdminEmails(): string[] {
const raw = process.env.PLATFORM_ADMIN_EMAILS ?? "";
return raw
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
}
export function isPlatformAdminEmail(email: string | null | undefined): boolean {
if (!email) return false;
return platformAdminEmails().includes(email.toLowerCase());
}
import type { NextRequest } from "next/server";
import { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User";
/**
* Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the
* caller is not a platform admin. The DB must already be connected.
*
* The JWT has no email, so we re-read the User and match its email
* against PLATFORM_ADMIN_EMAILS.
*/
export async function requirePlatformAdmin(
req: NextRequest
): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req);
if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive").lean();
if (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null;
return { userId: auth.sub, email: user.email };
}
+55
View File
@@ -0,0 +1,55 @@
// Server-side helpers that turn the shared tier definitions in
// `@/lib/tiers` into enforceable limits. Routes call these before
// creating a branch or a user so a tenant can never exceed what their
// plan allows. There is no real payment gateway yet; this is purely the
// tier/limit model that the platform console drives.
import { Types } from "mongoose";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { Business, IBusiness } from "@/lib/server/models/Business";
import { TIERS, type TierId } from "@/lib/tiers";
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
return (business.tier as TierId) ?? "STARTER";
}
/**
* Returns an error message string if adding one more branch would exceed
* the business's plan, or null if it's allowed.
*/
export async function branchLimitError(
businessId: Types.ObjectId | string,
tier: TierId
): Promise<string | null> {
const max = TIERS[tier].maxBranches;
if (max === null) return null; // unlimited
const count = await Branch.countDocuments({ businessId });
if (count >= max) {
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
}
return null;
}
/**
* Returns an error message string if adding one more user (any role)
* would exceed the business's plan, or null if it's allowed.
*/
export async function userLimitError(
businessId: Types.ObjectId | string,
tier: TierId
): Promise<string | null> {
const max = TIERS[tier].maxUsers;
if (max === null) return null; // unlimited
const count = await User.countDocuments({ businessId });
if (count >= max) {
return `Your ${TIERS[tier].name} plan allows ${max} user accounts. Upgrade to add more.`;
}
return null;
}
/** Load the business tier for a tenant, defaulting to STARTER. */
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
const biz = await Business.findById(businessId).select("tier").lean();
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
}
+80
View File
@@ -0,0 +1,80 @@
// Subscription tiers for the SaaS. Prices are in Kenyan Shillings (KSh)
// per month. `maxBranches` / `maxUsers` are hard caps enforced server-side
// when creating branches and staff. Use null for "unlimited".
//
// This file is intentionally free of server-only imports so both the API
// routes and the client pages can share the exact same definitions.
export type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
export interface TierDef {
id: TierId;
name: string;
priceKsh: number; // monthly price in KSh
maxBranches: number | null; // null = unlimited
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
features: string[];
}
export const TIERS: Record<TierId, TierDef> = {
STARTER: {
id: "STARTER",
name: "Starter",
priceKsh: 3500,
maxBranches: 1,
maxUsers: 6, // owner + up to 5 staff/manager
features: [
"1 branch",
"Up to 5 staff accounts",
"Sales, cash & M-Pesa capture",
"Daily stock reconciliation",
"Profit & expense reports",
],
},
GROWTH: {
id: "GROWTH",
name: "Growth",
priceKsh: 7500,
maxBranches: 5,
maxUsers: 30,
features: [
"Up to 5 branches",
"Up to 30 staff accounts",
"Everything in Starter",
"Multi-branch dashboard & ranking",
"Branch-level pricing overrides",
],
},
ENTERPRISE: {
id: "ENTERPRISE",
name: "Enterprise",
priceKsh: 15000,
maxBranches: null,
maxUsers: null,
features: [
"Unlimited branches",
"Unlimited staff accounts",
"Everything in Growth",
"Priority support",
],
},
};
export const TIER_ORDER: TierId[] = ["STARTER", "GROWTH", "ENTERPRISE"];
export const DEFAULT_TIER: TierId = "STARTER";
// Days of free trial a brand-new business gets on signup.
export const TRIAL_DAYS = 14;
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
export function formatKsh(amount: number): string {
return "KSh " + amount.toLocaleString("en-KE");
}
// Whether a subscription status currently permits using the app.
// TRIALING and ACTIVE are allowed; PAST_DUE still allowed (grace) but
// should warn; SUSPENDED is blocked.
export function isUsable(status: SubscriptionStatus): boolean {
return status !== "SUSPENDED";
}
+2 -1
View File
@@ -19,6 +19,7 @@ export interface AuthUser {
branchName?: string | null;
phoneNumber?: string;
email?: string | null;
isPlatformAdmin?: boolean;
}
export interface Product {
@@ -82,4 +83,4 @@ export interface ReconciliationRow {
totalSold: number;
expectedRemaining: number;
reconciledOk: boolean | null;
}
}