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
+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);