Initial commit: NetsKey ERP (copper & cream redesign)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user