feat: subscription tiers, branch-with-manager flow, platform command center
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
phoneNumber?: string;
|
||||
email?: string | null;
|
||||
isPlatformAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
@@ -82,4 +83,4 @@ export interface ReconciliationRow {
|
||||
totalSold: number;
|
||||
expectedRemaining: number;
|
||||
reconciledOk: boolean | null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user