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