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
+3 -1
View File
@@ -7,6 +7,7 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { getEnv } from "@/lib/server/env";
export const runtime = "nodejs";
@@ -116,9 +117,10 @@ 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());
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
}
+19 -1
View File
@@ -4,6 +4,8 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -19,6 +21,11 @@ export async function GET(req: NextRequest) {
}
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
const business = await Business.findById(user.businessId).lean();
const tier = (business?.tier as TierId) ?? "STARTER";
const status = (business?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
const tierDef = TIERS[tier];
return NextResponse.json({
user: {
id: user._id,
@@ -30,6 +37,17 @@ export async function GET(req: NextRequest) {
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
isPlatformAdmin: isPlatformAdminEmail(user.email),
},
subscription: {
tier,
tierName: tierDef.name,
priceKsh: tierDef.priceKsh,
status,
trialEndsAt: business?.trialEndsAt ?? null,
currentPeriodEnd: business?.currentPeriodEnd ?? null,
maxBranches: tierDef.maxBranches,
maxUsers: tierDef.maxUsers,
},
});
}
}
+11 -25
View File
@@ -10,21 +10,10 @@ import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
export const runtime = "nodejs";
/**
* Owner self-signup — the ONLY way a new tenant (Business) enters the
* system. It atomically:
* 1. creates the Business,
* 2. creates the owner as a SUPER_ADMIN (branchId = null) bound to it,
* 3. seeds a default water + eggs product catalog scoped to the business,
* 4. logs the owner straight in (session + cookies), same as /login.
*
* Branches and branch managers are created LATER by the owner from the
* admin UI; delivery staff are then added by branch managers. Signup only
* bootstraps the tenant and its first user.
*/
const signupSchema = z.object({
businessName: z.string().min(2).max(120),
ownerName: z.string().min(2).max(120),
@@ -63,9 +52,6 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase();
// Email + phone are globally unique across all tenants (login is by
// email alone, with no tenant selector), so reject collisions early
// with a friendly message rather than a raw duplicate-key error.
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
@@ -77,14 +63,18 @@ export async function POST(req: NextRequest) {
const passwordHash = await bcrypt.hash(password, 12);
// Create owner first (with a temporary businessId), then the business,
// then point the owner at the real business id and persist. This avoids
// a chicken-and-egg between User.businessId (required) and
// Business.ownerId (required).
const ownerId = new mongoose.Types.ObjectId();
const businessId = new mongoose.Types.ObjectId();
const business = new Business({ _id: businessId, name: businessName.trim(), ownerId });
const business = new Business({
_id: businessId,
name: businessName.trim(),
ownerId,
tier: DEFAULT_TIER,
subscriptionStatus: "TRIALING",
trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 24 * 60 * 60 * 1000),
currentPeriodEnd: null,
});
const owner = new User({
_id: ownerId,
businessId,
@@ -100,8 +90,6 @@ export async function POST(req: NextRequest) {
await business.save();
await owner.save();
} catch (e) {
// Roll back a partial create so a failed signup never leaves an
// orphaned business or user behind.
await Business.deleteOne({ _id: businessId }).catch(() => {});
await User.deleteOne({ _id: ownerId }).catch(() => {});
const msg = (e as Error).message || "";
@@ -111,10 +99,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Could not create your business. Please try again." }, { status: 500 });
}
// Seed the default catalog for the new tenant.
await Product.insertMany(DEFAULT_CATALOG.map((p) => ({ ...p, businessId })));
// Auto-login: create the session + issue cookies, exactly like /login.
const refreshToken = generateRefreshToken();
const refreshTokenHash = hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + refreshTtlMs());
@@ -149,4 +135,4 @@ export async function POST(req: NextRequest) {
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
return res;
}
}
+69 -4
View File
@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { getAuth, requireRole } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,8 +21,6 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
// Branch-scoped users still get filtered by businessId as a backstop,
// so a stale/forged branchId from another tenant can never resolve.
const branch = await Branch.findOne({ _id: auth.branchId, businessId: auth.businessId }).lean();
return NextResponse.json({ branches: branch ? [branch] : [] });
}
@@ -26,6 +28,14 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({
branchName: z.string().trim().min(1).max(120),
locationCity: z.string().trim().min(1).max(120),
manager: z
.object({
name: z.string().trim().min(1).max(120),
email: z.string().email(),
phoneNumber: z.string().min(9).max(15),
password: z.string().min(8, "Password must be at least 8 characters"),
})
.optional(),
});
export async function POST(req: NextRequest) {
@@ -40,6 +50,61 @@ export async function POST(req: NextRequest) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.create({ ...parsed.data, businessId: auth!.businessId });
const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier);
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
let managerPhone: string | null = null;
if (manager) {
const userErr = await userLimitError(businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
try {
managerPhone = normalizeKenyanPhone(manager.phoneNumber);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
const lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
}
const branch = await Branch.create({ branchName, locationCity, businessId });
if (manager && managerPhone) {
try {
const passwordHash = await bcrypt.hash(manager.password, 12);
const managerUser = await User.create({
businessId,
name: manager.name,
role: "BRANCH_MANAGER",
branchId: branch._id,
phoneNumber: managerPhone,
email: manager.email.toLowerCase(),
passwordHash,
});
branch.managerId = managerUser._id;
await branch.save();
return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
}
}
return NextResponse.json({ branch }, { status: 201 });
}
}
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Business } from "@/lib/server/models/Business";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { TIERS, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const patchSchema = z.object({
tier: z.enum(["STARTER", "GROWTH", "ENTERPRISE"]).optional(),
subscriptionStatus: z.enum(["TRIALING", "ACTIVE", "PAST_DUE", "SUSPENDED"]).optional(),
// Extend/renew the paid period by N days (operator convenience, since
// there's no gateway). Sets currentPeriodEnd = now + days.
extendDays: z.number().int().min(1).max(366).optional(),
});
/**
* Platform operator updates a single business's subscription — change
* plan tier, flip status (e.g. ACTIVE / SUSPENDED), or extend the billing
* period. Operator-only, cross-tenant.
*/
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
await connectDb();
const admin = await requirePlatformAdmin(req);
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const json = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { tier, subscriptionStatus, extendDays } = parsed.data;
const update: Record<string, unknown> = {};
if (tier) update.tier = tier;
if (subscriptionStatus) update.subscriptionStatus = subscriptionStatus;
if (extendDays) {
update.currentPeriodEnd = new Date(Date.now() + extendDays * 24 * 60 * 60 * 1000);
// Extending the period implies the account is paid/active.
if (!subscriptionStatus) update.subscriptionStatus = "ACTIVE";
}
if (Object.keys(update).length === 0) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
const business = await Business.findByIdAndUpdate(id, update, { new: true }).lean();
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
const t = (business.tier as TierId) ?? "STARTER";
return NextResponse.json({
business: {
id: String(business._id),
name: business.name,
tier: t,
tierName: TIERS[t].name,
priceKsh: TIERS[t].priceKsh,
subscriptionStatus: business.subscriptionStatus,
trialEndsAt: business.trialEndsAt ?? null,
currentPeriodEnd: business.currentPeriodEnd ?? null,
},
});
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { TIERS, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Platform command center — list EVERY business across all tenants with
* subscription + usage stats. Operator-only (platform admin email).
* This is the one place that intentionally crosses the tenant boundary;
* ordinary business owners can never reach it.
*/
export async function GET(req: NextRequest) {
await connectDb();
const admin = await requirePlatformAdmin(req);
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
// Usage counts per business in two grouped queries instead of N+1.
const [userCounts, branchCounts] = await Promise.all([
User.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
Branch.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
]);
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
// Owner contact lookup.
const ownerIds = businesses.map((b) => b.ownerId);
const owners = await User.find({ _id: { $in: ownerIds } })
.select("name email phoneNumber")
.lean();
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
const rows = businesses.map((b) => {
const tier = (b.tier as TierId) ?? "STARTER";
const owner = ownerMap.get(String(b.ownerId));
return {
id: String(b._id),
name: b.name,
tier,
tierName: TIERS[tier].name,
priceKsh: TIERS[tier].priceKsh,
subscriptionStatus: b.subscriptionStatus ?? "TRIALING",
trialEndsAt: b.trialEndsAt ?? null,
currentPeriodEnd: b.currentPeriodEnd ?? null,
isActive: b.isActive,
createdAt: b.createdAt,
userCount: userMap.get(String(b._id)) ?? 0,
branchCount: branchMap.get(String(b._id)) ?? 0,
maxUsers: TIERS[tier].maxUsers,
maxBranches: TIERS[tier].maxBranches,
owner: owner
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
: null,
};
});
// Headline MRR = sum of monthly price for businesses that are ACTIVE.
const mrr = rows
.filter((r) => r.subscriptionStatus === "ACTIVE")
.reduce((sum, r) => sum + r.priceKsh, 0);
return NextResponse.json({
businesses: rows,
stats: {
total: rows.length,
active: rows.filter((r) => r.subscriptionStatus === "ACTIVE").length,
trialing: rows.filter((r) => r.subscriptionStatus === "TRIALING").length,
suspended: rows.filter((r) => r.subscriptionStatus === "SUSPENDED").length,
mrr,
},
});
}
+7 -5
View File
@@ -6,6 +6,7 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { userLimitError, loadTier } from "@/lib/server/subscription";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -18,7 +19,6 @@ export async function GET(req: NextRequest) {
}
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
// businessId is the hard tenant boundary; branchId narrows within it.
const filter: Record<string, unknown> = { businessId: auth!.businessId };
if (branchId) filter.branchId = branchId;
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
@@ -62,9 +62,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
}
// Any branch supplied (by a SUPER_ADMIN creating a manager/staff for a
// specific branch) MUST belong to this business — otherwise a crafted
// branchId from another tenant could attach a user cross-tenant.
if (branchId) {
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
if (!branch) {
@@ -79,6 +76,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
// Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth!.businessId);
const userErr = await userLimitError(auth!.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
const passwordHash = await bcrypt.hash(data.password, 12);
try {
@@ -108,4 +110,4 @@ export async function POST(req: NextRequest) {
}
throw err;
}
}
}