Initial commit: NetsKey ERP (copper & cream redesign)

This commit is contained in:
EOH Developer
2026-10-02 23:48:11 -07:00
commit 3dc6eef18d
86 changed files with 9176 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { getDeviceId } from "@/lib/deviceId";
export class ApiError extends Error {
status: number;
body: any;
constructor(message: string, status: number, body?: any) {
super(message);
this.status = status;
this.body = body;
}
}
let refreshInFlight: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: getDeviceId() }),
})
.then((r) => r.ok)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
export async function apiFetch<T = any>(
path: string,
options: RequestInit = {},
_retried = false
): Promise<T> {
const res = await fetch(path, {
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
});
if (res.status === 401 && !_retried && !path.startsWith("/api/auth/")) {
const refreshed = await tryRefresh();
if (refreshed) {
return apiFetch<T>(path, options, true);
}
}
const isJson = res.headers.get("content-type")?.includes("application/json");
const body = isJson ? await res.json().catch(() => ({})) : null;
if (!res.ok) {
throw new ApiError(body?.error ?? "Request failed", res.status, body);
}
return body as T;
}
+29
View File
@@ -0,0 +1,29 @@
const STORAGE_KEY = "aquaflow_device_id";
/**
* A stable per-device identifier the server uses for device binding (max
* simultaneous DELIVERY sessions) and refresh-token session tracking.
* Persisted in localStorage — NOT sessionStorage or an in-memory variable
* — precisely because it must survive the browser/PWA being closed and
* reopened; that persistence is exactly what makes it "this device"
* rather than "this tab".
*/
export function getDeviceId(): string {
if (typeof window === "undefined") return "";
let id = localStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, id);
}
return id;
}
export function getDeviceLabel(): string {
if (typeof navigator === "undefined") return "Unknown device";
const ua = navigator.userAgent;
const isAndroid = /Android/i.test(ua);
const isIOS = /iPhone|iPad/i.test(ua);
const browser = /Chrome/i.test(ua) ? "Chrome" : /Firefox/i.test(ua) ? "Firefox" : /Safari/i.test(ua) ? "Safari" : "Browser";
const platform = isAndroid ? "Android" : isIOS ? "iOS" : "Desktop";
return browser + " on " + platform;
}
+98
View File
@@ -0,0 +1,98 @@
import { openDB, DBSchema, IDBPDatabase } from "idb";
import { apiFetch } from "@/lib/api";
export interface QueuedCashSale {
clientUuid: string;
customerName?: string;
customerPhone: string;
productId: string;
quantity: number;
clientSubmittedAt: string;
synced: boolean;
}
interface QueueDB extends DBSchema {
cash_sales: { key: string; value: QueuedCashSale };
}
const DB_NAME = "aquaflow-offline";
const STORE = "cash_sales";
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
function getDb() {
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
if (!dbPromise) {
dbPromise = openDB<QueueDB>(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE, { keyPath: "clientUuid" });
},
});
}
return dbPromise;
}
export function generateClientUuid(): string {
return crypto.randomUUID();
}
export async function queueCashSale(sale: Omit<QueuedCashSale, "synced">): Promise<void> {
const db = await getDb();
await db.put(STORE, { ...sale, synced: false });
}
export async function getPendingSales(): Promise<QueuedCashSale[]> {
const db = await getDb();
const all = await db.getAll(STORE);
return all.filter((s) => !s.synced);
}
async function markSynced(clientUuids: string[]) {
const db = await getDb();
const tx = db.transaction(STORE, "readwrite");
for (const id of clientUuids) {
const row = await tx.store.get(id);
if (row) {
row.synced = true;
await tx.store.put(row);
}
}
await tx.done;
}
export async function flushQueue(): Promise<{ synced: number; failed: number }> {
const pending = await getPendingSales();
if (pending.length === 0) return { synced: 0, failed: 0 };
try {
const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }),
});
const syncedIds = result.inserted.map((r) => r.clientUuid);
await markSynced(syncedIds);
return { synced: syncedIds.length, failed: pending.length - syncedIds.length };
} catch {
return { synced: 0, failed: pending.length };
}
}
export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) {
if (typeof window === "undefined") return () => {};
const handler = async () => {
const result = await flushQueue();
onFlush?.(result);
};
window.addEventListener("online", handler);
if (navigator.onLine) handler();
const interval = setInterval(() => {
if (navigator.onLine) handler();
}, 30000);
return () => {
window.removeEventListener("online", handler);
clearInterval(interval);
};
}
+4
View File
@@ -0,0 +1,4 @@
export function isValidKenyanPhone(raw: string): boolean {
const digits = raw.replace(/\s|-/g, "").replace(/^\+/, "");
return /^0(7|1)\d{8}$/.test(digits) || /^254(7|1)\d{8}$/.test(digits) || /^(7|1)\d{8}$/.test(digits);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { Transaction } from "@/lib/types";
export function useTransactionFeed(branchId: string, maxItems = 50, intervalMs = 4000) {
const [events, setEvents] = useState<Transaction[]>([]);
const [connected, setConnected] = useState(true);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const branchParam = branchId === "ALL" ? "" : "?branchId=" + branchId;
const result = await apiFetch<{ transactions: Transaction[] }>(
"/api/transactions" + (branchParam ? branchParam + "&limit=" + maxItems : "?limit=" + maxItems)
);
if (!cancelled) {
setEvents(result.transactions);
setConnected(true);
}
} catch {
if (!cancelled) setConnected(false);
}
}
poll();
timerRef.current = setInterval(poll, intervalMs);
return () => {
cancelled = true;
if (timerRef.current) clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [branchId, intervalMs]);
return { events, connected };
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest } from "next/server";
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
import { UserRole } from "@/lib/server/models/User";
export const ACCESS_COOKIE = "access_token";
export const REFRESH_COOKIE = "refresh_token";
export function getAuth(req: NextRequest): AccessTokenPayload | null {
const token = req.cookies.get(ACCESS_COOKIE)?.value;
if (!token) return null;
try {
return verifyAccessToken(token);
} catch {
return null;
}
}
export function requireRole(auth: AccessTokenPayload | null, ...roles: UserRole[]): boolean {
if (!auth) return false;
return roles.includes(auth.role);
}
export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?: string | null): string | null {
if (auth.role === "SUPER_ADMIN") {
return requestedBranchId && requestedBranchId !== "ALL" ? requestedBranchId : null;
}
return auth.branchId;
}
export function isProd(): boolean {
return process.env.NODE_ENV === "production";
}
/**
* Cookie options for both session cookies. Since the frontend and API now
* live in the SAME Next.js app on the SAME Vercel domain, these are
* always same-site — no SameSite=None/cross-site complexity needed here,
* unlike the earlier split Vercel+Render deployment.
*/
export function accessCookieOptions() {
return {
httpOnly: true,
secure: isProd(),
sameSite: "lax" as const,
maxAge: 15 * 60,
path: "/",
};
}
export function refreshCookieOptions(maxAgeSeconds: number) {
return {
httpOnly: true,
secure: isProd(),
sameSite: "lax" as const,
maxAge: maxAgeSeconds,
path: "/api/auth",
};
}
+37
View File
@@ -0,0 +1,37 @@
import mongoose from "mongoose";
/**
* Vercel serverless functions can be invoked many times against the same
* warm container, and Next.js hot-reloads modules in dev — without
* caching, each invocation would open a brand new MongoDB connection and
* quickly exhaust Atlas's connection limit. This caches the connection
* (and the in-flight connect promise, to avoid a race when multiple
* requests hit a cold container simultaneously) on the Node.js global
* object, which survives across invocations within the same warm
* container/module scope.
*/
declare global {
// eslint-disable-next-line no-var
var __mongooseCache: { conn: typeof mongoose | null; promise: Promise<typeof mongoose> | null } | undefined;
}
const cache = global.__mongooseCache ?? { conn: null, promise: null };
global.__mongooseCache = cache;
export async function connectDb(): Promise<typeof mongoose> {
if (cache.conn) return cache.conn;
if (!cache.promise) {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
mongoose.set("strictQuery", true);
cache.promise = mongoose.connect(uri, {
maxPoolSize: 5,
});
}
cache.conn = await cache.promise;
return cache.conn;
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
MONGODB_URI: z.string().min(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),
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(),
});
type ServerEnv = z.infer<typeof schema>;
let cached: ServerEnv | null = null;
export function getEnv(): ServerEnv {
if (cached) return cached;
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
cached = parsed.data;
return cached;
}
+39
View File
@@ -0,0 +1,39 @@
import jwt, { SignOptions } from "jsonwebtoken";
import crypto from "crypto";
import { getEnv } from "@/lib/server/env";
import { UserRole } from "@/lib/server/models/User";
export interface AccessTokenPayload {
sub: string;
role: UserRole;
branchId: string | null;
deviceId: string;
}
export function signAccessToken(payload: AccessTokenPayload): string {
const env = getEnv();
const options: SignOptions = { expiresIn: env.JWT_ACCESS_TTL as SignOptions["expiresIn"] };
return jwt.sign(payload, env.JWT_ACCESS_SECRET, options);
}
export function verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, getEnv().JWT_ACCESS_SECRET) as AccessTokenPayload;
}
export function generateRefreshToken(): string {
return crypto.randomBytes(48).toString("hex");
}
export function hashRefreshToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function refreshTtlMs(): number {
const ttl = getEnv().JWT_REFRESH_TTL;
const match = ttl.match(/^(\d+)([smhd])$/);
if (!match) return 30 * 24 * 60 * 60 * 1000;
const value = Number(match[1]);
const unit = match[2];
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return value * multipliers[unit];
}
+22
View File
@@ -0,0 +1,22 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IBranch extends Document {
_id: Types.ObjectId;
branchName: string;
locationCity: string;
managerId: Types.ObjectId | null;
createdAt: Date;
updatedAt: Date;
}
const branchSchema = new Schema<IBranch>(
{
branchName: { type: String, required: true, trim: true },
locationCity: { type: String, required: true, trim: true },
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: true }
);
export const Branch =
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
+43
View File
@@ -0,0 +1,43 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IDailyInventory extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
productId: Types.ObjectId;
date: string; // YYYY-MM-DD, branch-local calendar day — see note below
morningDispatched: number;
eveningPhysicalCount: number | null;
recordedBy: Types.ObjectId | null;
createdAt: Date;
updatedAt: Date;
}
/**
* One document per branch, per product (size), per day — a 5L and a 20L
* are physically different stock and must reconcile independently.
* `date` is stored as a plain YYYY-MM-DD string rather than a Date so
* "today" comparisons are unambiguous and don't depend on server timezone
* math; the app writes it using the branch's local calendar day.
*/
const dailyInventorySchema = new Schema<IDailyInventory>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
date: { type: String, required: true },
morningDispatched: { type: Number, default: 0, min: 0 },
eveningPhysicalCount: { type: Number, default: null, min: 0 },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: true }
);
// Required compound index: {branch_id: 1, date: 1} — this is the exact
// access pattern for both "today's dispatch entry for this branch" writes
// and the reconciliation aggregation's $match stage.
dailyInventorySchema.index({ branchId: 1, date: 1 });
// One ledger row per branch+product+day.
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
export const DailyInventory =
(models.DailyInventory as Model<IDailyInventory>) ||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
+48
View File
@@ -0,0 +1,48 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
// Business expense categories. Kept as a constrained string set so the UI
// can offer a dropdown while the owner can still extend later via code.
export const EXPENSE_CATEGORIES = [
"Transport",
"Electricity",
"Rent",
"Salaries",
"Repairs",
"Packaging",
"Other",
] as const;
export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number];
export interface IExpense extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
category: ExpenseCategory;
amount: number;
description: string;
reference: string | null;
notes: string | null;
recordedBy: Types.ObjectId;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const expenseSchema = new Schema<IExpense>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
category: { type: String, enum: EXPENSE_CATEGORIES, required: true },
amount: { type: Number, required: true, min: 0 },
description: { type: String, required: true, trim: true },
reference: { type: String, default: null, trim: true },
notes: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true } // createdAt/updatedAt from the server clock, never client input
);
expenseSchema.index({ branchId: 1, createdAt: -1 });
expenseSchema.index({ category: 1 });
// `models.Expense` guard avoids OverwriteModelError on serverless warm
// re-invocations where the module is evaluated more than once.
export const Expense =
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
+31
View File
@@ -0,0 +1,31 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IMpesaCallbackLog extends Document {
_id: Types.ObjectId;
sourceIp: string;
checkoutRequestId: string | null;
resultCode: number | null;
matchedTransactionId: Types.ObjectId | null;
rawPayload: unknown;
processingNote: string;
receivedAt: Date;
}
const mpesaCallbackLogSchema = new Schema<IMpesaCallbackLog>(
{
sourceIp: { type: String, required: true },
checkoutRequestId: { type: String, default: null, index: true },
resultCode: { type: Number, default: null },
matchedTransactionId: { type: Schema.Types.ObjectId, ref: "Transaction", default: null },
rawPayload: { type: Schema.Types.Mixed, required: true },
processingNote: { type: String, required: true },
receivedAt: { type: Date, default: () => new Date() },
},
{ timestamps: false }
);
mpesaCallbackLogSchema.index({ receivedAt: -1 });
export const MpesaCallbackLog =
(models.MpesaCallbackLog as Model<IMpesaCallbackLog>) ||
model<IMpesaCallbackLog>("MpesaCallbackLog", mpesaCallbackLogSchema);
+52
View File
@@ -0,0 +1,52 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IProduct extends Document {
_id: Types.ObjectId;
label: string;
sizeLiters: number;
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
category: string; // e.g. "Water", "Eggs"
unitPrice: number;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
const productSchema = new Schema<IProduct>(
{
label: { type: String, required: true, trim: true },
sizeLiters: { type: Number, required: true, min: 0.1 },
unit: { type: String, default: "L", trim: true },
category: { type: String, default: "Water", trim: true },
unitPrice: { type: Number, required: true, min: 0 },
sortOrder: { type: Number, default: 0 },
isActive: { type: Boolean, default: true, index: true },
},
{ timestamps: true }
);
export const Product =
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
// Branch-specific price override — optional, falls back to Product.unitPrice.
export interface IBranchPricing extends Document {
branchId: Types.ObjectId;
productId: Types.ObjectId;
unitPrice: number;
updatedAt: Date;
}
const branchPricingSchema = new Schema<IBranchPricing>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
unitPrice: { type: Number, required: true, min: 0 },
},
{ timestamps: { createdAt: false, updatedAt: true } }
);
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
export const BranchPricing =
(models.BranchPricing as Model<IBranchPricing>) ||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
+42
View File
@@ -0,0 +1,42 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
// A recorded stock purchase from a supplier. Completing a purchase is what
// increases branch stock. Price fields are snapshotted onto the record so
// historical purchases never change when a product's price changes later.
export interface IPurchase extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
supplierName: string;
productId: Types.ObjectId | null;
productLabel: string; // snapshot of the product name at purchase time
quantity: number;
unitCost: number; // buying price, snapshotted
totalCost: number; // quantity * unitCost, snapshotted
reference: string | null;
notes: string | null;
recordedBy: Types.ObjectId;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const purchaseSchema = new Schema<IPurchase>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
supplierName: { type: String, required: true, trim: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", default: null },
productLabel: { type: String, required: true, trim: true },
quantity: { type: Number, required: true, min: 1 },
unitCost: { type: Number, required: true, min: 0 },
totalCost: { type: Number, required: true, min: 0 },
reference: { type: String, default: null, trim: true },
notes: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: true }
);
purchaseSchema.index({ branchId: 1, createdAt: -1 });
purchaseSchema.index({ productId: 1 });
export const Purchase =
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
+95
View File
@@ -0,0 +1,95 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export type PaymentMethod = "CASH" | "MPESA";
export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface ITransaction extends Document {
_id: Types.ObjectId;
branchId: Types.ObjectId;
staffId: Types.ObjectId;
customerName: string | null;
customerPhone: string;
productId: Types.ObjectId;
sizeLiters: number; // snapshotted at sale time
quantity: number;
unitPrice: number; // snapshotted at sale time
amountTotal: number;
paymentMethod: PaymentMethod;
paymentStatus: PaymentStatus;
mpesaDetails: {
checkoutRequestId: string | null;
merchantRequestId: string | null;
receiptNumber: string | null;
resultCode: number | null;
resultDesc: string | null;
};
clientUuid: string; // idempotency key generated on-device
syncedFromOffline: boolean;
// TAMPER PREVENTION: this is what the device *claimed* the time was when
// the sale happened — informational only, e.g. for offline-queue
// ordering in the UI. It is NEVER used for reconciliation, reporting, or
// as the authoritative sale time, precisely because a delivery worker
// could set their phone's clock backward/forward to forge history. The
// authoritative time is `createdAt`, which Mongoose/MongoDB stamps from
// the SERVER's clock at insert time and which the API never accepts as
// client input (see transactions.routes.ts / mpesa.routes.ts — neither
// route reads a timestamp field off the request body).
clientSubmittedAt: Date | null;
createdAt: Date; // authoritative — server clock
updatedAt: Date;
}
const transactionSchema = new Schema<ITransaction>(
{
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
customerName: { type: String, default: null, trim: true },
customerPhone: { type: String, required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
sizeLiters: { type: Number, required: true },
quantity: { type: Number, required: true, min: 1 },
unitPrice: { type: Number, required: true, min: 0 },
amountTotal: { type: Number, required: true, min: 0 },
paymentMethod: { type: String, enum: ["CASH", "MPESA"], required: true },
paymentStatus: {
type: String,
enum: ["PENDING", "SUCCESS", "FAILED"],
required: true,
default: "PENDING",
},
mpesaDetails: {
checkoutRequestId: { type: String, default: null },
merchantRequestId: { type: String, default: null },
receiptNumber: { type: String, default: null },
resultCode: { type: Number, default: null },
resultDesc: { type: String, default: null },
},
clientUuid: { type: String, required: true },
syncedFromOffline: { type: Boolean, default: false },
clientSubmittedAt: { type: Date, default: null },
},
{ timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input
);
// Required indexes: branch_id, created_at, mpesa checkout id.
transactionSchema.index({ branchId: 1 });
transactionSchema.index({ createdAt: -1 });
transactionSchema.index({ "mpesaDetails.checkoutRequestId": 1 });
// Idempotency: an offline-queued sale replayed twice must never duplicate.
transactionSchema.index({ clientUuid: 1 }, { unique: true });
// Practical addition: the actual hot query pattern for both the live feed
// and reconciliation is "this branch, most recent first" / "this branch,
// today" — a compound index serves that far better than the two single
// indexes above combined.
transactionSchema.index({ branchId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 });
export const Transaction =
(models.Transaction as Model<ITransaction>) ||
model<ITransaction>("Transaction", transactionSchema);
+59
View File
@@ -0,0 +1,59 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
import bcrypt from "bcryptjs";
export type UserRole = "DELIVERY" | "BRANCH_MANAGER" | "SUPER_ADMIN";
export const USER_ROLES: UserRole[] = ["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"];
export interface IUser extends Document {
_id: Types.ObjectId;
name: string;
role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
email: string; // login identifier for every role, via the single /login page
passwordHash: string;
isActive: boolean;
failedLoginAttempts: number;
lockedUntil: Date | null;
createdAt: Date;
updatedAt: Date;
comparePassword(password: string): Promise<boolean>;
}
const userSchema = new Schema<IUser>(
{
name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 },
lockedUntil: { type: Date, default: null },
},
{ timestamps: true }
);
// Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no
// per-role credential branching left here — just the branch scoping rule.
userSchema.pre("validate", function (next) {
if (this.role !== "SUPER_ADMIN" && !this.branchId) {
return next(new Error("branchId is required for all roles except SUPER_ADMIN"));
}
if (this.role === "SUPER_ADMIN" && this.branchId) {
return next(new Error("SUPER_ADMIN accounts must not be scoped to a single branchId"));
}
next();
});
userSchema.methods.comparePassword = async function (password: string): Promise<boolean> {
if (!this.passwordHash) return false;
return bcrypt.compare(password, this.passwordHash);
};
export const User =
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
+44
View File
@@ -0,0 +1,44 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
/**
* One document per (user, device). This is what makes "persistent sessions
* that survive poor network coverage" possible — a device holds a
* long-lived refresh token (30d default) here, and uses it to mint fresh
* short-lived access tokens (15m) without re-entering a PIN. It is also
* how device binding is enforced: a DELIVERY account is capped at
* DELIVERY_MAX_DEVICES active sessions (see auth.routes.ts), counted by
* distinct `deviceId`, not by token — logging in again on the same device
* simply rotates that device's row rather than consuming a new slot.
*/
export interface IUserSession extends Document {
_id: Types.ObjectId;
userId: Types.ObjectId;
deviceId: string; // client-generated UUID, persisted in device storage
deviceLabel: string | null; // e.g. "Chrome on Android" — for the "active devices" UI
refreshTokenHash: string; // the raw refresh token is never stored, only its hash
lastUsedAt: Date;
expiresAt: Date;
createdAt: Date;
}
const userSessionSchema = new Schema<IUserSession>(
{
userId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
deviceId: { type: String, required: true },
deviceLabel: { type: String, default: null },
refreshTokenHash: { type: String, required: true },
lastUsedAt: { type: Date, default: () => new Date() },
expiresAt: { type: Date, required: true },
},
{ timestamps: { createdAt: true, updatedAt: false } }
);
userSessionSchema.index({ userId: 1, deviceId: 1 }, { unique: true });
// TTL index — MongoDB automatically deletes expired sessions, so a stale
// refresh token can never be replayed after its expiry even if somehow
// leaked.
userSessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
export const UserSession =
(models.UserSession as Model<IUserSession>) ||
model<IUserSession>("UserSession", userSessionSchema);
+122
View File
@@ -0,0 +1,122 @@
import axios from "axios";
import { getEnv } from "@/lib/server/env";
function baseUrl(): string {
return getEnv().DARAJA_ENV === "production"
? "https://api.safaricom.co.ke"
: "https://sandbox.safaricom.co.ke";
}
let cachedToken: { token: string; expiresAt: number } | null = null;
export async function getDarajaToken(): Promise<string> {
if (cachedToken && Date.now() < cachedToken.expiresAt) {
return cachedToken.token;
}
const env = getEnv();
const credentials = Buffer.from(`${env.DARAJA_CONSUMER_KEY}:${env.DARAJA_CONSUMER_SECRET}`).toString(
"base64"
);
const res = await axios.get(`${baseUrl()}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${credentials}` },
timeout: 10_000,
});
const token = res.data.access_token as string;
const expiresIn = Number(res.data.expires_in ?? 3599);
cachedToken = { token, expiresAt: Date.now() + (expiresIn - 60) * 1000 };
return token;
}
function darajaTimestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
d.getFullYear().toString() +
pad(d.getMonth() + 1) +
pad(d.getDate()) +
pad(d.getHours()) +
pad(d.getMinutes()) +
pad(d.getSeconds())
);
}
function darajaPassword(shortcode: string, passkey: string, timestamp: string) {
return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");
}
export interface StkPushParams {
phone: string;
amount: number;
accountReference: string;
transactionDesc: string;
}
export interface StkPushResult {
MerchantRequestID: string;
CheckoutRequestID: string;
ResponseCode: string;
ResponseDescription: string;
CustomerMessage: string;
}
export async function initiateStkPush(params: StkPushParams): Promise<StkPushResult> {
const env = getEnv();
const token = await getDarajaToken();
const timestamp = darajaTimestamp();
const password = darajaPassword(env.DARAJA_SHORTCODE, env.DARAJA_PASSKEY, timestamp);
const body = {
BusinessShortCode: env.DARAJA_SHORTCODE,
Password: password,
Timestamp: timestamp,
TransactionType: env.DARAJA_TRANSACTION_TYPE,
Amount: Math.round(params.amount),
PartyA: params.phone,
PartyB: env.DARAJA_SHORTCODE,
PhoneNumber: params.phone,
CallBackURL: env.DARAJA_CALLBACK_URL,
AccountReference: params.accountReference.slice(0, 12),
TransactionDesc: params.transactionDesc.slice(0, 13),
};
const res = await axios.post(`${baseUrl()}/mpesa/stkpush/v1/processrequest`, body, {
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 15_000,
});
return res.data as StkPushResult;
}
export interface StkQueryResult {
ResponseCode: string;
ResponseDescription: string;
MerchantRequestID: string;
CheckoutRequestID: string;
ResultCode: string;
ResultDesc: string;
}
export async function queryStkStatus(checkoutRequestId: string): Promise<StkQueryResult> {
const env = getEnv();
const token = await getDarajaToken();
const timestamp = darajaTimestamp();
const password = darajaPassword(env.DARAJA_SHORTCODE, env.DARAJA_PASSKEY, timestamp);
const res = await axios.post(
`${baseUrl()}/mpesa/stkpushquery/v1/query`,
{
BusinessShortCode: env.DARAJA_SHORTCODE,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestId,
},
{
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
timeout: 10_000,
}
);
return res.data as StkQueryResult;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Normalizes Kenyan phone numbers to 2547XXXXXXXX / 2541XXXXXXXX — the
* format Daraja requires and what we use as the canonical stored form.
* Accepts 07..., 01..., +2547..., 2547...
*/
export function normalizeKenyanPhone(raw: string): string {
const digits = raw.replace(/\s|-/g, "").replace(/^\+/, "");
let normalized: string | null = null;
if (/^0(7|1)\d{8}$/.test(digits)) {
normalized = "254" + digits.slice(1);
} else if (/^254(7|1)\d{8}$/.test(digits)) {
normalized = digits;
} else if (/^(7|1)\d{8}$/.test(digits)) {
normalized = "254" + digits;
}
if (!normalized) {
throw new Error("Invalid phone number. Use 07XXXXXXXX, 01XXXXXXXX, or +254XXXXXXXXX.");
}
return normalized;
}
export function isValidKenyanPhone(raw: string): boolean {
try {
normalizeKenyanPhone(raw);
return true;
} catch {
return false;
}
}
+83
View File
@@ -0,0 +1,83 @@
export type UserRole = "DELIVERY" | "BRANCH_MANAGER" | "SUPER_ADMIN";
export type PaymentMethod = "CASH" | "MPESA";
export type PaymentStatus = "PENDING" | "SUCCESS" | "FAILED";
export interface Branch {
_id: string;
branchName: string;
locationCity: string;
managerId: string | null;
}
export interface AuthUser {
id: string;
name: string;
role: UserRole;
branchId: string | null;
branchName?: string | null;
phoneNumber?: string;
email?: string | null;
}
export interface Product {
_id: string;
label: string;
sizeLiters: number;
unit?: string;
category?: string;
unitPrice: number;
sortOrder: number;
isActive: boolean;
}
export interface Transaction {
_id: string;
branchId: string | { _id: string; branchName: string };
staffId: string;
customerName: string | null;
customerPhone: string;
sizeLiters: number;
quantity: number;
unitPrice: number;
amountTotal: number;
paymentMethod: PaymentMethod;
paymentStatus: PaymentStatus;
mpesaDetails?: {
checkoutRequestId: string | null;
receiptNumber: string | null;
resultDesc: string | null;
};
createdAt: string;
}
export interface DashboardSummary {
scope: "global" | "branch";
totals: {
cashTotal: number;
mpesaTotal: number;
totalSales: number;
pendingMpesa: number;
};
branchRanking: {
branchId: string;
branchName: string;
locationCity: string;
revenue: number;
salesCount: number;
cashTotal: number;
mpesaTotal: number;
}[];
}
export interface ReconciliationRow {
branchId: string;
branchName: string;
productId: string;
productLabel: string;
sizeLiters: number;
morningDispatched: number;
eveningPhysicalCount: number | null;
totalSold: number;
expectedRemaining: number;
reconciledOk: boolean | null;
}