Enforce roles and tenant status on the server, and use the Kenyan business day
The admin pages redirected cashiers away, but the API itself let them read profit, expenses and purchases and record expenses and purchases. Branch managers could also change product prices for every branch. Every gated route now goes through authorize(), which answers 401 when signed out and 403 for the wrong role. - Owners and managers only: profit report, expenses, purchases, users, morning dispatch and evening count. Owner only: create and edit products, rename branches. - PATCH /api/users/:id deactivates or reactivates staff (managers: their own cashiers only) and revokes the user's device sessions. PATCH /api/branches/:id renames a branch. - Login and token refresh refuse inactive or SUSPENDED businesses. - Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered operator email can no longer be claimed. - M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the pilot): its routes return 404 and the Daraja variables are optional. Turning it on in production requires the callback IP allowlist. - "Today" and the default month now follow Africa/Nairobi instead of the server's UTC clock, so the business day no longer starts at 03:00 EAT. - Malformed ids in query strings and bodies return 400 instead of a 500, the inventory routes check the product belongs to the tenant, and the reconciliation lookups are tenant-scoped. - Branch-scoped staff see their branch's override price from GET /api/products. - Adds a vitest suite against an in-memory MongoDB (npm test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+45
-1
@@ -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 { UserRole } from "@/lib/server/models/User";
|
||||
|
||||
@@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?:
|
||||
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 {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,26 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
MONGODB_URI: z.string().min(1),
|
||||
const booleanFlag = z
|
||||
.enum(["true", "false", "1", "0", ""])
|
||||
.optional()
|
||||
.transform((v) => v === "true" || v === "1");
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||
JWT_REFRESH_TTL: z.string().default("30d"),
|
||||
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
||||
const schema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
MONGODB_URI: z.string().min(1),
|
||||
|
||||
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||
DARAJA_CONSUMER_KEY: z.string().min(1),
|
||||
DARAJA_CONSUMER_SECRET: z.string().min(1),
|
||||
DARAJA_SHORTCODE: z.string().min(1),
|
||||
DARAJA_PASSKEY: z.string().min(1),
|
||||
DARAJA_TRANSACTION_TYPE: z
|
||||
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
|
||||
.default("CustomerPayBillOnline"),
|
||||
DARAJA_CALLBACK_URL: z.string().url(),
|
||||
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
|
||||
});
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||
JWT_REFRESH_TTL: z.string().default("30d"),
|
||||
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
||||
|
||||
// M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it
|
||||
// is OFF unless explicitly enabled. When off, the Daraja variables are
|
||||
// not required and every /api/mpesa route answers 404.
|
||||
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>;
|
||||
let cached: ServerEnv | null = null;
|
||||
@@ -35,3 +69,22 @@ export function getEnv(): ServerEnv {
|
||||
cached = parsed.data;
|
||||
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!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface IProduct extends Document {
|
||||
unitPrice: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ const productSchema = new Schema<IProduct>(
|
||||
unitPrice: { type: Number, required: true, min: 0 },
|
||||
sortOrder: { type: Number, default: 0 },
|
||||
isActive: { type: Boolean, default: true, index: true },
|
||||
lowStockThreshold: { type: Number, default: 10, min: 0 },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { getEnv } from "@/lib/server/env";
|
||||
import { getDarajaEnv as getEnv } from "@/lib/server/env";
|
||||
|
||||
function baseUrl(): string {
|
||||
return getEnv().DARAJA_ENV === "production"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Types } from "mongoose";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Business, IBusiness } from "@/lib/server/models/Business";
|
||||
import { TIERS, type TierId } from "@/lib/tiers";
|
||||
import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
||||
return (business.tier as TierId) ?? "STARTER";
|
||||
@@ -52,4 +52,19 @@ export async function userLimitError(
|
||||
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
|
||||
const biz = await Business.findById(businessId).select("tier").lean();
|
||||
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
Reference in New Issue
Block a user