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