Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync #1

Merged
denis_ireri merged 4 commits from fix/pilot-readiness into main 2026-10-12 06:14:51 +00:00
38 changed files with 2700 additions and 158 deletions
Showing only changes of commit d4808b3e46 - Show all commits
+12 -1
View File
@@ -12,7 +12,18 @@ JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2 DELIVERY_MAX_DEVICES=2
# ---- Safaricom Daraja ---- # ---- Platform operator ----
# Comma-separated emails with cross-tenant access to /platform. Create these
# accounts with `npm run seed`; public signup refuses these addresses.
PLATFORM_ADMIN_EMAILS=
# ---- M-Pesa (optional, off for the pilot) ----
# M-Pesa STK Push is out of scope for the pilot. While this is false every
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
# If you turn it on in production, DARAJA_CALLBACK_IP_ALLOWLIST is required.
FEATURE_MPESA=false
# ---- Safaricom Daraja (only read when FEATURE_MPESA=true) ----
DARAJA_ENV=sandbox DARAJA_ENV=sandbox
DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx
DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx
+1778 -1
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -8,7 +8,9 @@
"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" "migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -29,10 +31,12 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"dotenv": "^16.4.5",
"mongodb-memory-server": "^10.1.4",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"dotenv": "^16.4.5" "vitest": "^2.1.9"
} }
} }
+6
View File
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
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 { isPlatformAdminEmail } from "@/lib/server/platform";
import { getEnv } from "@/lib/server/env"; import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -70,6 +71,11 @@ export async function POST(req: NextRequest) {
user.lockedUntil = null; user.lockedUntil = null;
await user.save(); await user.save();
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
if (user.role === "DELIVERY") { if (user.role === "DELIVERY") {
const binding = await enforceDeviceBinding(user._id.toString(), deviceId); const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
if (!binding.allowed) { if (!binding.allowed) {
+2
View File
@@ -5,6 +5,7 @@ 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 { isPlatformAdminEmail } from "@/lib/server/platform";
import { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -49,5 +50,6 @@ export async function GET(req: NextRequest) {
maxBranches: tierDef.maxBranches, maxBranches: tierDef.maxBranches,
maxUsers: tierDef.maxUsers, maxUsers: tierDef.maxUsers,
}, },
features: { mpesa: isMpesaEnabled() },
}); });
} }
+6
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession"; 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 { tenantAccessError } from "@/lib/server/subscription";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -29,6 +30,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Account no longer active" }, { status: 401 }); return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
} }
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
const newRefreshToken = generateRefreshToken(); const newRefreshToken = generateRefreshToken();
session.refreshTokenHash = hashRefreshToken(newRefreshToken); session.refreshTokenHash = hashRefreshToken(newRefreshToken);
session.lastUsedAt = new Date(); session.lastUsedAt = new Date();
+9
View File
@@ -11,6 +11,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
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"; import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
import { isPlatformAdminEmail } from "@/lib/server/platform";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -52,6 +53,14 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase(); const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
// and signup does not verify email ownership, so an operator address
// must never be claimable through public signup. Operator accounts are
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
}
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 });
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs";
const updateSchema = z
.object({
branchName: z.string().trim().min(1).max(120).optional(),
locationCity: z.string().trim().min(1).max(120).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
// PATCH /api/branches/:id — owner renames a branch or changes its city.
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, {
new: true,
}).lean();
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
return NextResponse.json({ branch });
}
+5 -6
View File
@@ -4,7 +4,7 @@ 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 { User } from "@/lib/server/models/User";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize, getAuth } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription"; import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
@@ -40,10 +40,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, ["SUPER_ADMIN"]);
if (!requireRole(auth, "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
@@ -51,7 +50,7 @@ export async function POST(req: NextRequest) {
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 { branchName, locationCity, manager } = parsed.data; const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId; const businessId = auth.businessId;
const tier = await loadTier(businessId); const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier); const branchErr = await branchLimitError(businessId, tier);
+29 -10
View File
@@ -3,27 +3,48 @@ import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, badRequest, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10); if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const date = req.nextUrl.searchParams.get("date") || todayNairobi();
if (!isBusinessDate(date)) return badRequest("date must be YYYY-MM-DD");
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) }; const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId); if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
const inventoryRows = await DailyInventory.aggregate([ const inventoryRows = await DailyInventory.aggregate([
{ $match: invMatch }, { $match: invMatch },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } }, // Lookups are tenant-scoped too, so a row can never surface another
// tenant's product or branch even if one were referenced by mistake.
{
$lookup: {
from: "products",
let: { pid: "$productId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$pid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "product",
},
},
{ $unwind: "$product" }, { $unwind: "$product" },
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } }, {
$lookup: {
from: "branches",
let: { bid: "$branchId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$bid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "branch",
},
},
{ $unwind: "$branch" }, { $unwind: "$branch" },
{ {
$project: { $project: {
@@ -39,9 +60,7 @@ export async function GET(req: NextRequest) {
}, },
]); ]);
const dayStart = new Date(date + "T00:00:00.000Z"); const { start: dayStart, end: dayEnd } = dayRangeNairobi(date);
const dayEnd = new Date(dayStart);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
const soldMatch: Record<string, unknown> = { const soldMatch: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId), businessId: new mongoose.Types.ObjectId(auth.businessId),
+9 -13
View File
@@ -2,26 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose"; import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function todayRange() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const { start, end } = todayRange(); if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const { start, end } = dayRangeNairobi(todayNairobi());
const matchStage: Record<string, unknown> = { const matchStage: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId), businessId: new mongoose.Types.ObjectId(auth.businessId),
+16 -11
View File
@@ -3,21 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense"; import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// GET /api/expenses?branchId=&from=&to= // GET /api/expenses?branchId=&from=&to=
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is // Managers and owners only. SUPER_ADMIN sees all (optionally filtered by
// hard-scoped to their own branch regardless of query params. // branch); a BRANCH_MANAGER is hard-scoped to their own branch.
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch; if (scopeBranch) filter.branchId = scopeBranch;
@@ -42,7 +46,7 @@ export async function GET(req: NextRequest) {
} }
const createSchema = z.object({ const createSchema = z.object({
branchId: z.string().trim().min(1).optional(), branchId: objectId.optional(),
category: z.enum(EXPENSE_CATEGORIES), category: z.enum(EXPENSE_CATEGORIES),
amount: z.number().positive().max(100_000_000), amount: z.number().positive().max(100_000_000),
description: z.string().trim().min(1).max(300), description: z.string().trim().min(1).max(300),
@@ -50,12 +54,13 @@ const createSchema = z.object({
notes: z.string().trim().max(500).optional(), notes: z.string().trim().max(500).optional(),
}); });
// POST /api/expenses — any authenticated staff can record an expense, but // POST /api/expenses — managers and owners only, and only against a branch
// only against a branch they are allowed to touch. // they are allowed to touch.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs"; export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const dispatchSchema = z.object({ const dispatchSchema = z.object({
productId: z.string().min(1), productId: objectId,
morningDispatched: z.number().int().min(0), morningDispatched: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: z.string().optional(), branchId: objectId.optional(),
}); });
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = dispatchSchema.safeParse(json); const parsed = dispatchSchema.safeParse(json);
@@ -31,18 +29,22 @@ export async function POST(req: NextRequest) {
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 branchId = resolveBranchScope(auth!, parsed.data.branchId); const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant — blocks a crafted branchId from // The branch MUST belong to this tenant — blocks a crafted branchId from
// writing inventory into another business. // writing inventory into another business.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayLocal(); // The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId }, { morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
return NextResponse.json({ inventory: row }); return NextResponse.json({ inventory: row });
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs"; export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const countSchema = z.object({ const countSchema = z.object({
productId: z.string().min(1), productId: objectId,
eveningPhysicalCount: z.number().int().min(0), eveningPhysicalCount: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: z.string().optional(), branchId: objectId.optional(),
}); });
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = countSchema.safeParse(json); const parsed = countSchema.safeParse(json);
@@ -31,17 +29,21 @@ export async function POST(req: NextRequest) {
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 branchId = resolveBranchScope(auth!, parsed.data.branchId); const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant. // The branch MUST belong to this tenant.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayLocal(); // The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId }, { eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
return NextResponse.json({ inventory: row }); return NextResponse.json({ inventory: row });
+2
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
@@ -18,6 +19,7 @@ function isIpAllowed(ip: string): boolean {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" }); const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
const sourceIp = getSourceIp(req); const sourceIp = getSourceIp(req);
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth } from "@/lib/server/auth"; import { getAuth } from "@/lib/server/auth";
import { queryStkStatus } from "@/lib/server/mpesa"; import { queryStkStatus } from "@/lib/server/mpesa";
@@ -8,6 +9,7 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) { export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
@@ -31,6 +32,7 @@ function rateLimited(key: string): boolean {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const auth = getAuth(req); const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
+9 -6
View File
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
import { isValidObjectId } from "mongoose";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -12,14 +13,16 @@ const updateSchema = z.object({
unitPrice: z.number().nonnegative().optional(), unitPrice: z.number().nonnegative().optional(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
}); });
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb(); await connectDb();
const auth = getAuth(req); // Owner only — product prices apply to every branch.
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { const gate = authorize(req, ["SUPER_ADMIN"]);
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
} const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Product not found" }, { status: 404 });
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json); const parsed = updateSchema.safeParse(json);
@@ -31,7 +34,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
} }
const product = await Product.findOneAndUpdate( const product = await Product.findOneAndUpdate(
{ _id: params.id, businessId: auth!.businessId }, { _id: params.id, businessId: auth.businessId },
parsed.data, parsed.data,
{ new: true } { new: true }
); );
+23 -9
View File
@@ -1,18 +1,30 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// GET /api/products — the tenant's active catalogue. For branch-scoped
// staff the price returned is the price THEIR branch charges (the branch
// override when one exists), so the terminal shows exactly what the
// server will record.
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean(); const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
if (auth.branchId) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) {
p.unitPrice = priceByProduct.get(p._id.toString()) ?? p.unitPrice;
}
}
return NextResponse.json({ products }); return NextResponse.json({ products });
} }
@@ -23,20 +35,22 @@ const createSchema = z.object({
category: z.string().trim().min(1).max(40).optional(), category: z.string().trim().min(1).max(40).optional(),
unitPrice: z.number().nonnegative(), unitPrice: z.number().nonnegative(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
}); });
// POST /api/products — owner only: the catalogue and its prices apply to
// every branch, so a branch manager must not be able to change them.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, ["SUPER_ADMIN"]);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
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 product = await Product.create({ ...parsed.data, businessId: auth!.businessId }); const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
return NextResponse.json({ product }, { status: 201 }); return NextResponse.json({ product }, { status: 201 });
} }
+13 -8
View File
@@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db";
import { Purchase } from "@/lib/server/models/Purchase"; import { Purchase } from "@/lib/server/models/Purchase";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,11 +13,14 @@ export const dynamic = "force-dynamic";
// GET /api/purchases?branchId=&from=&to= // GET /api/purchases?branchId=&from=&to=
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch; if (scopeBranch) filter.branchId = scopeBranch;
@@ -41,9 +45,9 @@ export async function GET(req: NextRequest) {
} }
const createSchema = z.object({ const createSchema = z.object({
branchId: z.string().trim().min(1).optional(), branchId: objectId.optional(),
supplierName: z.string().trim().min(1).max(120), supplierName: z.string().trim().min(1).max(120),
productId: z.string().trim().min(1).optional(), productId: objectId.optional(),
productLabel: z.string().trim().min(1).max(120).optional(), productLabel: z.string().trim().min(1).max(120).optional(),
quantity: z.number().int().positive().max(1_000_000), quantity: z.number().int().positive().max(1_000_000),
unitCost: z.number().nonnegative().max(100_000_000), unitCost: z.number().nonnegative().max(100_000_000),
@@ -57,8 +61,9 @@ const createSchema = z.object({
// branch stock. // branch stock.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
+12 -7
View File
@@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase"; import { Purchase } from "@/lib/server/models/Purchase";
import { Expense } from "@/lib/server/models/Expense"; import { Expense } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
import { startOfMonthNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,17 +18,21 @@ export const dynamic = "force-dynamic";
// Net Profit = Gross Profit - Expenses // Net Profit = Gross Profit - Expenses
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); // Revenue, costs and profit are owner/manager information, never a cashier's.
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
// Default range: start of the current month -> now. // Default range: start of the current business month (EAT) -> now.
const now = new Date(); const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0); const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now; const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null; const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const businessObjId = new mongoose.Types.ObjectId(auth.businessId); const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
+9 -7
View File
@@ -1,25 +1,27 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, branchParam } from "@/lib/server/auth";
import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200); const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
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 startOfDay = new Date(); filter.createdAt = { $gte: startOfTodayNairobi() };
startOfDay.setHours(0, 0, 0, 0);
filter.createdAt = { $gte: startOfDay };
const transactions = await Transaction.find(filter) const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { UserSession } from "@/lib/server/models/UserSession";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs";
const updateSchema = z
.object({
isActive: z.boolean().optional(),
branchId: objectId.optional(), // owner only: move staff to another branch
})
.refine((v) => v.isActive !== undefined || v.branchId !== undefined, { message: "No fields to update" });
/**
* PATCH /api/users/:id — deactivate/reactivate a staff account, or (owner
* only) move it to another branch. This is how an owner "removes" a user:
* the account and its sales history stay, but it can no longer sign in.
*
* SUPER_ADMIN any user in the business except themselves and the owner of record
* BRANCH_MANAGER only DELIVERY staff in their own branch, and only isActive
*/
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { isActive, branchId } = parsed.data;
const target = await User.findOne({ _id: params.id, businessId: auth.businessId });
if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (target._id.toString() === auth.sub) {
return NextResponse.json({ error: "You cannot change your own account here" }, { status: 403 });
}
if (auth.role === "BRANCH_MANAGER") {
const ownStaff = target.role === "DELIVERY" && target.branchId?.toString() === auth.branchId;
if (!ownStaff) {
return NextResponse.json({ error: "Branch managers may only manage delivery staff in their own branch" }, { status: 403 });
}
if (branchId !== undefined) {
return NextResponse.json({ error: "Only the owner can move staff between branches" }, { status: 403 });
}
}
const business = await Business.findById(auth.businessId).select("ownerId").lean();
if (business && business.ownerId.toString() === target._id.toString()) {
return NextResponse.json({ error: "The business owner's account cannot be changed" }, { status: 403 });
}
if (branchId !== undefined) {
if (target.role === "SUPER_ADMIN") {
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
}
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
target.branchId = branch._id;
}
if (isActive !== undefined) target.isActive = isActive;
await target.save();
// Revoke every device session so a deactivated (or moved) user cannot
// mint new access tokens; any current access token expires within 15m.
if (isActive === false || branchId !== undefined) {
await UserSession.deleteMany({ userId: target._id });
}
const obj = target.toObject() as unknown as Record<string, unknown>;
delete obj.passwordHash;
return NextResponse.json({ user: obj });
}
+21 -21
View File
@@ -4,7 +4,8 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User"; 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 { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { userLimitError, loadTier } from "@/lib/server/subscription"; import { userLimitError, loadTier } from "@/lib/server/subscription";
@@ -13,14 +14,14 @@ export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const filter: Record<string, unknown> = { businessId: auth!.businessId }; if (!scope.ok) return scope.response;
if (branchId) filter.branchId = branchId; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.branchId = scope.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();
return NextResponse.json({ users }); return NextResponse.json({ users });
} }
@@ -28,7 +29,7 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({ const createSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]), role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
branchId: z.string().min(1).optional(), branchId: objectId.optional(),
phoneNumber: z.string().min(9).max(15), phoneNumber: z.string().min(9).max(15),
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),
@@ -36,10 +37,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
@@ -48,22 +48,22 @@ export async function POST(req: NextRequest) {
} }
const data = parsed.data; const data = parsed.data;
if (auth!.role === "BRANCH_MANAGER") { if (auth.role === "BRANCH_MANAGER") {
if (data.role !== "DELIVERY") { if (data.role !== "DELIVERY") {
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 }); return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
} }
if (data.branchId && data.branchId !== auth!.branchId) { if (data.branchId && data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 }); return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
} }
} }
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined; const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined;
if (data.role !== "SUPER_ADMIN" && !branchId) { if (data.role !== "SUPER_ADMIN" && !branchId) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 }); return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
} }
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) {
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
} }
@@ -77,15 +77,15 @@ export async function POST(req: NextRequest) {
} }
// Enforce the plan's user cap (counts owner + managers + staff). // Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth!.businessId); const tier = await loadTier(auth.businessId);
const userErr = await userLimitError(auth!.businessId, tier); const userErr = await userLimitError(auth.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 }); 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 {
const user = await User.create({ const user = await User.create({
businessId: auth!.businessId, businessId: auth.businessId,
name: data.name, name: data.name,
role: data.role, role: data.role,
branchId: branchId ?? null, branchId: branchId ?? null,
@@ -96,7 +96,7 @@ export async function POST(req: NextRequest) {
if (data.role === "BRANCH_MANAGER" && branchId) { if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findOneAndUpdate( await Branch.findOneAndUpdate(
{ _id: branchId, businessId: auth!.businessId }, { _id: branchId, businessId: auth.businessId },
{ managerId: user._id } { managerId: user._id }
); );
} }
+45 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { isValidObjectId } from "mongoose";
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt"; import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
import { UserRole } from "@/lib/server/models/User"; import { UserRole } from "@/lib/server/models/User";
@@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?:
return auth.branchId; return auth.branchId;
} }
export const MANAGERS: UserRole[] = ["BRANCH_MANAGER", "SUPER_ADMIN"];
type Gate = { ok: true; auth: AccessTokenPayload } | { ok: false; response: NextResponse };
/**
* Authenticate the request and, when `roles` is given, authorize it.
* 401 = not signed in; 403 = signed in with the wrong role. Every route
* that limits roles must use this rather than relying on the admin pages
* redirecting — the PRD requires enforcement on the backend too.
*/
export function authorize(req: NextRequest, roles?: UserRole[]): Gate {
const auth = getAuth(req);
if (!auth) {
return { ok: false, response: NextResponse.json({ error: "Not authenticated" }, { status: 401 }) };
}
if (roles && !roles.includes(auth.role)) {
return {
ok: false,
response: NextResponse.json({ error: "You do not have permission to do this" }, { status: 403 }),
};
}
return { ok: true, auth };
}
type BranchParam = { ok: true; branchId: string | null } | { ok: false; response: NextResponse };
/**
* Resolve the `branchId` query parameter for a read route: owners may pick
* a branch or ALL, everyone else is pinned to their own branch. Rejects a
* malformed id with 400 instead of letting ObjectId() throw a 500.
*/
export function branchParam(auth: AccessTokenPayload, raw: string | null | undefined): BranchParam {
const branchId = resolveBranchScope(auth, raw);
if (branchId && !isValidObjectId(branchId)) {
return { ok: false, response: NextResponse.json({ error: "Invalid branchId" }, { status: 400 }) };
}
return { ok: true, branchId };
}
export function badRequest(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 400 });
}
export function isProd(): boolean { export function isProd(): boolean {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} }
+40
View File
@@ -0,0 +1,40 @@
// The business day for every tenant is the Kenyan calendar day
// (Africa/Nairobi, EAT). EAT is a fixed UTC+3 with no daylight saving, so a
// constant offset is exact — no timezone database needed.
//
// Never use `new Date().setHours(0)` or `toISOString().slice(0, 10)` for
// "today" on the server: on Vercel the server clock is UTC, which would
// start the business day at 03:00 EAT and push early-morning sales onto
// the previous day.
const EAT_OFFSET_MS = 3 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** Today's business day as YYYY-MM-DD. */
export function todayNairobi(now: Date = new Date()): string {
return new Date(now.getTime() + EAT_OFFSET_MS).toISOString().slice(0, 10);
}
export function isBusinessDate(value: string): boolean {
if (!DATE_RE.test(value)) return false;
const d = new Date(value + "T00:00:00.000Z");
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
/** The UTC instants [start, end) covering one business day. */
export function dayRangeNairobi(date: string): { start: Date; end: Date } {
if (!isBusinessDate(date)) throw new Error(`Invalid business date: ${date}`);
const start = new Date(new Date(date + "T00:00:00.000Z").getTime() - EAT_OFFSET_MS);
return { start, end: new Date(start.getTime() + DAY_MS) };
}
/** Start of the business day containing `now`. */
export function startOfTodayNairobi(now: Date = new Date()): Date {
return dayRangeNairobi(todayNairobi(now)).start;
}
/** Start of the current business month (1st, 00:00 EAT). */
export function startOfMonthNairobi(now: Date = new Date()): Date {
return dayRangeNairobi(todayNairobi(now).slice(0, 8) + "01").start;
}
+72 -19
View File
@@ -1,26 +1,60 @@
import { z } from "zod"; import { z } from "zod";
const schema = z.object({ const booleanFlag = z
NODE_ENV: z.enum(["development", "production", "test"]).default("development"), .enum(["true", "false", "1", "0", ""])
MONGODB_URI: z.string().min(1), .optional()
.transform((v) => v === "true" || v === "1");
JWT_ACCESS_SECRET: z.string().min(16), const schema = z
JWT_REFRESH_SECRET: z.string().min(16), .object({
JWT_ACCESS_TTL: z.string().default("15m"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
JWT_REFRESH_TTL: z.string().default("30d"), MONGODB_URI: z.string().min(1),
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"), JWT_ACCESS_SECRET: z.string().min(16),
DARAJA_CONSUMER_KEY: z.string().min(1), JWT_REFRESH_SECRET: z.string().min(16),
DARAJA_CONSUMER_SECRET: z.string().min(1), JWT_ACCESS_TTL: z.string().default("15m"),
DARAJA_SHORTCODE: z.string().min(1), JWT_REFRESH_TTL: z.string().default("30d"),
DARAJA_PASSKEY: z.string().min(1), DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
DARAJA_TRANSACTION_TYPE: z
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]) // M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it
.default("CustomerPayBillOnline"), // is OFF unless explicitly enabled. When off, the Daraja variables are
DARAJA_CALLBACK_URL: z.string().url(), // not required and every /api/mpesa route answers 404.
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(), FEATURE_MPESA: booleanFlag,
});
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
DARAJA_CONSUMER_KEY: z.string().optional(),
DARAJA_CONSUMER_SECRET: z.string().optional(),
DARAJA_SHORTCODE: z.string().optional(),
DARAJA_PASSKEY: z.string().optional(),
DARAJA_TRANSACTION_TYPE: z
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
.default("CustomerPayBillOnline"),
DARAJA_CALLBACK_URL: z.string().url().optional(),
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
})
.superRefine((env, ctx) => {
if (!env.FEATURE_MPESA) return;
const required = [
"DARAJA_CONSUMER_KEY",
"DARAJA_CONSUMER_SECRET",
"DARAJA_SHORTCODE",
"DARAJA_PASSKEY",
"DARAJA_CALLBACK_URL",
] as const;
for (const key of required) {
if (!env[key]) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "required when FEATURE_MPESA=true" });
}
}
// An open callback endpoint lets anyone mark a pending sale as paid.
if (env.NODE_ENV === "production" && !env.DARAJA_CALLBACK_IP_ALLOWLIST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DARAJA_CALLBACK_IP_ALLOWLIST"],
message: "required in production when FEATURE_MPESA=true",
});
}
});
type ServerEnv = z.infer<typeof schema>; type ServerEnv = z.infer<typeof schema>;
let cached: ServerEnv | null = null; let cached: ServerEnv | null = null;
@@ -35,3 +69,22 @@ export function getEnv(): ServerEnv {
cached = parsed.data; cached = parsed.data;
return cached; return cached;
} }
export function isMpesaEnabled(): boolean {
return getEnv().FEATURE_MPESA;
}
/** Daraja settings, only valid when FEATURE_MPESA is on (validated above). */
export function getDarajaEnv() {
const env = getEnv();
if (!env.FEATURE_MPESA) throw new Error("M-Pesa is disabled (FEATURE_MPESA is not true)");
return {
DARAJA_ENV: env.DARAJA_ENV,
DARAJA_CONSUMER_KEY: env.DARAJA_CONSUMER_KEY!,
DARAJA_CONSUMER_SECRET: env.DARAJA_CONSUMER_SECRET!,
DARAJA_SHORTCODE: env.DARAJA_SHORTCODE!,
DARAJA_PASSKEY: env.DARAJA_PASSKEY!,
DARAJA_TRANSACTION_TYPE: env.DARAJA_TRANSACTION_TYPE,
DARAJA_CALLBACK_URL: env.DARAJA_CALLBACK_URL!,
};
}
+2
View File
@@ -10,6 +10,7 @@ export interface IProduct extends Document {
unitPrice: number; unitPrice: number;
sortOrder: number; sortOrder: number;
isActive: boolean; isActive: boolean;
lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -24,6 +25,7 @@ const productSchema = new Schema<IProduct>(
unitPrice: { type: Number, required: true, min: 0 }, unitPrice: { type: Number, required: true, min: 0 },
sortOrder: { type: Number, default: 0 }, sortOrder: { type: Number, default: 0 },
isActive: { type: Boolean, default: true, index: true }, isActive: { type: Boolean, default: true, index: true },
lowStockThreshold: { type: Number, default: 10, min: 0 },
}, },
{ timestamps: true } { timestamps: true }
); );
+1 -1
View File
@@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { getEnv } from "@/lib/server/env"; import { getDarajaEnv as getEnv } from "@/lib/server/env";
function baseUrl(): string { function baseUrl(): string {
return getEnv().DARAJA_ENV === "production" return getEnv().DARAJA_ENV === "production"
+16 -1
View File
@@ -8,7 +8,7 @@ import { Types } from "mongoose";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { Business, IBusiness } from "@/lib/server/models/Business"; import { Business, IBusiness } from "@/lib/server/models/Business";
import { TIERS, type TierId } from "@/lib/tiers"; import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export function tierOf(business: Pick<IBusiness, "tier">): TierId { export function tierOf(business: Pick<IBusiness, "tier">): TierId {
return (business.tier as TierId) ?? "STARTER"; return (business.tier as TierId) ?? "STARTER";
@@ -53,3 +53,18 @@ export async function loadTier(businessId: Types.ObjectId | string): Promise<Tie
const biz = await Business.findById(businessId).select("tier").lean(); const biz = await Business.findById(businessId).select("tier").lean();
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness); return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
} }
/**
* Returns a user-facing reason this tenant may not use the app right now,
* or null when access is allowed. Checked at login and on every token
* refresh, so a suspension takes effect within one access-token lifetime
* (15 minutes) even for devices that are already signed in.
*/
export async function tenantAccessError(businessId: Types.ObjectId | string): Promise<string | null> {
const biz = await Business.findById(businessId).select("isActive subscriptionStatus").lean();
if (!biz || !biz.isActive) return "This business account has been deactivated.";
if (!isUsable((biz.subscriptionStatus as SubscriptionStatus) ?? "TRIALING")) {
return "This business account is suspended. Contact support to restore access.";
}
return null;
}
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
import { isValidObjectId } from "mongoose";
/** A MongoDB ObjectId string. Validating up front turns a would-be CastError 500 into a 400. */
export const objectId = z
.string()
.trim()
.refine((v) => isValidObjectId(v) && /^[0-9a-f]{24}$/i.test(v), { message: "Invalid id" });
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import bcrypt from "bcryptjs";
import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import * as profit from "@/app/api/reports/profit/route";
import * as expenses from "@/app/api/expenses/route";
import * as purchases from "@/app/api/purchases/route";
import * as products from "@/app/api/products/route";
import * as productById from "@/app/api/products/[id]/route";
import * as userById from "@/app/api/users/[id]/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as summary from "@/app/api/dashboard/summary/route";
import * as login from "@/app/api/auth/login/route";
import * as signup from "@/app/api/auth/signup/route";
import * as stkpush from "@/app/api/mpesa/stkpush/route";
import * as callback from "@/app/api/mpesa/callback/route";
import { json, makeTenant, params, req, uuid } from "./helpers";
describe("role-based access on the backend", () => {
it("blocks cashiers from owner-level money endpoints with 403", async () => {
const t = await makeTenant();
const as = t.cashierA;
const responses = await Promise.all([
profit.GET(req("GET", "/api/reports/profit", { as })),
expenses.GET(req("GET", "/api/expenses", { as })),
purchases.GET(req("GET", "/api/purchases", { as })),
expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })),
purchases.POST(
req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } })
),
]);
expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]);
});
it("still lets a branch manager use them for their own branch", async () => {
const t = await makeTenant();
const res = await expenses.POST(
req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } })
);
expect(res.status).toBe(201);
expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200);
});
it("returns 401, not 403, when signed out", async () => {
expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401);
});
it("only lets the owner create or reprice products", async () => {
const t = await makeTenant();
const body = { label: "1L", sizeLiters: 1, unitPrice: 20 };
expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403);
expect(
(await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 })))
.status
).toBe(403);
expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201);
});
it("rejects a malformed branchId with 400 instead of crashing", async () => {
const t = await makeTenant();
const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner }));
expect(res.status).toBe(400);
});
});
describe("user deactivation", () => {
it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => {
const t = await makeTenant();
await UserSession.create({
userId: t.cashierA.id,
deviceId: uuid(),
refreshTokenHash: "h",
expiresAt: new Date(Date.now() + 86_400_000),
});
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
params({ id: t.cashierA.id })
);
expect(res.status).toBe(200);
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
});
it("does not let a manager touch another branch's staff", async () => {
const t = await makeTenant();
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
params({ id: t.cashierB.id })
);
expect(res.status).toBe(403);
});
it("does not let the owner deactivate themselves", async () => {
const t = await makeTenant();
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.owner, body: { isActive: false } }),
params({ id: t.owner.id })
);
expect(res.status).toBe(403);
});
});
describe("tenant status", () => {
it("refuses login for a suspended business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
const res = await login.POST(
req("POST", "/api/auth/login", {
body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() },
})
);
expect(res.status).toBe(403);
});
it("still allows login for an active business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
const res = await login.POST(
req("POST", "/api/auth/login", {
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
})
);
expect(res.status).toBe(200);
});
});
describe("platform and M-Pesa hardening", () => {
it("refuses public signup with a platform-operator email", async () => {
const res = await signup.POST(
req("POST", "/api/auth/signup", {
body: {
businessName: "Takeover Ltd",
ownerName: "Mallory",
email: "operator@aquaflow.test",
phoneNumber: "0712345678",
password: "password123",
deviceId: uuid(),
},
})
);
expect(res.status).toBe(403);
});
it("hides every M-Pesa route when FEATURE_MPESA is off", async () => {
const t = await makeTenant();
const push = await stkpush.POST(
req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } })
);
const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } }));
expect([push.status, cb.status]).toEqual([404, 404]);
});
it("lets only the owner rename a branch", async () => {
const t = await makeTenant();
const body = { branchName: "Renamed" };
expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403);
const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA })));
expect(ok.status).toBe(200);
expect(ok.body.branch.branchName).toBe("Renamed");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay";
describe("Nairobi business day", () => {
it("rolls over at midnight EAT, not midnight UTC", () => {
// 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct.
expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12");
// 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct.
expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11");
});
it("maps a business date to the UTC range 21:00 -> 21:00", () => {
const { start, end } = dayRangeNairobi("2026-10-12");
expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z");
expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z");
});
it("starts the month on the 1st at 00:00 EAT", () => {
expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z");
expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z");
});
it("rejects malformed dates", () => {
expect(isBusinessDate("2026-02-30")).toBe(false);
expect(isBusinessDate("2026-2-3")).toBe(false);
expect(isBusinessDate("2026-02-28")).toBe(true);
expect(() => dayRangeNairobi("nope")).toThrow();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { MongoMemoryServer } from "mongodb-memory-server";
let server: MongoMemoryServer | null = null;
export async function setup({ provide }: { provide: (key: string, value: string) => void }) {
server = await MongoMemoryServer.create();
provide("mongoUri", server.getUri("aquaflow-test"));
}
export async function teardown() {
await server?.stop();
}
+133
View File
@@ -0,0 +1,133 @@
import { randomUUID } from "crypto";
import mongoose from "mongoose";
import { NextRequest } from "next/server";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User, UserRole } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { signAccessToken } from "@/lib/server/jwt";
import { ACCESS_COOKIE } from "@/lib/server/auth";
let phoneSeq = 0;
function nextPhone(): string {
phoneSeq += 1;
return "2547" + String(10_000_000 + phoneSeq).slice(-8);
}
export interface Actor {
id: string;
businessId: string;
role: UserRole;
branchId: string | null;
email: string;
}
export interface Tenant {
businessId: string;
owner: Actor;
branchA: string;
branchB: string;
managerA: Actor;
cashierA: Actor;
cashierB: Actor;
water20: string;
water5: string;
}
async function makeUser(
businessId: string,
role: UserRole,
branchId: string | null,
label: string
): Promise<Actor> {
const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`;
const user = await User.create({
businessId,
name: label,
role,
branchId,
phoneNumber: nextPhone(),
email,
passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in
});
return { id: user._id.toString(), businessId, role, branchId, email };
}
/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */
export async function makeTenant(name = "Tenant"): Promise<Tenant> {
const business = await Business.create({
name,
ownerId: new mongoose.Types.ObjectId(),
tier: "ENTERPRISE",
subscriptionStatus: "ACTIVE",
});
const businessId = business._id.toString();
const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner");
business.ownerId = new mongoose.Types.ObjectId(owner.id);
await business.save();
const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" });
const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" });
const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA");
const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA");
const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB");
const [p20, p5] = await Product.insertMany([
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 },
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 },
]);
return {
businessId,
owner,
branchA: a._id.toString(),
branchB: b._id.toString(),
managerA,
cashierA,
cashierB,
water20: p20._id.toString(),
water5: p5._id.toString(),
};
}
export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string {
return signAccessToken({
sub: actor.id,
businessId: actor.businessId,
role: actor.role,
branchId: actor.branchId,
deviceId,
});
}
export function req(
method: string,
path: string,
opts: { as?: Actor; body?: unknown; cookies?: Record<string, string> } = {}
): NextRequest {
const cookies: Record<string, string> = { ...(opts.cookies ?? {}) };
if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as);
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(new URL(path, "http://localhost"), {
method,
headers: {
"content-type": "application/json",
...(cookieHeader ? { cookie: cookieHeader } : {}),
},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
}
export function params<T extends Record<string, string>>(p: T) {
return { params: p };
}
export async function json<T = any>(res: Response): Promise<{ status: number; body: T }> {
return { status: res.status, body: (await res.json()) as T };
}
export function uuid(): string {
return randomUUID();
}
+26
View File
@@ -0,0 +1,26 @@
import { afterAll, beforeEach, inject } from "vitest";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
declare module "vitest" {
export interface ProvidedContext {
mongoUri: string;
}
}
process.env.MONGODB_URI = inject("mongoUri");
process.env.JWT_ACCESS_SECRET = "test-access-secret-0123456789";
process.env.JWT_REFRESH_SECRET = "test-refresh-secret-0123456789";
process.env.PLATFORM_ADMIN_EMAILS = "operator@aquaflow.test";
beforeEach(async () => {
await connectDb();
const collections = await mongoose.connection.db!.collections();
await Promise.all(collections.map((c) => c.deleteMany({})));
await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes()));
});
afterAll(async () => {
await mongoose.disconnect();
global.__mongooseCache = { conn: null, promise: null };
});
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
globalSetup: ["tests/globalSetup.ts"],
setupFiles: ["tests/setup.ts"],
// Route handlers share one cached mongoose connection per worker, so
// run files sequentially against the single in-memory server.
fileParallelism: false,
testTimeout: 30_000,
hookTimeout: 120_000,
},
});