Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync #1

Merged
denis_ireri merged 4 commits from fix/pilot-readiness into main 2026-10-12 06:14:51 +00:00
70 changed files with 5305 additions and 502 deletions
+12 -1
View File
@@ -12,7 +12,18 @@ JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2 DELIVERY_MAX_DEVICES=2
# ---- Safaricom Daraja ---- # ---- Platform operator ----
# Comma-separated emails with cross-tenant access to /platform. Create these
# accounts with `npm run seed`; public signup refuses these addresses.
PLATFORM_ADMIN_EMAILS=
# ---- M-Pesa (optional, off for the pilot) ----
# M-Pesa STK Push is out of scope for the pilot. While this is false every
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
# If you turn it on in production, DARAJA_CALLBACK_IP_ALLOWLIST is required.
FEATURE_MPESA=false
# ---- Safaricom Daraja (only read when FEATURE_MPESA=true) ----
DARAJA_ENV=sandbox DARAJA_ENV=sandbox
DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx
DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx
+104
View File
@@ -0,0 +1,104 @@
# Pilot readiness changes
This branch (`fix/pilot-readiness`) closes the gaps found when the MVP was
reviewed against `PRD.md` and `REVIEW.md`. This page records what changed,
the decisions taken along the way, and what is still open.
## What changed
### Tenant isolation and roles (PRD 5.1)
- Every role-limited API route goes through `authorize()` in
`src/lib/server/auth.ts`: **401** when signed out, **403** for the wrong
role. Cashiers can no longer read profit, expenses or purchases, or
record expenses and purchases. Only the owner creates or edits products
and prices.
- `PATCH /api/users/:id` deactivates and reactivates staff (managers: only
their own cashiers). Deactivating or moving a user revokes their device
sessions.
- `PATCH /api/branches/:id` renames a branch (owner only).
- Login and token refresh refuse an inactive or `SUSPENDED` business.
- Public signup refuses any address in `PLATFORM_ADMIN_EMAILS`.
- Ids in query strings and bodies are validated (400, not a 500), inventory
routes check the product belongs to the tenant, and reconciliation
lookups are tenant-scoped.
### Stock ledger and transfers (PRD 5.2)
- `StockMovement` is the ledger: a branch's stock of a product is the sum
of its movements. Each movement names its cause, and `(type, refId)` is
unique, so a replayed sale or retried transfer never moves stock twice.
- Sales and confirmed M-Pesa payments deduct stock; purchases of a
catalogue product add it.
- `POST /api/stock/transfers` moves stock between branches.
`POST /api/stock/adjustments` records opening stock, breakage and
physical counts. `GET /api/stock` returns stock per branch with LOW/OUT
flags (per-product `lowStockThreshold`, default 10).
- The admin **Stock** page (`/admin/stock`) covers all of this, plus the
existing morning dispatch and evening count. The dashboard shows stock
on hand per branch.
### Offline POS and sync (PRD 5.3, 5.5)
- `POST /api/transactions/cash` answers each sale with `saved`,
`duplicate` or `rejected`. Previously a replayed sale was skipped
silently, the device never marked it synced, and once more than 100 rows
piled up every sync failed.
- The device queue (`src/lib/offlineQueue.ts`) is keyed by cashier and
branch, sends only the signed-in cashier's sales, chunks to 50 per
request, and stops retrying rejected sales (they are shown to the
cashier instead).
- A service worker (`public/sw.js`) caches the app shell, and the last
session and product list are kept on the phone, so the terminal opens
and sells with no network.
- Sales can contain several products, the customer phone is optional for
cash, and the terminal shows a printable receipt.
### Other
- "Today" and "this month" follow Africa/Nairobi, not the server's UTC
clock (`src/lib/server/businessDay.ts`).
- M-Pesa is behind `FEATURE_MPESA` (off by default; out of scope for the
pilot per PRD section 4).
## Decisions taken
| Decision | Why |
|---|---|
| Offline sales are always accepted, even if they take stock below zero. | The sale already happened. Refusing it would lose real cash. Negative stock is shown on the Stock page and fixed with a physical count. |
| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. |
| The server price is authoritative; a different price shown on the device is kept (`clientUnitPrice`) and flagged (`priceMismatch`). | Trusting the device's price would let a cashier under-charge. Overwriting it silently would hide a cash discrepancy. |
| A queued sale stays with the branch where it was recorded, even if the cashier has since moved. | That is where the stock left and the cash was taken. |
| A product deactivated while a phone was offline is still recorded at its price. | Same reason as the first row: the sale happened. |
## Rolling out to an existing database
- No migration is needed: new fields have defaults, and Mongoose creates
the new indexes on start-up.
- Existing branches start with **no stock on record**. Before the pilot,
each branch should record an opening count on the Stock page.
- Cashiers' phones upgrade their offline queue automatically; any unsynced
sales from the old version are sent on the next sync.
## Testing
- `npm test` runs 37 API tests against an in-memory MongoDB, covering role
enforcement, tenant isolation for sales, stock and transfers, suspension,
the sync protocol (including a 120-sale backlog replayed twice), and the
business day.
- The offline flow was also checked end to end in headless Chrome: sign
in, stop the server, reload the terminal, record two sales, restart the
server, confirm they sync once and deduct stock.
## Still open
- **Removing a branch.** Branches can be renamed but not removed. Someone
needs to decide what happens to a removed branch's stock and history.
- **Setting branch prices.** Branch price overrides are applied everywhere,
but there is no screen or API to set them yet.
- **Global email and phone uniqueness.** One person cannot work for two
businesses, and signup reveals whether an address is already registered.
- **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every
route.
- **Concurrent transfers.** Two transfers submitted at the same moment
could together send more than the branch holds.
- **Cash-basis profit.** The profit report counts all purchases in the
period as cost of goods.
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
times out still needs a manual check before taking cash.
- **Not modelled yet:** invites and email verification, empty-bottle and
deposit tracking, home-screen icons for the PWA.
+1778 -1
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -8,7 +8,9 @@
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"seed": "tsx scripts/seed.ts", "seed": "tsx scripts/seed.ts",
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts" "migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
@@ -29,10 +31,12 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"dotenv": "^16.4.5",
"mongodb-memory-server": "^10.1.4",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"dotenv": "^16.4.5" "vitest": "^2.1.9"
} }
} }
+86
View File
@@ -0,0 +1,86 @@
/* AquaFlow service worker: lets the cashier terminal open and run with no network.
*
* - Page loads (navigations): network first, falling back to the cached copy,
* and to the cached /terminal page for anything else, so a cashier who opens
* the app offline still lands on the till.
* - Next.js build assets (/_next/static/*): cache first. They are content-hashed
* and immutable, so a cached copy is always correct.
* - API calls (/api/*) and every non-GET request: never cached. Sales go through
* the IndexedDB queue in the page, not through this worker.
*
* Bump CACHE_VERSION to drop every cached page and asset on the next visit.
*/
const CACHE_VERSION = "aquaflow-v1";
const APP_SHELL = ["/terminal", "/login"];
async function cacheShell() {
const cache = await caches.open(CACHE_VERSION);
for (const path of APP_SHELL) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (!res.ok) continue;
await cache.put(path, res.clone());
// Also cache the scripts and styles the page needs to boot.
const html = await res.text();
const assets = Array.from(html.matchAll(/(?:src|href)="(\/_next\/static\/[^"]+)"/g), (m) => m[1]);
await Promise.all(
Array.from(new Set(assets)).map((url) =>
cache.match(url).then((hit) => hit || fetch(url).then((r) => r.ok && cache.put(url, r)))
)
);
} catch (e) {
// Offline during install: the runtime handlers below fill the cache later.
}
}
}
self.addEventListener("install", (event) => {
event.waitUntil(cacheShell().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) return;
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE_VERSION).then((c) => c.put(url.pathname, copy));
}
return res;
})
.catch(async () => {
const cache = await caches.open(CACHE_VERSION);
return (await cache.match(url.pathname)) || (await cache.match("/terminal")) || Response.error();
})
);
return;
}
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(
caches.open(CACHE_VERSION).then(async (cache) => {
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
})
);
}
});
+11 -2
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
import { useTransactionFeed } from "@/lib/pollingFeed"; import { useTransactionFeed } from "@/lib/pollingFeed";
import { AuthUser, Branch, DashboardSummary, ReconciliationRow } from "@/lib/types"; import { AuthUser, Branch, DashboardSummary, ReconciliationRow, StockLevel } from "@/lib/types";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton"; import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";
@@ -16,6 +16,7 @@ import LiveStatsRow from "@/components/admin/LiveStatsRow";
import BranchRankingList from "@/components/admin/BranchRankingList"; import BranchRankingList from "@/components/admin/BranchRankingList";
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix"; import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
import ReconciliationGrid from "@/components/admin/ReconciliationGrid"; import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
import StockSummaryCard from "@/components/admin/StockSummaryCard";
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -27,6 +28,7 @@ export default function AdminDashboardPage() {
const [selectedBranchId, setSelectedBranchId] = useState<string>("ALL"); const [selectedBranchId, setSelectedBranchId] = useState<string>("ALL");
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [reconciliation, setReconciliation] = useState<ReconciliationRow[]>([]); const [reconciliation, setReconciliation] = useState<ReconciliationRow[]>([]);
const [stock, setStock] = useState<StockLevel[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50); const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50);
@@ -75,12 +77,14 @@ export default function AdminDashboardPage() {
setLoading(true); setLoading(true);
try { try {
const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId; const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
const [summaryRes, reconRes] = await Promise.all([ const [summaryRes, reconRes, stockRes] = await Promise.all([
apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam), apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam),
apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam), apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam),
apiFetch<{ levels: StockLevel[] }>("/api/stock" + branchParam),
]); ]);
setSummary(summaryRes); setSummary(summaryRes);
setReconciliation(reconRes.rows); setReconciliation(reconRes.rows);
setStock(stockRes.levels);
} catch (err) { } catch (err) {
show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error"); show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error");
} }
@@ -138,6 +142,9 @@ export default function AdminDashboardPage() {
Platform Platform
</Link> </Link>
)} )}
<Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Stock
</Link>
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"> <Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Sizes Sizes
</Link> </Link>
@@ -183,6 +190,8 @@ export default function AdminDashboardPage() {
pendingMpesa={summary.totals.pendingMpesa} pendingMpesa={summary.totals.pendingMpesa}
/> />
<StockSummaryCard levels={stock} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<PaymentSplitDonut cashTotal={summary.totals.cashTotal} mpesaTotal={summary.totals.mpesaTotal} /> <PaymentSplitDonut cashTotal={summary.totals.cashTotal} mpesaTotal={summary.totals.mpesaTotal} />
<LiveStatsRow <LiveStatsRow
+37 -6
View File
@@ -25,6 +25,10 @@ export default function ProductsPage() {
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editPrice, setEditPrice] = useState(""); const [editPrice, setEditPrice] = useState("");
const [editThreshold, setEditThreshold] = useState("");
// Products and prices apply to every branch, so only the owner edits them.
const canEdit = me?.role === "SUPER_ADMIN";
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -82,14 +86,22 @@ export default function ProductsPage() {
async function handlePriceUpdate(id: string) { async function handlePriceUpdate(id: string) {
const price = parseFloat(editPrice); const price = parseFloat(editPrice);
const threshold = parseInt(editThreshold, 10);
if (!Number.isFinite(price) || price < 0) { if (!Number.isFinite(price) || price < 0) {
setError("Enter a valid price."); setError("Enter a valid price.");
return; return;
} }
if (!Number.isInteger(threshold) || threshold < 0) {
setError("Enter a whole number for the low-stock alert.");
return;
}
setSaving(true); setSaving(true);
try { try {
await apiFetch("/api/products/" + id, { method: "PATCH", body: JSON.stringify({ unitPrice: price }) }); await apiFetch("/api/products/" + id, {
show("Price updated", "success"); method: "PATCH",
body: JSON.stringify({ unitPrice: price, lowStockThreshold: threshold }),
});
show("Product updated", "success");
setEditingId(null); setEditingId(null);
await loadProducts(); await loadProducts();
} catch (err) { } catch (err) {
@@ -152,6 +164,7 @@ export default function ProductsPage() {
<p className="text-xs text-depth-500"> <p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions. it from new sales without touching past transactions.
{!canEdit && " Only the business owner can change products and prices."}
</p> </p>
</div> </div>
@@ -170,8 +183,12 @@ export default function ProductsPage() {
<p className="font-medium text-depth-900"> <p className="font-medium text-depth-900">
{p.label} <span className="text-depth-500">· {p.sizeLiters}L</span> {p.label} <span className="text-depth-500">· {p.sizeLiters}L</span>
</p> </p>
{editingId === p._id ? ( {!canEdit ? (
<div className="mt-1 flex items-center gap-2"> <p className="mt-0.5 font-mono text-sm text-depth-600">
KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10}
</p>
) : editingId === p._id ? (
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className="text-sm text-depth-500">KES</span> <span className="text-sm text-depth-500">KES</span>
<input <input
autoFocus autoFocus
@@ -180,8 +197,19 @@ export default function ProductsPage() {
step="0.01" step="0.01"
value={editPrice} value={editPrice}
onChange={(e) => setEditPrice(e.target.value)} onChange={(e) => setEditPrice(e.target.value)}
aria-label="Price"
className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm" className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/> />
<span className="text-sm text-depth-500">low stock at</span>
<input
type="number"
min="0"
step="1"
value={editThreshold}
onChange={(e) => setEditThreshold(e.target.value)}
aria-label="Low-stock alert level"
className="w-20 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/>
<button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950"> <button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
Save Save
</button> </button>
@@ -194,10 +222,11 @@ export default function ProductsPage() {
onClick={() => { onClick={() => {
setEditingId(p._id); setEditingId(p._id);
setEditPrice(String(p.unitPrice)); setEditPrice(String(p.unitPrice));
setEditThreshold(String(p.lowStockThreshold ?? 10));
}} }}
className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2" className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2"
> >
KES {p.unitPrice.toLocaleString()} · edit KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} · edit
</button> </button>
)} )}
</div> </div>
@@ -205,7 +234,7 @@ export default function ProductsPage() {
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}> <span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}>
{p.isActive ? "Active" : "Inactive"} {p.isActive ? "Active" : "Inactive"}
</span> </span>
{p.isActive ? ( {!canEdit ? null : p.isActive ? (
<ConfirmButton <ConfirmButton
label="Deactivate" label="Deactivate"
confirmLabel="Deactivate" confirmLabel="Deactivate"
@@ -228,6 +257,7 @@ export default function ProductsPage() {
)} )}
</div> </div>
{canEdit && (
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft"> <form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2> <h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
@@ -254,6 +284,7 @@ export default function ProductsPage() {
{saving ? "Adding…" : "Add size"} {saving ? "Adding…" : "Add size"}
</button> </button>
</form> </form>
)}
</div> </div>
</main> </main>
); );
+42 -6
View File
@@ -109,6 +109,26 @@ export default function StaffPage() {
setSaving(false); setSaving(false);
} }
function canManage(s: StaffMember): boolean {
if (!me || s._id === me.id) return false;
if (isSuperAdmin) return true;
return s.role === "DELIVERY" && s.branchId === me.branchId;
}
async function toggleActive(s: StaffMember) {
const next = !s.isActive;
if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
return;
}
try {
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ isActive: next }) });
show(`${s.name} ${next ? "reactivated" : "deactivated"}`, "success");
await loadStaff();
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not update this account", "error");
}
}
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -186,17 +206,33 @@ export default function StaffPage() {
{staff.map((s) => { {staff.map((s) => {
const branchName = branches.find((b) => b._id === s.branchId)?.branchName; const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
return ( return (
<li key={s._id} className="flex items-center justify-between px-4 py-3 animate-fade-up"> <li key={s._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div> <div className={s.isActive ? "" : "opacity-50"}>
<p className="font-medium text-depth-900">{s.name}</p> <p className="font-medium text-depth-900">
{s.name}
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
</p>
<p className="text-xs text-depth-500"> <p className="text-xs text-depth-500">
{s.email} {s.email}
{branchName ? " · " + branchName : ""} {branchName ? " · " + branchName : ""}
</p> </p>
</div> </div>
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}> <div className="flex shrink-0 items-center gap-2">
{s.role.replace("_", " ")} <span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
</span> {s.role.replace("_", " ")}
</span>
{canManage(s) && (
<button
onClick={() => toggleActive(s)}
className={
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
(s.isActive ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
}
>
{s.isActive ? "Deactivate" : "Reactivate"}
</button>
)}
</div>
</li> </li>
); );
})} })}
+456
View File
@@ -0,0 +1,456 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch, Product, StockLevel, StockMovementRow } from "@/lib/types";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
import GlobalSwitcher from "@/components/admin/GlobalSwitcher";
const statusStyles: Record<StockLevel["status"], string> = {
OK: "bg-confirm-500/15 text-confirm-600",
LOW: "bg-cash-500/15 text-cash-700",
OUT: "bg-alert-500/15 text-alert-600",
};
const statusLabel: Record<StockLevel["status"], string> = { OK: "In stock", LOW: "Low", OUT: "Out" };
const movementLabel: Record<StockMovementRow["type"], string> = {
SALE: "Sale",
PURCHASE: "Purchase",
TRANSFER_OUT: "Transfer out",
TRANSFER_IN: "Transfer in",
ADJUSTMENT: "Adjustment",
};
type StockForm = "count" | "transfer" | "daily";
function newUuid() {
return crypto.randomUUID();
}
export default function StockPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [levels, setLevels] = useState<StockLevel[]>([]);
const [movements, setMovements] = useState<StockMovementRow[]>([]);
const [selectedBranchId, setSelectedBranchId] = useState("ALL");
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<StockForm>("count");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Shared form fields. A fresh clientUuid per submission makes a double
// tap on "Save" record the change once.
const [formBranchId, setFormBranchId] = useState("");
const [toBranchId, setToBranchId] = useState("");
const [productId, setProductId] = useState("");
const [countMode, setCountMode] = useState<"COUNT" | "DELTA">("COUNT");
const [dailyMode, setDailyMode] = useState<"dispatch" | "evening">("dispatch");
const [quantity, setQuantity] = useState("");
const [note, setNote] = useState("");
const [submissionId, setSubmissionId] = useState(newUuid);
const isSuper = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
if (result.user.role !== "SUPER_ADMIN" && result.user.branchId) {
setSelectedBranchId(result.user.branchId);
setFormBranchId(result.user.branchId);
}
const [br, pr] = await Promise.all([
apiFetch<{ branches: Branch[] }>("/api/branches"),
apiFetch<{ products: Product[] }>("/api/products"),
]);
setBranches(br.branches);
setProducts(pr.products);
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!me) return;
loadStock();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me, selectedBranchId]);
async function loadStock() {
const q = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
try {
const [s, m] = await Promise.all([
apiFetch<{ levels: StockLevel[] }>("/api/stock" + q),
apiFetch<{ movements: StockMovementRow[] }>("/api/stock/movements" + q + (q ? "&" : "?") + "limit=30"),
]);
setLevels(s.levels);
setMovements(m.movements);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load stock", "error");
}
}
const byBranch = useMemo(() => {
const groups = new Map<string, { branchName: string; rows: StockLevel[] }>();
for (const l of levels) {
if (!groups.has(l.branchId)) groups.set(l.branchId, { branchName: l.branchName, rows: [] });
groups.get(l.branchId)!.rows.push(l);
}
return Array.from(groups.entries());
}, [levels]);
const alerts = levels.filter((l) => l.status !== "OK").length;
function resetForm() {
setQuantity("");
setNote("");
setSubmissionId(newUuid());
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const qty = parseInt(quantity, 10);
const branchId = isSuper ? formBranchId : me?.branchId ?? "";
if (!productId) return setError("Choose a product.");
if (!branchId) return setError(form === "transfer" ? "Choose the sending branch." : "Choose a branch.");
if (!Number.isInteger(qty)) return setError("Enter a whole number.");
setSaving(true);
try {
if (form === "count") {
if (countMode === "COUNT" && qty < 0) throw new Error("A counted quantity cannot be negative.");
if (countMode === "DELTA" && qty === 0) throw new Error("Enter a non-zero quantity to add or remove.");
const res = await apiFetch<{ quantity: number }>("/api/stock/adjustments", {
method: "POST",
body: JSON.stringify({
clientUuid: submissionId,
branchId,
productId,
mode: countMode,
quantity: qty,
note: note.trim() || undefined,
}),
});
show(`Stock updated - now ${res.quantity}`, "success");
} else if (form === "transfer") {
if (qty <= 0) throw new Error("Enter how many to transfer.");
if (!toBranchId) throw new Error("Choose the receiving branch.");
await apiFetch("/api/stock/transfers", {
method: "POST",
body: JSON.stringify({
clientUuid: submissionId,
fromBranchId: branchId,
toBranchId,
productId,
quantity: qty,
note: note.trim() || undefined,
}),
});
show("Transfer recorded at both branches", "success");
} else {
if (qty < 0) throw new Error("Enter zero or more.");
const path = dailyMode === "dispatch" ? "/api/inventory/dispatch" : "/api/inventory/evening-count";
const field = dailyMode === "dispatch" ? "morningDispatched" : "eveningPhysicalCount";
await apiFetch(path, { method: "POST", body: JSON.stringify({ branchId, productId, [field]: qty }) });
show(dailyMode === "dispatch" ? "Morning dispatch saved" : "Evening count saved", "success");
}
resetForm();
await loadStock();
} catch (err) {
const message = err instanceof Error ? err.message : "Could not save";
setError(message);
show(message, "error");
}
setSaving(false);
}
if (loading || !me) {
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8">
<div className="mx-auto h-14 max-w-4xl" />
</header>
<div className="mx-auto -mt-4 max-w-4xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
const tabs: { id: StockForm; label: string }[] = [
{ id: "count", label: "Count / adjust" },
{ id: "transfer", label: "Transfer" },
{ id: "daily", label: "Daily dispatch" },
];
const inputClass = "tap-target w-full rounded-xl border border-depth-200 px-3";
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
<h1 className="font-display text-xl font-bold">Stock</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<GlobalSwitcher
branches={branches}
selectedBranchId={selectedBranchId}
onChange={setSelectedBranchId}
isSuperAdmin={!!isSuper}
/>
<Link
href="/admin/dashboard"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Dashboard
</Link>
</div>
</div>
</header>
<div className="mx-auto -mt-4 max-w-4xl space-y-5 px-5">
{alerts > 0 && (
<div className="rounded-xl2 border border-cash-500/30 bg-cash-500/10 px-4 py-3 text-sm text-cash-700">
{alerts} product line(s) are low or out of stock.
</div>
)}
{byBranch.length === 0 ? (
<div className="rounded-xl2 bg-white shadow-soft">
<EmptyState
icon="📦"
title="No stock to show yet"
description="Add branches and products first, then record opening stock below with Count / adjust."
/>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{byBranch.map(([branchId, group]) => (
<section key={branchId} className="rounded-xl2 bg-white shadow-soft">
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">
{group.branchName}
</h2>
<ul className="divide-y divide-depth-100">
{group.rows.map((l) => (
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-depth-800">{l.productLabel}</span>
<span className="flex shrink-0 items-center gap-2">
<span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
{l.quantity}
</span>
<span className={"rounded-full px-2 py-0.5 text-xs font-medium " + statusStyles[l.status]}>
{statusLabel[l.status]}
</span>
</span>
</li>
))}
</ul>
</section>
))}
</div>
)}
{levels.some((l) => l.quantity < 0) && (
<p className="text-xs text-depth-500">
A negative number means sales were recorded for stock the system never received. Do a physical count
to correct it.
</p>
)}
<form onSubmit={handleSubmit} className="rounded-xl2 bg-white p-4 shadow-soft">
<div role="tablist" className="mb-4 grid grid-cols-3 gap-1 rounded-xl2 bg-sand-100 p-1">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={form === t.id}
onClick={() => {
setForm(t.id);
setError(null);
}}
className={
"rounded-xl px-2 py-2 text-xs font-semibold sm:text-sm " +
(form === t.id ? "bg-white text-depth-900 shadow-soft" : "text-depth-600")
}
>
{t.label}
</button>
))}
</div>
<p className="mb-3 text-xs text-depth-500">
{form === "count" &&
"Record a physical count (the system is corrected to what you counted), or add/remove stock for opening balances, breakage or deliveries not entered as purchases."}
{form === "transfer" && "Moves stock out of one branch and into another. Both branches update immediately."}
{form === "daily" &&
"For delivery crews: how many went out this morning, and how many came back this evening. Feeds the dashboard's reconciliation."}
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{isSuper && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-branch">
{form === "transfer" ? "From branch" : "Branch"}
</label>
<select id="stock-branch" value={formBranchId} onChange={(e) => setFormBranchId(e.target.value)} className={inputClass}>
<option value="">Select a branch…</option>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
{form === "transfer" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-to">
To branch
</label>
<select id="stock-to" value={toBranchId} onChange={(e) => setToBranchId(e.target.value)} className={inputClass}>
<option value="">Select a branch…</option>
{branches
.filter((b) => b._id !== (isSuper ? formBranchId : me.branchId))
.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-product">
Product
</label>
<select id="stock-product" value={productId} onChange={(e) => setProductId(e.target.value)} className={inputClass}>
<option value="">Select a product…</option>
{products.map((p) => (
<option key={p._id} value={p._id}>
{p.label}
</option>
))}
</select>
</div>
{form === "count" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-mode">
Type
</label>
<select
id="stock-mode"
value={countMode}
onChange={(e) => setCountMode(e.target.value as "COUNT" | "DELTA")}
className={inputClass}
>
<option value="COUNT">Physical count (set to)</option>
<option value="DELTA">Add or remove (+/-)</option>
</select>
</div>
)}
{form === "daily" && (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-daily">
Entry
</label>
<select
id="stock-daily"
value={dailyMode}
onChange={(e) => setDailyMode(e.target.value as "dispatch" | "evening")}
className={inputClass}
>
<option value="dispatch">Morning dispatch</option>
<option value="evening">Evening count</option>
</select>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-qty">
{form === "count" && countMode === "DELTA" ? "Quantity (use - to remove)" : "Quantity"}
</label>
<input
id="stock-qty"
type="number"
inputMode="numeric"
step="1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className={inputClass}
/>
</div>
{form !== "daily" && (
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-note">
Note (optional)
</label>
<input
id="stock-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={form === "transfer" ? "e.g. Sent with the Thursday van" : "e.g. Opening stock, 3 cans leaking"}
className={inputClass}
/>
</div>
)}
</div>
{error && <p className="mt-3 text-sm text-alert-600">{error}</p>}
<button
type="submit"
disabled={saving}
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
>
{saving ? "Saving…" : form === "transfer" ? "Record transfer" : "Save"}
</button>
</form>
<div className="rounded-xl2 bg-white shadow-soft">
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">Recent stock movements</h2>
{movements.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-depth-500">No movements yet.</p>
) : (
<ul className="divide-y divide-depth-100">
{movements.map((m) => (
<li key={m._id} className="flex items-start justify-between gap-3 px-4 py-2.5 text-sm">
<div className="min-w-0">
<p className="text-depth-900">
{movementLabel[m.type]} · {m.productId?.label ?? "Product"}
</p>
<p className="truncate text-xs text-depth-500">
{m.branchId?.branchName ?? ""} · {new Date(m.createdAt).toLocaleString("en-KE")}
{m.recordedBy?.name ? " · " + m.recordedBy.name : ""}
{m.note ? " · " + m.note : ""}
</p>
</div>
<span className={"shrink-0 font-mono font-semibold " + (m.quantity < 0 ? "text-alert-600" : "text-confirm-600")}>
{m.quantity > 0 ? "+" : ""}
{m.quantity}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</main>
);
}
+6
View File
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform"; import { isPlatformAdminEmail } from "@/lib/server/platform";
import { getEnv } from "@/lib/server/env"; import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -70,6 +71,11 @@ export async function POST(req: NextRequest) {
user.lockedUntil = null; user.lockedUntil = null;
await user.save(); await user.save();
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
if (user.role === "DELIVERY") { if (user.role === "DELIVERY") {
const binding = await enforceDeviceBinding(user._id.toString(), deviceId); const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
if (!binding.allowed) { if (!binding.allowed) {
+2
View File
@@ -5,6 +5,7 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth"; import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform"; import { isPlatformAdminEmail } from "@/lib/server/platform";
import { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -49,5 +50,6 @@ export async function GET(req: NextRequest) {
maxBranches: tierDef.maxBranches, maxBranches: tierDef.maxBranches,
maxUsers: tierDef.maxUsers, maxUsers: tierDef.maxUsers,
}, },
features: { mpesa: isMpesaEnabled() },
}); });
} }
+6
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession"; import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { tenantAccessError } from "@/lib/server/subscription";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -29,6 +30,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Account no longer active" }, { status: 401 }); return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
} }
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
return NextResponse.json({ error: tenantError }, { status: 403 });
}
const newRefreshToken = generateRefreshToken(); const newRefreshToken = generateRefreshToken();
session.refreshTokenHash = hashRefreshToken(newRefreshToken); session.refreshTokenHash = hashRefreshToken(newRefreshToken);
session.lastUsedAt = new Date(); session.lastUsedAt = new Date();
+9
View File
@@ -11,6 +11,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers"; import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
import { isPlatformAdminEmail } from "@/lib/server/platform";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -52,6 +53,14 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase(); const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
// and signup does not verify email ownership, so an operator address
// must never be claimable through public signup. Operator accounts are
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
}
const emailTaken = await User.findOne({ email: lowerEmail }).lean(); const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) { if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
+37
View File
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs";
const updateSchema = z
.object({
branchName: z.string().trim().min(1).max(120).optional(),
locationCity: z.string().trim().min(1).max(120).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
// PATCH /api/branches/:id — owner renames a branch or changes its city.
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, {
new: true,
}).lean();
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
return NextResponse.json({ branch });
}
+5 -6
View File
@@ -4,7 +4,7 @@ import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize, getAuth } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription"; import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
@@ -40,10 +40,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, ["SUPER_ADMIN"]);
if (!requireRole(auth, "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
@@ -51,7 +50,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
const { branchName, locationCity, manager } = parsed.data; const { branchName, locationCity, manager } = parsed.data;
const businessId = auth!.businessId; const businessId = auth.businessId;
const tier = await loadTier(businessId); const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier); const branchErr = await branchLimitError(businessId, tier);
+29 -10
View File
@@ -3,27 +3,48 @@ import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, badRequest, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10); if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const date = req.nextUrl.searchParams.get("date") || todayNairobi();
if (!isBusinessDate(date)) return badRequest("date must be YYYY-MM-DD");
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) }; const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId); if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
const inventoryRows = await DailyInventory.aggregate([ const inventoryRows = await DailyInventory.aggregate([
{ $match: invMatch }, { $match: invMatch },
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } }, // Lookups are tenant-scoped too, so a row can never surface another
// tenant's product or branch even if one were referenced by mistake.
{
$lookup: {
from: "products",
let: { pid: "$productId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$pid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "product",
},
},
{ $unwind: "$product" }, { $unwind: "$product" },
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } }, {
$lookup: {
from: "branches",
let: { bid: "$branchId", biz: "$businessId" },
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$bid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
as: "branch",
},
},
{ $unwind: "$branch" }, { $unwind: "$branch" },
{ {
$project: { $project: {
@@ -39,9 +60,7 @@ export async function GET(req: NextRequest) {
}, },
]); ]);
const dayStart = new Date(date + "T00:00:00.000Z"); const { start: dayStart, end: dayEnd } = dayRangeNairobi(date);
const dayEnd = new Date(dayStart);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
const soldMatch: Record<string, unknown> = { const soldMatch: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId), businessId: new mongoose.Types.ObjectId(auth.businessId),
+9 -13
View File
@@ -2,26 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose"; import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, branchParam } from "@/lib/server/auth";
import { dayRangeNairobi, todayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function todayRange() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end };
}
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const { start, end } = todayRange(); if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const { start, end } = dayRangeNairobi(todayNairobi());
const matchStage: Record<string, unknown> = { const matchStage: Record<string, unknown> = {
businessId: new mongoose.Types.ObjectId(auth.businessId), businessId: new mongoose.Types.ObjectId(auth.businessId),
+16 -11
View File
@@ -3,21 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense"; import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// GET /api/expenses?branchId=&from=&to= // GET /api/expenses?branchId=&from=&to=
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is // Managers and owners only. SUPER_ADMIN sees all (optionally filtered by
// hard-scoped to their own branch regardless of query params. // branch); a BRANCH_MANAGER is hard-scoped to their own branch.
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch; if (scopeBranch) filter.branchId = scopeBranch;
@@ -42,7 +46,7 @@ export async function GET(req: NextRequest) {
} }
const createSchema = z.object({ const createSchema = z.object({
branchId: z.string().trim().min(1).optional(), branchId: objectId.optional(),
category: z.enum(EXPENSE_CATEGORIES), category: z.enum(EXPENSE_CATEGORIES),
amount: z.number().positive().max(100_000_000), amount: z.number().positive().max(100_000_000),
description: z.string().trim().min(1).max(300), description: z.string().trim().min(1).max(300),
@@ -50,12 +54,13 @@ const createSchema = z.object({
notes: z.string().trim().max(500).optional(), notes: z.string().trim().max(500).optional(),
}); });
// POST /api/expenses — any authenticated staff can record an expense, but // POST /api/expenses — managers and owners only, and only against a branch
// only against a branch they are allowed to touch. // they are allowed to touch.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs"; export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const dispatchSchema = z.object({ const dispatchSchema = z.object({
productId: z.string().min(1), productId: objectId,
morningDispatched: z.number().int().min(0), morningDispatched: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: z.string().optional(), branchId: objectId.optional(),
}); });
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = dispatchSchema.safeParse(json); const parsed = dispatchSchema.safeParse(json);
@@ -31,18 +29,22 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
const branchId = resolveBranchScope(auth!, parsed.data.branchId); const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant — blocks a crafted branchId from // The branch MUST belong to this tenant — blocks a crafted branchId from
// writing inventory into another business. // writing inventory into another business.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayLocal(); // The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId }, { morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
return NextResponse.json({ inventory: row }); return NextResponse.json({ inventory: row });
+18 -16
View File
@@ -3,27 +3,25 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { DailyInventory } from "@/lib/server/models/DailyInventory"; import { DailyInventory } from "@/lib/server/models/DailyInventory";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { Product } from "@/lib/server/models/Product";
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs"; export const runtime = "nodejs";
function todayLocal(): string {
return new Date().toISOString().slice(0, 10);
}
const countSchema = z.object({ const countSchema = z.object({
productId: z.string().min(1), productId: objectId,
eveningPhysicalCount: z.number().int().min(0), eveningPhysicalCount: z.number().int().min(0),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
branchId: z.string().optional(), branchId: objectId.optional(),
}); });
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = countSchema.safeParse(json); const parsed = countSchema.safeParse(json);
@@ -31,17 +29,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
const branchId = resolveBranchScope(auth!, parsed.data.branchId); const branchId = resolveBranchScope(auth, parsed.data.branchId);
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 }); if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
// The branch MUST belong to this tenant. // The branch MUST belong to this tenant.
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayLocal(); // The product must belong to this tenant too.
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId }, { eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
return NextResponse.json({ inventory: row }); return NextResponse.json({ inventory: row });
+4
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { recordSaleMovement } from "@/lib/server/stock";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -18,6 +20,7 @@ function isIpAllowed(ip: string): boolean {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" }); const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
const sourceIp = getSourceIp(req); const sourceIp = getSourceIp(req);
@@ -97,6 +100,7 @@ export async function POST(req: NextRequest) {
txn.mpesaDetails.resultDesc = resultDesc; txn.mpesaDetails.resultDesc = resultDesc;
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber; txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
await txn.save(); await txn.save();
await recordSaleMovement(txn);
await MpesaCallbackLog.create({ await MpesaCallbackLog.create({
sourceIp, sourceIp,
@@ -1,17 +1,22 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth } from "@/lib/server/auth"; import { getAuth } from "@/lib/server/auth";
import { queryStkStatus } from "@/lib/server/mpesa"; import { queryStkStatus } from "@/lib/server/mpesa";
import { recordSaleMovement } from "@/lib/server/stock";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) { export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
if (!isValidObjectId(params.transactionId)) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId }); const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId });
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 }); if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
@@ -27,6 +32,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
fresh.mpesaDetails.resultCode = code; fresh.mpesaDetails.resultCode = code;
fresh.mpesaDetails.resultDesc = result.ResultDesc; fresh.mpesaDetails.resultDesc = result.ResultDesc;
await fresh.save(); await fresh.save();
await recordSaleMovement(fresh);
return NextResponse.json(fresh); return NextResponse.json(fresh);
} }
} }
+2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
@@ -31,6 +32,7 @@ function rateLimited(key: string): boolean {
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
await connectDb(); await connectDb();
const auth = getAuth(req); const auth = getAuth(req);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
+9 -6
View File
@@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
import { isValidObjectId } from "mongoose";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -12,14 +13,16 @@ const updateSchema = z.object({
unitPrice: z.number().nonnegative().optional(), unitPrice: z.number().nonnegative().optional(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
}); });
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb(); await connectDb();
const auth = getAuth(req); // Owner only — product prices apply to every branch.
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { const gate = authorize(req, ["SUPER_ADMIN"]);
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
} const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Product not found" }, { status: 404 });
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json); const parsed = updateSchema.safeParse(json);
@@ -31,7 +34,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
} }
const product = await Product.findOneAndUpdate( const product = await Product.findOneAndUpdate(
{ _id: params.id, businessId: auth!.businessId }, { _id: params.id, businessId: auth.businessId },
parsed.data, parsed.data,
{ new: true } { new: true }
); );
+23 -9
View File
@@ -1,18 +1,30 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { getAuth, requireRole } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// GET /api/products — the tenant's active catalogue. For branch-scoped
// staff the price returned is the price THEIR branch charges (the branch
// override when one exists), so the terminal shows exactly what the
// server will record.
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean(); const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
if (auth.branchId) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) {
p.unitPrice = priceByProduct.get(p._id.toString()) ?? p.unitPrice;
}
}
return NextResponse.json({ products }); return NextResponse.json({ products });
} }
@@ -23,20 +35,22 @@ const createSchema = z.object({
category: z.string().trim().min(1).max(40).optional(), category: z.string().trim().min(1).max(40).optional(),
unitPrice: z.number().nonnegative(), unitPrice: z.number().nonnegative(),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
}); });
// POST /api/products — owner only: the catalogue and its prices apply to
// every branch, so a branch manager must not be able to change them.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, ["SUPER_ADMIN"]);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
const product = await Product.create({ ...parsed.data, businessId: auth!.businessId }); const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
return NextResponse.json({ product }, { status: 201 }); return NextResponse.json({ product }, { status: 201 });
} }
+30 -10
View File
@@ -4,7 +4,9 @@ import { connectDb } from "@/lib/server/db";
import { Purchase } from "@/lib/server/models/Purchase"; import { Purchase } from "@/lib/server/models/Purchase";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { objectId } from "@/lib/server/validation";
import { recordMovement } from "@/lib/server/stock";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,11 +14,14 @@ export const dynamic = "force-dynamic";
// GET /api/purchases?branchId=&from=&to= // GET /api/purchases?branchId=&from=&to=
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const scopeBranch = scope.branchId;
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scopeBranch) filter.branchId = scopeBranch; if (scopeBranch) filter.branchId = scopeBranch;
@@ -41,9 +46,9 @@ export async function GET(req: NextRequest) {
} }
const createSchema = z.object({ const createSchema = z.object({
branchId: z.string().trim().min(1).optional(), branchId: objectId.optional(),
supplierName: z.string().trim().min(1).max(120), supplierName: z.string().trim().min(1).max(120),
productId: z.string().trim().min(1).optional(), productId: objectId.optional(),
productLabel: z.string().trim().min(1).max(120).optional(), productLabel: z.string().trim().min(1).max(120).optional(),
quantity: z.number().int().positive().max(1_000_000), quantity: z.number().int().positive().max(1_000_000),
unitCost: z.number().nonnegative().max(100_000_000), unitCost: z.number().nonnegative().max(100_000_000),
@@ -53,12 +58,14 @@ const createSchema = z.object({
// POST /api/purchases — records a supplier purchase. The buying price is // POST /api/purchases — records a supplier purchase. The buying price is
// snapshotted (unitCost/totalCost) so historical records never shift when // snapshotted (unitCost/totalCost) so historical records never shift when
// product prices change later. A completed purchase conceptually increases // product prices change later. A purchase of a catalogue product adds its
// branch stock. // quantity to the branch's stock ledger; a free-text item (no productId)
// is recorded as a cost only.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
@@ -120,5 +127,18 @@ export async function POST(req: NextRequest) {
recordedBy: auth.sub, recordedBy: auth.sub,
}); });
if (productId) {
await recordMovement({
businessId: auth.businessId,
branchId: branchId!,
productId,
type: "PURCHASE",
quantity: parsed.data.quantity,
refId: purchase._id.toString(),
note: `From ${parsed.data.supplierName}`,
recordedBy: auth.sub,
});
}
return NextResponse.json({ purchase }, { status: 201 }); return NextResponse.json({ purchase }, { status: 201 });
} }
+12 -7
View File
@@ -4,7 +4,8 @@ import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase"; import { Purchase } from "@/lib/server/models/Purchase";
import { Expense } from "@/lib/server/models/Expense"; import { Expense } from "@/lib/server/models/Expense";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
import { startOfMonthNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,17 +18,21 @@ export const dynamic = "force-dynamic";
// Net Profit = Gross Profit - Expenses // Net Profit = Gross Profit - Expenses
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); // Revenue, costs and profit are owner/manager information, never a cashier's.
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const url = new URL(req.url); const url = new URL(req.url);
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId")); const scope = branchParam(auth, url.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
// Default range: start of the current month -> now. // Default range: start of the current business month (EAT) -> now.
const now = new Date(); const now = new Date();
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0); const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now; const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null; const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const businessObjId = new mongoose.Types.ObjectId(auth.businessId); const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { clientRefId, recordMovement, stockOf } from "@/lib/server/stock";
export const runtime = "nodejs";
const adjustmentSchema = z.object({
clientUuid: z.string().uuid(),
branchId: objectId.optional(), // owners must give one; managers default to their own branch
productId: objectId,
// COUNT: "we physically counted N", so the ledger is corrected to N.
// DELTA: "add or remove N", e.g. opening stock, breakage, or a delivery
// that was not recorded as a purchase.
mode: z.enum(["COUNT", "DELTA"]),
quantity: z.number().int().min(-1_000_000).max(1_000_000),
note: z.string().trim().max(300).optional(),
});
// POST /api/stock/adjustments — managers (own branch) and owners (any branch).
export async function POST(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = adjustmentSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const data = parsed.data;
if (data.mode === "COUNT" && data.quantity < 0) {
return NextResponse.json({ error: "A counted quantity cannot be negative" }, { status: 400 });
}
let branchId: string | null;
if (auth.role === "SUPER_ADMIN") {
branchId = data.branchId ?? null;
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
} else {
branchId = auth.branchId;
if (data.branchId && data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Cannot adjust stock for another branch" }, { status: 403 });
}
}
if (!branchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
const [branch, product] = await Promise.all([
Branch.exists({ _id: branchId, businessId: auth.businessId }),
Product.exists({ _id: data.productId, businessId: auth.businessId }),
]);
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// A retried submit with the same clientUuid is a no-op.
const refId = clientRefId(auth.businessId, data.clientUuid);
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
if (!already) {
const before = await stockOf(auth.businessId, branchId, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({
businessId: auth.businessId,
branchId,
productId: data.productId,
type: "ADJUSTMENT",
quantity: delta,
refId,
note: data.note ? `${label}. ${data.note}` : label,
recordedBy: auth.sub,
});
}
}
const quantity = await stockOf(auth.businessId, branchId, data.productId);
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/stock/movements?branchId=&limit= — recent ledger entries, newest first.
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.branchId = scope.branchId;
const movements = await StockMovement.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.lean();
return NextResponse.json({ movements });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { authorize, branchParam } from "@/lib/server/auth";
import { getStockLevels } from "@/lib/server/stock";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/stock?branchId= — current stock per branch and product.
// Owners see every branch (or one, via branchId); everyone else sees only
// their own branch.
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
if (auth.role !== "SUPER_ADMIN" && !scope.branchId) return NextResponse.json({ levels: [] });
const levels = await getStockLevels(auth.businessId, scope.branchId);
return NextResponse.json({ levels });
}
+136
View File
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { recordMovement, stockOf } from "@/lib/server/stock";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// GET /api/stock/transfers?branchId= — recent transfers into or out of the branch(es) in scope.
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.$or = [{ fromBranchId: scope.branchId }, { toBranchId: scope.branchId }];
const transfers = await StockTransfer.find(filter)
.sort({ createdAt: -1 })
.limit(100)
.populate("fromBranchId", "branchName")
.populate("toBranchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.lean();
return NextResponse.json({ transfers });
}
const transferSchema = z.object({
clientUuid: z.string().uuid(),
fromBranchId: objectId.optional(), // owners must give one; managers send from their own branch
toBranchId: objectId,
productId: objectId,
quantity: z.number().int().min(1).max(1_000_000),
note: z.string().trim().max(300).optional(),
});
/** Both ledger sides of a transfer. Idempotent, so it is safe to re-run on a retry. */
async function applyTransfer(t: IStockTransfer) {
const common = {
businessId: t.businessId,
productId: t.productId,
refId: t._id.toString(),
recordedBy: t.recordedBy,
note: t.note,
};
await recordMovement({ ...common, branchId: t.fromBranchId, type: "TRANSFER_OUT", quantity: -t.quantity });
await recordMovement({ ...common, branchId: t.toBranchId, type: "TRANSFER_IN", quantity: t.quantity });
}
// POST /api/stock/transfers — move stock from one branch to another.
// Owners: between any two branches. Managers: out of their own branch only.
export async function POST(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = transferSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const data = parsed.data;
// A retried submit returns the transfer that already happened.
const existing = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
if (existing) {
await applyTransfer(existing);
return NextResponse.json({ transfer: existing }, { status: 200 });
}
let fromBranchId: string | null;
if (auth.role === "SUPER_ADMIN") {
fromBranchId = data.fromBranchId ?? null;
if (!fromBranchId) return NextResponse.json({ error: "fromBranchId is required" }, { status: 400 });
} else {
fromBranchId = auth.branchId;
if (data.fromBranchId && data.fromBranchId !== auth.branchId) {
return NextResponse.json(
{ error: "Branch managers can only transfer stock out of their own branch" },
{ status: 403 }
);
}
}
if (!fromBranchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
if (fromBranchId === data.toBranchId) {
return NextResponse.json({ error: "Choose two different branches" }, { status: 400 });
}
const [branchCount, product] = await Promise.all([
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId }),
Product.exists({ _id: data.productId, businessId: auth.businessId }),
]);
if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// Transfers are made online by a manager, so unlike offline sales they
// can be refused when the sending branch does not have the stock.
const available = await stockOf(auth.businessId, fromBranchId, data.productId);
if (available < data.quantity) {
return NextResponse.json(
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
{ status: 409 }
);
}
let transfer: IStockTransfer;
try {
transfer = await StockTransfer.create({
businessId: auth.businessId,
fromBranchId,
toBranchId: data.toBranchId,
productId: data.productId,
quantity: data.quantity,
clientUuid: data.clientUuid,
note: data.note ?? null,
recordedBy: auth.sub,
});
} catch (err: unknown) {
// Two identical submits raced and the other one created it.
if ((err as { code?: number })?.code !== 11000) throw err;
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
}
await applyTransfer(transfer);
return NextResponse.json({ transfer }, { status: 201 });
}
+165 -64
View File
@@ -1,29 +1,67 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction, ITransaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { Branch } from "@/lib/server/models/Branch";
import { authorize } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { objectId } from "@/lib/server/validation";
import { recordSaleMovement } from "@/lib/server/stock";
import { MAX_LINES_PER_SALE, MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
export const runtime = "nodejs"; export const runtime = "nodejs";
const saleSchema = z.object({ const lineSchema = z.object({
clientUuid: z.string().uuid(), productId: objectId,
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().min(9).max(15),
productId: z.string().min(1),
quantity: z.number().int().min(1).max(500), quantity: z.number().int().min(1).max(500),
clientSubmittedAt: z.string().datetime().optional(), unitPrice: z.number().nonnegative().optional(), // what the device showed; informational
}); });
const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) });
const saleSchema = z.preprocess(
// Older clients sent one product per sale at the top level.
(raw) => {
const r = raw as Record<string, unknown> | null;
if (r && !Array.isArray(r.lines) && r.productId) {
return { ...r, lines: [{ productId: r.productId, quantity: r.quantity }] };
}
return raw;
},
z.object({
clientUuid: z.string().uuid(),
staffId: objectId.optional(), // who recorded it on the device
branchId: objectId.optional(), // where it was recorded
customerName: z.string().trim().max(120).optional(),
customerPhone: z.string().trim().max(20).optional(),
clientSubmittedAt: z.string().datetime().optional(),
lines: z.array(lineSchema).min(1).max(MAX_LINES_PER_SALE),
})
);
type SaleInput = z.infer<typeof saleSchema>;
const bodySchema = z.object({ sales: z.array(z.unknown()).min(1).max(MAX_SALES_PER_REQUEST) });
/**
* POST /api/transactions/cash — record cash sales, one or many, live or
* replayed from the device's offline queue.
*
* Every sale gets its own result so the device knows exactly what to do
* with each queued row:
* saved newly recorded -> mark synced
* duplicate already recorded earlier -> mark synced (this is a replay)
* rejected can never be recorded -> stop retrying, show the cashier
* One bad sale never blocks the others in the batch.
*
* Idempotency: each line's clientUuid is `${saleUuid}:${index}` and is
* unique in the database; the stock movement for each line is keyed on
* the transaction id. Replaying a sale any number of times records it,
* and deducts its stock, exactly once.
*/
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, ["DELIVERY", "BRANCH_MANAGER"]);
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] }; const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
@@ -31,61 +69,124 @@ export async function POST(req: NextRequest) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
if (!auth.branchId) {
const branchId = resolveBranchScope(auth!);
if (!branchId) {
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 }); return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
} }
const { sales } = parsed.data; const sales: SaleInput[] = [];
const productIds = Array.from(new Set(sales.map((s) => s.productId))); const results: SaleResult[] = [];
const products = await Product.find({ _id: { $in: productIds }, businessId: auth!.businessId }).lean(); for (const raw of parsed.data.sales) {
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds }, businessId: auth!.businessId }).lean(); const sale = saleSchema.safeParse(raw);
const productMap = new Map(products.map((p) => [p._id.toString(), p])); if (sale.success) {
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); sales.push(sale.data);
} else {
const rejected: { clientUuid: string; error: string }[] = []; const clientUuid = String((raw as { clientUuid?: unknown })?.clientUuid ?? "");
const inserted: any[] = []; results.push({ clientUuid, status: "rejected", error: sale.error.issues[0]?.message ?? "Invalid sale" });
for (const sale of sales) {
let phone: string;
try {
phone = normalizeKenyanPhone(sale.customerPhone);
} catch (e: any) {
rejected.push({ clientUuid: sale.clientUuid, error: e.message });
continue;
}
const product = productMap.get(sale.productId);
if (!product || !product.isActive) {
rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" });
continue;
}
const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice;
try {
const created = await Transaction.create({
businessId: auth!.businessId,
branchId,
staffId: auth!.sub,
customerName: sale.customerName ?? null,
customerPhone: phone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity: sale.quantity,
unitPrice,
amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100,
paymentMethod: "CASH",
paymentStatus: "SUCCESS",
clientUuid: sale.clientUuid,
syncedFromOffline: !!sale.clientSubmittedAt,
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
});
inserted.push(created);
} catch (err: any) {
if (err?.code === 11000) continue;
rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" });
} }
} }
return NextResponse.json({ inserted, rejected }); // Tenant data needed to price every line in the batch, in three queries.
const productIds = Array.from(new Set(sales.flatMap((s) => s.lines.map((l) => l.productId))));
const branchIds = Array.from(new Set([auth.branchId, ...sales.map((s) => s.branchId ?? auth.branchId!)]));
const [products, overrides, branches] = await Promise.all([
Product.find({ _id: { $in: productIds }, businessId: auth.businessId }).lean(),
BranchPricing.find({ branchId: { $in: branchIds }, productId: { $in: productIds }, businessId: auth.businessId }).lean(),
Branch.find({ _id: { $in: branchIds }, businessId: auth.businessId }).select("_id").lean(),
]);
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
const overrideMap = new Map(overrides.map((o) => [`${o.branchId}:${o.productId}`, o.unitPrice]));
const tenantBranches = new Set(branches.map((b) => b._id.toString()));
for (const sale of sales) {
results.push(await recordSale(sale));
}
async function recordSale(sale: SaleInput): Promise<SaleResult> {
const reject = (error: string): SaleResult => ({ clientUuid: sale.clientUuid, status: "rejected", error });
// A queued sale belongs to the cashier who made it. The device only
// sends its signed-in user's rows; anything else is refused rather than
// credited to the wrong person.
if (sale.staffId && sale.staffId !== auth.sub) {
return reject("This sale was recorded by a different user. Sign in as them to sync it.");
}
// The sale stays with the branch where it happened, even if the
// cashier has since been moved — as long as that branch is in this business.
const branchId = sale.branchId ?? auth.branchId!;
if (!tenantBranches.has(branchId)) return reject("This sale's branch is not part of your business.");
for (const line of sale.lines) {
if (!productMap.has(line.productId)) return reject("A product in this sale no longer exists.");
}
// A bad phone number must not lose a sale that physically happened.
let customerPhone: string | null = null;
if (sale.customerPhone) {
try {
customerPhone = normalizeKenyanPhone(sale.customerPhone);
} catch {
customerPhone = null;
}
}
let newLines = 0;
let amountTotal = 0;
let priceMismatch = false;
for (const [index, line] of sale.lines.entries()) {
// A product deactivated while the device was offline is still
// recorded at its price: the sale already happened.
const product = productMap.get(line.productId)!;
const unitPrice = overrideMap.get(`${branchId}:${line.productId}`) ?? product.unitPrice;
const lineTotal = Math.round(line.quantity * unitPrice * 100) / 100;
const mismatch = line.unitPrice !== undefined && line.unitPrice !== unitPrice;
let txn: ITransaction;
try {
txn = await Transaction.create({
businessId: auth.businessId,
branchId,
staffId: auth.sub,
customerName: sale.customerName || null,
customerPhone,
productId: product._id,
sizeLiters: product.sizeLiters,
quantity: line.quantity,
unitPrice,
amountTotal: lineTotal,
paymentMethod: "CASH",
paymentStatus: "SUCCESS",
clientUuid: `${sale.clientUuid}:${index}`,
saleId: sale.clientUuid,
lineIndex: index,
syncedFromOffline: !!sale.clientSubmittedAt,
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
clientUnitPrice: line.unitPrice ?? null,
priceMismatch: mismatch,
});
newLines += 1;
} catch (err: unknown) {
if ((err as { code?: number })?.code !== 11000) throw err;
// Already recorded by an earlier attempt: reuse it.
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` });
if (!existing || existing.businessId.toString() !== auth.businessId) {
return reject("This sale id is already in use.");
}
txn = existing;
}
// Idempotent, and also repairs a line whose stock movement was not
// written because an earlier attempt failed midway.
await recordSaleMovement(txn);
amountTotal += txn.amountTotal;
priceMismatch = priceMismatch || txn.priceMismatch;
}
return {
clientUuid: sale.clientUuid,
status: newLines === 0 ? "duplicate" : "saved",
amountTotal: Math.round(amountTotal * 100) / 100,
priceMismatch,
};
}
return NextResponse.json({ results });
} }
+9 -7
View File
@@ -1,25 +1,27 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { getAuth, resolveBranchScope } from "@/lib/server/auth"; import { authorize, branchParam } from "@/lib/server/auth";
import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200); const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (branchId) filter.branchId = branchId; if (branchId) filter.branchId = branchId;
const startOfDay = new Date(); filter.createdAt = { $gte: startOfTodayNairobi() };
startOfDay.setHours(0, 0, 0, 0);
filter.createdAt = { $gte: startOfDay };
const transactions = await Transaction.find(filter) const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { UserSession } from "@/lib/server/models/UserSession";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
export const runtime = "nodejs";
const updateSchema = z
.object({
isActive: z.boolean().optional(),
branchId: objectId.optional(), // owner only: move staff to another branch
})
.refine((v) => v.isActive !== undefined || v.branchId !== undefined, { message: "No fields to update" });
/**
* PATCH /api/users/:id — deactivate/reactivate a staff account, or (owner
* only) move it to another branch. This is how an owner "removes" a user:
* the account and its sales history stay, but it can no longer sign in.
*
* SUPER_ADMIN any user in the business except themselves and the owner of record
* BRANCH_MANAGER only DELIVERY staff in their own branch, and only isActive
*/
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
const json = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { isActive, branchId } = parsed.data;
const target = await User.findOne({ _id: params.id, businessId: auth.businessId });
if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (target._id.toString() === auth.sub) {
return NextResponse.json({ error: "You cannot change your own account here" }, { status: 403 });
}
if (auth.role === "BRANCH_MANAGER") {
const ownStaff = target.role === "DELIVERY" && target.branchId?.toString() === auth.branchId;
if (!ownStaff) {
return NextResponse.json({ error: "Branch managers may only manage delivery staff in their own branch" }, { status: 403 });
}
if (branchId !== undefined) {
return NextResponse.json({ error: "Only the owner can move staff between branches" }, { status: 403 });
}
}
const business = await Business.findById(auth.businessId).select("ownerId").lean();
if (business && business.ownerId.toString() === target._id.toString()) {
return NextResponse.json({ error: "The business owner's account cannot be changed" }, { status: 403 });
}
if (branchId !== undefined) {
if (target.role === "SUPER_ADMIN") {
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
}
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
target.branchId = branch._id;
}
if (isActive !== undefined) target.isActive = isActive;
await target.save();
// Revoke every device session so a deactivated (or moved) user cannot
// mint new access tokens; any current access token expires within 15m.
if (isActive === false || branchId !== undefined) {
await UserSession.deleteMany({ userId: target._id });
}
const obj = target.toObject() as unknown as Record<string, unknown>;
delete obj.passwordHash;
return NextResponse.json({ user: obj });
}
+21 -21
View File
@@ -4,7 +4,8 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { normalizeKenyanPhone } from "@/lib/server/phone"; import { normalizeKenyanPhone } from "@/lib/server/phone";
import { userLimitError, loadTier } from "@/lib/server/subscription"; import { userLimitError, loadTier } from "@/lib/server/subscription";
@@ -13,14 +14,14 @@ export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId")); const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
const filter: Record<string, unknown> = { businessId: auth!.businessId }; if (!scope.ok) return scope.response;
if (branchId) filter.branchId = branchId; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.branchId = scope.branchId;
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean(); const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
return NextResponse.json({ users }); return NextResponse.json({ users });
} }
@@ -28,7 +29,7 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({ const createSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]), role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
branchId: z.string().min(1).optional(), branchId: objectId.optional(),
phoneNumber: z.string().min(9).max(15), phoneNumber: z.string().min(9).max(15),
email: z.string().email(), email: z.string().email(),
password: z.string().min(8), password: z.string().min(8),
@@ -36,10 +37,9 @@ const createSchema = z.object({
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
await connectDb(); await connectDb();
const auth = getAuth(req); const gate = authorize(req, MANAGERS);
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) { if (!gate.ok) return gate.response;
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); const auth = gate.auth;
}
const json = await req.json().catch(() => null); const json = await req.json().catch(() => null);
const parsed = createSchema.safeParse(json); const parsed = createSchema.safeParse(json);
@@ -48,22 +48,22 @@ export async function POST(req: NextRequest) {
} }
const data = parsed.data; const data = parsed.data;
if (auth!.role === "BRANCH_MANAGER") { if (auth.role === "BRANCH_MANAGER") {
if (data.role !== "DELIVERY") { if (data.role !== "DELIVERY") {
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 }); return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
} }
if (data.branchId && data.branchId !== auth!.branchId) { if (data.branchId && data.branchId !== auth.branchId) {
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 }); return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
} }
} }
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined; const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined;
if (data.role !== "SUPER_ADMIN" && !branchId) { if (data.role !== "SUPER_ADMIN" && !branchId) {
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 }); return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
} }
if (branchId) { if (branchId) {
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) { if (!branch) {
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
} }
@@ -77,15 +77,15 @@ export async function POST(req: NextRequest) {
} }
// Enforce the plan's user cap (counts owner + managers + staff). // Enforce the plan's user cap (counts owner + managers + staff).
const tier = await loadTier(auth!.businessId); const tier = await loadTier(auth.businessId);
const userErr = await userLimitError(auth!.businessId, tier); const userErr = await userLimitError(auth.businessId, tier);
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 }); if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
const passwordHash = await bcrypt.hash(data.password, 12); const passwordHash = await bcrypt.hash(data.password, 12);
try { try {
const user = await User.create({ const user = await User.create({
businessId: auth!.businessId, businessId: auth.businessId,
name: data.name, name: data.name,
role: data.role, role: data.role,
branchId: branchId ?? null, branchId: branchId ?? null,
@@ -96,7 +96,7 @@ export async function POST(req: NextRequest) {
if (data.role === "BRANCH_MANAGER" && branchId) { if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findOneAndUpdate( await Branch.findOneAndUpdate(
{ _id: branchId, businessId: auth!.businessId }, { _id: branchId, businessId: auth.businessId },
{ managerId: user._id } { managerId: user._id }
); );
} }
+22
View File
@@ -28,3 +28,25 @@ body {
.tap-target { .tap-target {
min-height: 3.25rem; min-height: 3.25rem;
} }
/* Receipts: printing from the terminal prints only the receipt. */
@media print {
body * {
visibility: hidden;
}
.print-receipt,
.print-receipt * {
visibility: visible;
}
.print-receipt {
position: absolute;
left: 0;
top: 0;
width: 72mm;
box-shadow: none !important;
border: none !important;
}
.no-print {
display: none !important;
}
}
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import "./globals.css"; import "./globals.css";
import { ToastProvider } from "@/components/ui/Toast"; import { ToastProvider } from "@/components/ui/Toast";
import ServiceWorkerRegister from "@/components/ServiceWorkerRegister";
// NOTE: We deliberately do NOT use `next/font/google` here. That loader // NOTE: We deliberately do NOT use `next/font/google` here. That loader
// downloads the font files from Google at *build time*, which fails in // downloads the font files from Google at *build time*, which fails in
@@ -34,6 +35,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/> />
</head> </head>
<body> <body>
<ServiceWorkerRegister />
<ToastProvider>{children}</ToastProvider> <ToastProvider>{children}</ToastProvider>
</body> </body>
</html> </html>
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
// Lets cashiers add the terminal to their home screen and open it full-screen.
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Vessel Branch Terminal",
short_name: "Vessel",
start_url: "/terminal",
display: "standalone",
background_color: "#F5F2EF",
theme_color: "#0C0C0C",
};
}
+388 -123
View File
@@ -1,37 +1,60 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
import { isValidKenyanPhone } from "@/lib/phone"; import { isValidKenyanPhone } from "@/lib/phone";
import { AuthUser, PaymentMethod, Product } from "@/lib/types"; import { AuthUser, PaymentMethod, Product } from "@/lib/types";
import { generateClientUuid, queueCashSale, registerOfflineSync } from "@/lib/offlineQueue"; import {
dismissRejected,
flushQueue,
generateClientUuid,
queueCounts,
queueSale,
registerOfflineSync,
salesFor,
type QueuedSale,
type QueueOwner,
} from "@/lib/offlineQueue";
import { clearSession, loadSession, saveSession } from "@/lib/sessionCache";
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import ProductSizeSelector from "@/components/terminal/ProductSizeSelector"; import ProductGrid from "@/components/terminal/ProductGrid";
import QuantityCounter from "@/components/terminal/QuantityCounter"; import Receipt from "@/components/terminal/Receipt";
import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle"; import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle";
import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay"; import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay";
type ScreenState = type ScreenState =
| { kind: "idle" } | { kind: "idle" }
| { kind: "cash_saved" } | { kind: "receipt"; sale: QueuedSale }
| { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null }; | { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null };
interface CartLine {
productId: string;
quantity: number;
}
export default function TerminalPage() { export default function TerminalPage() {
const router = useRouter(); const router = useRouter();
const { show } = useToast(); const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null); const [me, setMe] = useState<AuthUser | null>(null);
const [authChecked, setAuthChecked] = useState(false); const [mpesaEnabled, setMpesaEnabled] = useState(false);
const [bootError, setBootError] = useState<string | null>(null);
const [online, setOnline] = useState(true); const [online, setOnline] = useState(true);
const [pendingCount, setPendingCount] = useState(0); // navigator.onLine only knows about the phone's own connection; this
// also goes false when the server could not be reached.
const [serverReachable, setServerReachable] = useState(true);
const [counts, setCounts] = useState({ pending: 0, rejected: 0 });
const [showQueue, setShowQueue] = useState(false);
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true); const [productsLoading, setProductsLoading] = useState(true);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null); const [cart, setCart] = useState<CartLine[]>([]);
const [showCustomer, setShowCustomer] = useState(false);
const [customerName, setCustomerName] = useState(""); const [customerName, setCustomerName] = useState("");
const [customerPhone, setCustomerPhone] = useState(""); const [customerPhone, setCustomerPhone] = useState("");
const [quantity, setQuantity] = useState(1);
const [method, setMethod] = useState<PaymentMethod>("CASH"); const [method, setMethod] = useState<PaymentMethod>("CASH");
const [phoneError, setPhoneError] = useState<string | null>(null); const [phoneError, setPhoneError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -40,49 +63,89 @@ export default function TerminalPage() {
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" }); const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const selectedProduct = products.find((p) => p._id === selectedProductId) ?? null; const owner: QueueOwner | null = me ? { id: me.id, businessId: me.businessId, branchId: me.branchId } : null;
const amount = selectedProduct ? quantity * selectedProduct.unitPrice : 0; const productById = new Map(products.map((p) => [p._id, p]));
const lines = cart
.map((c) => ({ ...c, product: productById.get(c.productId) }))
.filter((c): c is CartLine & { product: Product } => !!c.product);
const total = lines.reduce((sum, l) => sum + l.quantity * l.product.unitPrice, 0);
const refreshCounts = useCallback(async (userId: string) => {
setCounts(await queueCounts(userId));
}, []);
// Boot: confirm the session online, or fall back to the last confirmed
// session on this device so a cashier can keep selling with no network.
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const cached = loadSession();
let user: AuthUser;
try { try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me"); const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me");
if (result.user.role === "SUPER_ADMIN") { if (result.user.role === "SUPER_ADMIN") {
router.replace("/admin/dashboard"); router.replace("/admin/dashboard");
return; return;
} }
setMe(result.user); user = result.user;
} catch { setMpesaEnabled(!!result.features?.mpesa);
router.replace("/login"); saveSession({ user, features: { mpesa: !!result.features?.mpesa } });
return; } catch (err) {
// Signed out or blocked: go to login. Anything else (no network,
// server unreachable) falls back to the saved session.
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
router.replace("/login");
return;
}
if (!cached) {
setBootError("You're offline. Connect to the internet once to sign in on this phone.");
return;
}
user = cached.user;
setServerReachable(false);
setMpesaEnabled(false); // M-Pesa needs a connection anyway
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
} }
setAuthChecked(true); setMe(user);
await refreshCounts(user.id);
try { try {
const productsResult = await apiFetch<{ products: Product[] }>("/api/products"); const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
setProducts(productsResult.products); setProducts(productsResult.products);
if (productsResult.products.length > 0) setSelectedProductId(productsResult.products[0]._id); saveSession({ user, products: productsResult.products });
} catch { } catch {
show("Could not load jerrycan sizes — check your connection", "error"); const saved = loadSession();
if (saved?.user.id === user.id && saved.products.length > 0) {
setProducts(saved.products);
} else {
show("Could not load products - check your connection", "error");
}
} }
setProductsLoading(false); setProductsLoading(false);
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Connectivity + background sync for this cashier's queue.
useEffect(() => {
if (!me) return;
setOnline(navigator.onLine); setOnline(navigator.onLine);
const goOnline = () => { const goOnline = () => {
setOnline(true); setOnline(true);
show("Back online — syncing queued sales", "info"); show("Back online - syncing saved sales", "info");
}; };
const goOffline = () => { const goOffline = () => {
setOnline(false); setOnline(false);
show("You're offline — cash sales will keep saving and sync automatically", "info"); show("You're offline - cash sales keep saving and sync automatically", "info");
}; };
window.addEventListener("online", goOnline); window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline); window.addEventListener("offline", goOffline);
const unregister = registerOfflineSync((r) => { const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
if (r.synced > 0) { setCounts((c) => ({ ...c, pending: r.pending }));
setPendingCount((c) => Math.max(0, c - r.synced)); setServerReachable(!r.offline);
show(r.synced + " queued sale(s) synced", "success"); if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
if (r.rejected > 0) {
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
refreshCounts(me.id);
} }
}); });
@@ -93,24 +156,46 @@ export default function TerminalPage() {
if (pollRef.current) clearInterval(pollRef.current); if (pollRef.current) clearInterval(pollRef.current);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [me?.id]);
function addProduct(productId: string) {
setFormError(null);
setCart((prev) => {
const existing = prev.find((l) => l.productId === productId);
if (existing) {
return prev.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + 1) } : l));
}
if (prev.length >= MAX_LINES_PER_SALE) return prev;
return [...prev, { productId, quantity: 1 }];
});
}
function changeQuantity(productId: string, delta: number) {
setCart((prev) =>
prev
.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + delta) } : l))
.filter((l) => l.quantity > 0)
);
}
function resetForm() { function resetForm() {
setCart([]);
setCustomerName(""); setCustomerName("");
setCustomerPhone(""); setCustomerPhone("");
setQuantity(1); setShowCustomer(false);
setMethod("CASH"); setMethod("CASH");
setPhoneError(null); setPhoneError(null);
setFormError(null); setFormError(null);
setScreen({ kind: "idle" }); setScreen({ kind: "idle" });
} }
function validate(): boolean { function validate(requirePhone: boolean): boolean {
if (!selectedProduct) { if (lines.length === 0) {
setFormError("Choose a jerrycan size first."); setFormError("Add at least one product.");
return false; return false;
} }
if (!isValidKenyanPhone(customerPhone)) { if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) {
setShowCustomer(true);
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX"); setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
return false; return false;
} }
@@ -120,51 +205,56 @@ export default function TerminalPage() {
} }
async function handleCashSale() { async function handleCashSale() {
if (!validate() || !selectedProduct) return; if (!me || !owner || !validate(false)) return;
if (!me.branchId) {
setFormError("Your account is not assigned to a branch.");
return;
}
setSubmitting(true); setSubmitting(true);
const sale = { // The IndexedDB write is the only step that must finish before we show
// success: it is what keeps the sale if the phone dies before syncing.
const sale = await queueSale({
clientUuid: generateClientUuid(), clientUuid: generateClientUuid(),
customerName: customerName || undefined, userId: me.id,
customerPhone, businessId: me.businessId ?? "",
productId: selectedProduct._id, branchId: me.branchId,
quantity, customerName: customerName.trim() || undefined,
customerPhone: customerPhone.trim() || undefined,
lines: lines.map((l) => ({
productId: l.productId,
label: l.product.label,
quantity: l.quantity,
unitPrice: l.product.unitPrice,
})),
total,
clientSubmittedAt: new Date().toISOString(), clientSubmittedAt: new Date().toISOString(),
}; });
// IndexedDB write is the only step that must complete before we can
// safely show success — it's what guarantees the sale survives even if
// the app crashes or the device loses power before the network sync
// below finishes. It's also fast (a few ms), so this doesn't add
// perceptible delay.
await queueCashSale(sale);
setPendingCount((c) => c + 1);
setSubmitting(false); setSubmitting(false);
setScreen({ kind: "receipt", sale });
setCart([]);
await refreshCounts(me.id);
// OPTIMISTIC UI: show the confirmation screen immediately rather than // Sync in the background; a failure leaves the sale queued for the
// waiting on the network round trip. The worker handed over the // next automatic retry.
// jerrycan and took the cash — from their perspective the sale is
// already done, and the app should agree instantly. The actual sync
// to the server happens in the background; if it fails here, the sale
// is NOT lost — it stays in the offline queue and the 'online'
// listener / interval in offlineQueue.ts retries it automatically.
setScreen({ kind: "cash_saved" });
if (navigator.onLine) { if (navigator.onLine) {
apiFetch("/api/transactions/cash", { flushQueue(owner).then(async (r) => {
method: "POST", setCounts((c) => ({ ...c, pending: r.pending }));
body: JSON.stringify({ sales: [sale] }), setServerReachable(!r.offline);
}) const rows = await salesFor(me.id);
.then(() => setPendingCount((c) => Math.max(0, c - 1))) const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
.catch(() => { if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
// stays queued; offline sync interval will retry — no toast here, if (r.rejected > 0) refreshCounts(me.id);
// since this is a routine background retry, not a user-facing error });
});
} }
} }
async function startMpesaPush() { async function startMpesaPush() {
if (!validate() || !selectedProduct) return; if (!validate(true)) return;
if (lines.length !== 1) {
setFormError("M-Pesa payments support one product per payment. Use cash for mixed sales.");
return;
}
if (!navigator.onLine) { if (!navigator.onLine) {
setFormError("M-Pesa requires an active network connection."); setFormError("M-Pesa requires an active network connection.");
return; return;
@@ -177,8 +267,8 @@ export default function TerminalPage() {
body: JSON.stringify({ body: JSON.stringify({
customerName: customerName || undefined, customerName: customerName || undefined,
customerPhone, customerPhone,
productId: selectedProduct._id, productId: lines[0].productId,
quantity, quantity: lines[0].quantity,
}), }),
}); });
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" }); setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
@@ -196,7 +286,10 @@ export default function TerminalPage() {
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
try { try {
const data = await apiFetch<any>("/api/mpesa/status/" + transactionId); const data = await apiFetch<{
paymentStatus: string;
mpesaDetails?: { receiptNumber?: string | null; resultDesc?: string | null };
}>("/api/mpesa/status/" + transactionId);
if (data.paymentStatus === "SUCCESS") { if (data.paymentStatus === "SUCCESS") {
clearInterval(pollRef.current!); clearInterval(pollRef.current!);
setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber }); setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber });
@@ -210,7 +303,15 @@ export default function TerminalPage() {
}); });
} else if (Date.now() - start > 60000) { } else if (Date.now() - start > 60000) {
clearInterval(pollRef.current!); clearInterval(pollRef.current!);
setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No response from customer in time." }); // Not "failed": the customer may still approve it, and the callback
// would then record it as paid. Taking cash now would count it twice.
setScreen({
kind: "mpesa",
transactionId,
ui: "FAILED",
reason:
"No confirmation yet. Do NOT take cash for this sale until a manager has checked whether the M-Pesa payment arrived.",
});
} }
} catch { } catch {
// transient network hiccup while polling; keep trying until timeout above // transient network hiccup while polling; keep trying until timeout above
@@ -218,7 +319,53 @@ export default function TerminalPage() {
}, 3000); }, 3000);
} }
if (!authChecked || !me) { async function openQueue() {
if (!me) return;
setQueueRows((await salesFor(me.id)).filter((r) => r.state !== "synced"));
setShowQueue(true);
}
async function handleDismiss(clientUuid: string) {
if (!me) return;
if (!window.confirm("Remove this sale from the phone? Make sure your manager has a record of it first.")) return;
await dismissRejected(clientUuid);
setQueueRows((rows) => rows.filter((r) => r.clientUuid !== clientUuid));
await refreshCounts(me.id);
}
async function handleSignOut() {
if (counts.pending > 0) {
const ok = window.confirm(
`${counts.pending} sale(s) have not synced yet. They stay safely on this phone and sync the next time you sign in here. Sign out anyway?`
);
if (!ok) return;
}
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
// Offline: the cookies expire on their own; still clear this device.
}
clearSession();
router.replace("/login");
}
if (bootError) {
return (
<main className="flex min-h-screen items-center justify-center bg-depth-900 px-6 text-center">
<div>
<p className="text-sand-200">{bootError}</p>
<button
onClick={() => window.location.reload()}
className="tap-target mt-6 rounded-xl2 bg-flow-500 px-6 font-display font-semibold text-depth-950"
>
Try again
</button>
</div>
</main>
);
}
if (!me) {
return ( return (
<main className="min-h-screen bg-sand-50 pb-10"> <main className="min-h-screen bg-sand-50 pb-10">
<header className="bg-depth-900 px-5 pb-6 pt-8"> <header className="bg-depth-900 px-5 pb-6 pt-8">
@@ -232,95 +379,177 @@ export default function TerminalPage() {
); );
} }
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
const connected = online && serverReachable;
return ( return (
<main className="min-h-screen bg-sand-50 pb-10"> <main className="min-h-screen bg-sand-50 pb-10">
<header className="bg-depth-900 px-5 pb-6 pt-8 text-white"> <header className="no-print bg-depth-900 px-5 pb-6 pt-8 text-white">
<div className="flex items-center justify-between"> <div className="mx-auto flex max-w-md items-center justify-between">
<div> <div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p> <p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p>
<h1 className="font-display text-lg font-bold">{me.name}</h1> <h1 className="font-display text-lg font-bold">{me.name}</h1>
</div> </div>
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")}> <span
{online ? "● Online" : "● Offline"} className={
"rounded-full px-2.5 py-1 text-xs font-medium " +
(connected ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
}
>
{connected ? "● Online" : "● Offline"}
</span> </span>
{pendingCount > 0 && <span className="text-xs text-cash-400">{pendingCount} cash sale(s) syncing…</span>} <button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
Sign out
</button>
</div> </div>
</div> </div>
</header> </header>
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5"> <div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
{screen.kind === "cash_saved" ? ( {(counts.pending > 0 || counts.rejected > 0) && (
<div className="animate-fade-up rounded-xl2 border border-cash-500/30 bg-white p-6 text-center shadow-soft"> <button
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-cash-500/15 text-2xl">💵</div> onClick={openQueue}
<h2 className="font-display text-lg font-bold text-depth-900">Cash Sale Recorded</h2> className={
<p className="mt-1 text-sm text-depth-600"> "no-print w-full rounded-xl2 px-4 py-3 text-left text-sm font-medium shadow-soft " +
KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans (counts.rejected > 0 ? "bg-alert-500 text-white" : "bg-cash-400 text-depth-900")
</p> }
<button onClick={resetForm} className="tap-target mt-6 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]"> >
Start next sale {counts.rejected > 0
</button> ? `${counts.rejected} sale(s) need attention - tap to see why`
: `${counts.pending} sale(s) saved on this phone, waiting to sync`}
</button>
)}
{screen.kind === "receipt" ? (
<div className="animate-fade-up space-y-4">
<Receipt
sale={screen.sale}
businessName={me.businessName}
branchName={me.branchName}
cashierName={me.name}
synced={screen.sale.state === "synced"}
/>
<div className="no-print grid grid-cols-2 gap-3">
<button
onClick={() => window.print()}
className="tap-target rounded-xl2 border border-depth-200 bg-white font-display font-semibold text-depth-800"
>
Print receipt
</button>
<button
onClick={resetForm}
className="tap-target rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]"
>
Next sale
</button>
</div>
</div> </div>
) : ( ) : (
<> <>
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
Customer name (optional)
</label>
<input
id="cust-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="e.g. Jane Wambui"
className="tap-target w-full rounded-xl border border-depth-200 px-3 mb-3"
/>
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
Customer phone
</label>
<input
id="cust-phone"
inputMode="tel"
value={customerPhone}
onChange={(e) => {
setCustomerPhone(e.target.value);
if (phoneError) setPhoneError(null);
}}
placeholder="07XXXXXXXX"
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
/>
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
</div>
{productsLoading ? ( {productsLoading ? (
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" /> <div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
) : ( ) : (
<ProductSizeSelector products={products} selectedId={selectedProductId} onSelect={setSelectedProductId} /> <ProductGrid products={products} quantities={quantities} onAdd={addProduct} />
)} )}
{selectedProduct && ( {lines.length > 0 && (
<QuantityCounter quantity={quantity} onChange={setQuantity} unitPrice={selectedProduct.unitPrice} sizeLabel={selectedProduct.sizeLiters + "L"} /> <div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<p className="mb-2 text-sm font-medium text-depth-600">This sale</p>
<ul className="divide-y divide-depth-100">
{lines.map((l) => (
<li key={l.productId} className="flex items-center justify-between gap-3 py-2">
<div className="min-w-0">
<p className="truncate font-display text-sm font-bold text-depth-900">{l.product.label}</p>
<p className="font-mono text-xs text-depth-500">
KES {(l.quantity * l.product.unitPrice).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => changeQuantity(l.productId, -1)}
aria-label={`Remove one ${l.product.label}`}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-sand-100 text-2xl font-bold text-depth-800 active:scale-95"
>
–
</button>
<span className="w-8 text-center font-display text-lg font-bold">{l.quantity}</span>
<button
type="button"
onClick={() => changeQuantity(l.productId, 1)}
aria-label={`Add one ${l.product.label}`}
className="flex h-11 w-11 items-center justify-center rounded-xl bg-flow-500 text-2xl font-bold text-depth-950 active:scale-95"
>
+
</button>
</div>
</li>
))}
</ul>
<div className="mt-2 flex justify-between border-t border-depth-100 pt-2 font-display font-bold">
<span>Total</span>
<span>KES {total.toLocaleString()}</span>
</div>
</div>
)} )}
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
{!showCustomer ? (
<button onClick={() => setShowCustomer(true)} className="text-sm font-medium text-flow-600 underline underline-offset-2">
+ Add customer details {method === "MPESA" ? "(phone required)" : "(optional)"}
</button>
) : (
<>
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
Customer name (optional)
</label>
<input
id="cust-name"
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
placeholder="e.g. Jane Wambui"
className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3"
/>
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
Customer phone {method === "MPESA" ? "" : "(optional)"}
</label>
<input
id="cust-phone"
inputMode="tel"
value={customerPhone}
onChange={(e) => {
setCustomerPhone(e.target.value);
if (phoneError) setPhoneError(null);
}}
placeholder="07XXXXXXXX"
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
/>
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
</>
)}
</div>
{formError && <p className="text-sm text-alert-500">{formError}</p>} {formError && <p className="text-sm text-alert-500">{formError}</p>}
<div className="space-y-3"> <div className="space-y-3">
<PaymentMethodToggle value={method} onChange={setMethod} /> {mpesaEnabled && <PaymentMethodToggle value={method} onChange={setMethod} />}
{method === "CASH" ? ( {method === "CASH" || !mpesaEnabled ? (
<button <button
onClick={handleCashSale} onClick={handleCashSale}
disabled={submitting || !selectedProduct} disabled={submitting || lines.length === 0}
className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60" className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
> >
{submitting ? "Saving…" : "Record Cash Sale · KES " + amount.toLocaleString()} {submitting ? "Saving…" : "Record Cash Sale · KES " + total.toLocaleString()}
</button> </button>
) : ( ) : (
<button <button
onClick={startMpesaPush} onClick={startMpesaPush}
disabled={submitting || !selectedProduct} disabled={submitting || lines.length === 0}
className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60" className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
> >
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + amount.toLocaleString()} {submitting ? "Sending prompt…" : "Initiate Payment · KES " + total.toLocaleString()}
</button> </button>
)} )}
</div> </div>
@@ -328,10 +557,46 @@ export default function TerminalPage() {
)} )}
</div> </div>
{showQueue && (
<div role="dialog" aria-modal="true" className="no-print fixed inset-0 z-40 flex items-end justify-center bg-depth-950/60 sm:items-center">
<div className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-5 sm:rounded-3xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display text-lg font-bold">Sales on this phone</h2>
<button onClick={() => setShowQueue(false)} className="text-sm text-depth-500 underline">
Close
</button>
</div>
{queueRows.length === 0 && <p className="text-sm text-depth-500">Everything is synced.</p>}
<ul className="space-y-3">
{queueRows.map((r) => (
<li key={r.clientUuid} className="rounded-xl border border-depth-200 p-3 text-sm">
<div className="flex justify-between font-medium">
<span>{new Date(r.clientSubmittedAt).toLocaleString("en-KE")}</span>
<span>KES {r.total.toLocaleString()}</span>
</div>
<p className="text-xs text-depth-500">{r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}</p>
{r.state === "pending" ? (
<p className="mt-1 text-xs text-cash-600">Waiting to sync</p>
) : (
<>
<p className="mt-1 text-xs text-alert-600">Not accepted: {r.error}</p>
<p className="text-xs text-depth-500">Tell your manager, then remove it from this phone.</p>
<button onClick={() => handleDismiss(r.clientUuid)} className="mt-1 text-xs font-medium text-alert-600 underline">
Remove from phone
</button>
</>
)}
</li>
))}
</ul>
</div>
</div>
)}
{screen.kind === "mpesa" && ( {screen.kind === "mpesa" && (
<PaymentStatusOverlay <PaymentStatusOverlay
state={screen.ui} state={screen.ui}
amount={amount} amount={total}
receiptNumber={screen.receipt} receiptNumber={screen.receipt}
failureReason={screen.reason} failureReason={screen.reason}
onRetry={() => { onRetry={() => {
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
/**
* Registers public/sw.js so the terminal can open offline. Production only:
* in `next dev` the build assets are not content-hashed, and caching them
* would serve stale code while developing.
*/
export default function ServiceWorkerRegister() {
useEffect(() => {
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js").catch(() => {
// Unsupported or blocked: the app still works online.
});
}, []);
return null;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export default function ActivityFeedMatrix({ transactions, branchNames, connecte
<li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up"> <li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone}</p> <p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone || "Walk-in customer"}</p>
{scope === "global" && ( {scope === "global" && (
<span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600"> <span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600">
{branchLabel} {branchLabel}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { StockLevel } from "@/lib/types";
interface Props {
levels: StockLevel[];
}
/** Current stock per branch at a glance, with anything low or out listed first. */
export default function StockSummaryCard({ levels }: Props) {
const totalsByBranch = new Map<string, { name: string; units: number; alerts: number }>();
for (const l of levels) {
const entry = totalsByBranch.get(l.branchId) ?? { name: l.branchName, units: 0, alerts: 0 };
entry.units += Math.max(l.quantity, 0);
if (l.status !== "OK") entry.alerts += 1;
totalsByBranch.set(l.branchId, entry);
}
const alerts = levels.filter((l) => l.status !== "OK").sort((a, b) => a.quantity - b.quantity);
return (
<section className="rounded-xl2 bg-white p-4 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-display font-semibold text-depth-900">Stock on hand</h2>
<Link href="/admin/stock" className="text-sm font-medium text-flow-600 underline underline-offset-2">
Manage stock
</Link>
</div>
{levels.length === 0 ? (
<p className="text-sm text-depth-500">No stock recorded yet.</p>
) : (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{Array.from(totalsByBranch.entries()).map(([id, b]) => (
<div key={id} className="rounded-xl bg-sand-50 px-3 py-2">
<p className="truncate text-xs text-depth-500">{b.name}</p>
<p className="font-display text-lg font-bold text-depth-900">{b.units.toLocaleString()} units</p>
{b.alerts > 0 && <p className="text-xs font-medium text-cash-700">{b.alerts} low/out</p>}
</div>
))}
</div>
{alerts.length > 0 && (
<ul className="mt-3 space-y-1 text-sm">
{alerts.slice(0, 6).map((l) => (
<li key={l.branchId + l.productId} className="flex justify-between gap-3">
<span className="truncate text-depth-700">
{l.productLabel} · {l.branchName}
</span>
<span className={"shrink-0 font-mono " + (l.status === "OUT" ? "text-alert-600" : "text-cash-700")}>
{l.quantity}
</span>
</li>
))}
</ul>
)}
</>
)}
</section>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { Product } from "@/lib/types";
interface Props {
products: Product[];
quantities: Record<string, number>;
onAdd: (productId: string) => void;
}
/** One tap adds one unit of a product to the current sale. */
export default function ProductGrid({ products, quantities, onAdd }: Props) {
if (products.length === 0) {
return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
No products configured yet. Ask the owner to add them.
</div>
);
}
return (
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<span className="mb-2 block text-sm font-medium text-depth-600">Tap a product to add it</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{products.map((p) => {
const inCart = quantities[p._id] ?? 0;
return (
<button
key={p._id}
type="button"
onClick={() => onAdd(p._id)}
aria-label={`Add ${p.label}`}
className={
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 py-2 pl-3 text-left transition active:scale-[0.97] " +
(inCart > 0 ? "border-flow-500 bg-flow-500/10 pr-9" : "border-depth-200 bg-white pr-3")
}
>
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
<span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
{inCart > 0 && (
<span className="absolute right-2 top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-flow-500 px-1.5 font-mono text-xs font-bold text-white">
{inCart}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
@@ -1,54 +0,0 @@
"use client";
import { Product } from "@/lib/types";
interface Props {
products: Product[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export default function ProductSizeSelector({ products, selectedId, onSelect }: Props) {
if (products.length === 0) {
return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
No jerrycan sizes configured yet.
</div>
);
}
const maxSize = Math.max(...products.map((p) => p.sizeLiters));
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-depth-600">Jerrycan size</span>
<div role="radiogroup" aria-label="Jerrycan size" className="flex flex-wrap gap-2">
{products.map((p) => {
const isSelected = p._id === selectedId;
const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize);
return (
<button
key={p._id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onSelect(p._id)}
className={
"tap-target flex items-center gap-2 rounded-xl2 border-2 px-4 transition " +
(isSelected ? "border-flow-500 bg-flow-500/10 text-depth-900" : "border-depth-200 bg-white text-depth-600")
}
>
<svg width={18 * scale} height={22 * scale} viewBox="0 0 24 28" className={isSelected ? "text-flow-500" : "text-depth-300"}>
<rect x="4" y="6" width="16" height="20" rx="2" fill="currentColor" />
<rect x="9" y="2" width="6" height="5" rx="1" fill="currentColor" />
</svg>
<span className="text-left leading-tight">
<span className="block font-display text-sm font-bold">{p.sizeLiters}{p.unit ?? "L"}</span>
<span className="block font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
@@ -1,48 +0,0 @@
"use client";
interface Props {
quantity: number;
onChange: (q: number) => void;
unitPrice: number;
sizeLabel: string;
max?: number;
}
export default function QuantityCounter({ quantity, onChange, unitPrice, sizeLabel, max = 200 }: Props) {
const dec = () => onChange(Math.max(1, quantity - 1));
const inc = () => onChange(Math.min(max, quantity + 1));
return (
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-depth-600">{sizeLabel} jerrycans</span>
<span className="font-mono text-sm text-depth-600">KES {unitPrice.toLocaleString()} each</span>
</div>
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={dec}
aria-label="Decrease quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-sand-100 text-3xl font-bold text-depth-800 active:scale-95"
>
–
</button>
<div className="text-center">
<div className="font-display text-4xl font-extrabold text-depth-900">{quantity}</div>
<div className="font-mono text-sm text-depth-600">KES {(quantity * unitPrice).toLocaleString()}</div>
</div>
<button
type="button"
onClick={inc}
aria-label="Increase quantity"
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-flow-500 text-3xl font-bold text-depth-950 active:scale-95"
>
+
</button>
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import type { QueuedSale } from "@/lib/offlineQueue";
interface Props {
sale: QueuedSale;
businessName?: string | null;
branchName?: string | null;
cashierName: string;
synced: boolean;
}
/** A printable sale receipt. Printing hides everything but `.print-receipt` (see globals.css). */
export default function Receipt({ sale, businessName, branchName, cashierName, synced }: Props) {
const when = new Date(sale.clientSubmittedAt);
const reference = sale.clientUuid.slice(0, 8).toUpperCase();
return (
<div className="print-receipt rounded-xl2 border border-depth-200/60 bg-white p-5 font-mono text-sm text-depth-900 shadow-soft">
<div className="text-center">
<p className="font-display text-base font-bold">{businessName ?? "Receipt"}</p>
{branchName && <p className="text-xs text-depth-500">{branchName}</p>}
<p className="mt-1 text-xs text-depth-500">
{when.toLocaleDateString("en-KE")} {when.toLocaleTimeString("en-KE", { hour: "2-digit", minute: "2-digit" })}
</p>
<p className="text-xs text-depth-500">Ref {reference}</p>
</div>
<table className="mt-4 w-full">
<tbody>
{sale.lines.map((l) => (
<tr key={l.productId} className="align-top">
<td className="py-0.5 pr-2">
{l.quantity} x {l.label}
<span className="block text-xs text-depth-500">@ KES {l.unitPrice.toLocaleString()}</span>
</td>
<td className="py-0.5 text-right">{(l.quantity * l.unitPrice).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3 flex justify-between border-t border-dashed border-depth-300 pt-2 font-bold">
<span>TOTAL (KES)</span>
<span>{sale.total.toLocaleString()}</span>
</div>
<p className="mt-1 text-xs">Paid: Cash</p>
{sale.customerName && <p className="text-xs">Customer: {sale.customerName}</p>}
<p className="text-xs">Served by: {cashierName}</p>
<p className="no-print mt-3 text-center text-xs text-depth-500">
{synced ? "Synced to head office" : "Saved on this phone - will sync automatically"}
</p>
</div>
);
}
+202 -33
View File
@@ -1,7 +1,49 @@
import { openDB, DBSchema, IDBPDatabase } from "idb"; import { openDB, DBSchema, IDBPDatabase } from "idb";
import { apiFetch } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
import { MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
export interface QueuedCashSale { /**
* The terminal's durable sales queue (IndexedDB).
*
* Every cash sale is written here BEFORE the cashier sees "recorded", and
* stays here until the server has confirmed it. Each row carries the
* cashier and branch that recorded it, and a device only ever sends the
* signed-in cashier's rows — so on a shared phone, sales can never be
* credited to whoever signs in next.
*
* Row lifecycle:
* pending -> sent on the next sync (immediately when online)
* synced -> server answered saved/duplicate; kept 7 days for the receipt history
* rejected -> server can never accept it (reason stored); shown to the cashier,
* never retried, so it can't block the queue
*/
export interface SaleLine {
productId: string;
label: string;
quantity: number;
unitPrice: number; // the price the cashier saw and charged
}
export type SaleState = "pending" | "synced" | "rejected";
export interface QueuedSale {
clientUuid: string;
userId: string;
businessId: string;
branchId: string;
customerName?: string;
customerPhone?: string;
lines: SaleLine[];
total: number;
clientSubmittedAt: string;
state: SaleState;
error?: string;
syncedAt?: string;
}
/** Rows written by the pre-v2 terminal (no owner, one product). */
interface LegacyCashSale {
clientUuid: string; clientUuid: string;
customerName?: string; customerName?: string;
customerPhone: string; customerPhone: string;
@@ -12,19 +54,30 @@ export interface QueuedCashSale {
} }
interface QueueDB extends DBSchema { interface QueueDB extends DBSchema {
cash_sales: { key: string; value: QueuedCashSale }; sales: { key: string; value: QueuedSale; indexes: { byUser: string } };
cash_sales: { key: string; value: LegacyCashSale };
}
export interface QueueOwner {
id: string;
businessId?: string;
branchId: string | null;
} }
const DB_NAME = "aquaflow-offline"; const DB_NAME = "aquaflow-offline";
const STORE = "cash_sales"; const KEEP_SYNCED_MS = 7 * 24 * 60 * 60 * 1000;
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null; let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
function getDb() { function getDb() {
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser"); if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
if (!dbPromise) { if (!dbPromise) {
dbPromise = openDB<QueueDB>(DB_NAME, 1, { dbPromise = openDB<QueueDB>(DB_NAME, 2, {
upgrade(db) { upgrade(db, oldVersion) {
db.createObjectStore(STORE, { keyPath: "clientUuid" }); if (oldVersion < 1) db.createObjectStore("cash_sales", { keyPath: "clientUuid" });
if (oldVersion < 2) {
const sales = db.createObjectStore("sales", { keyPath: "clientUuid" });
sales.createIndex("byUser", "userId");
}
}, },
}); });
} }
@@ -35,52 +88,168 @@ export function generateClientUuid(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
export async function queueCashSale(sale: Omit<QueuedCashSale, "synced">): Promise<void> { export async function queueSale(sale: Omit<QueuedSale, "state">): Promise<QueuedSale> {
const row: QueuedSale = { ...sale, state: "pending" };
const db = await getDb(); const db = await getDb();
await db.put(STORE, { ...sale, synced: false }); await db.put("sales", row);
return row;
} }
export async function getPendingSales(): Promise<QueuedCashSale[]> { export async function salesFor(userId: string): Promise<QueuedSale[]> {
const db = await getDb(); const db = await getDb();
const all = await db.getAll(STORE); return db.getAllFromIndex("sales", "byUser", userId);
return all.filter((s) => !s.synced);
} }
async function markSynced(clientUuids: string[]) { export async function queueCounts(userId: string): Promise<{ pending: number; rejected: number }> {
const rows = await salesFor(userId);
return {
pending: rows.filter((r) => r.state === "pending").length,
rejected: rows.filter((r) => r.state === "rejected").length,
};
}
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
export async function dismissRejected(clientUuid: string): Promise<void> {
const db = await getDb(); const db = await getDb();
const tx = db.transaction(STORE, "readwrite"); const row = await db.get("sales", clientUuid);
for (const id of clientUuids) { if (row?.state === "rejected") await db.delete("sales", clientUuid);
const row = await tx.store.get(id); }
if (row) {
row.synced = true; async function applyResults(results: SaleResult[]) {
await tx.store.put(row); const db = await getDb();
const tx = db.transaction("sales", "readwrite");
for (const r of results) {
const row = await tx.store.get(r.clientUuid);
if (!row) continue;
if (r.status === "rejected") {
row.state = "rejected";
row.error = r.error;
} else {
row.state = "synced";
row.syncedAt = new Date().toISOString();
delete row.error;
} }
await tx.store.put(row);
} }
await tx.done; await tx.done;
} }
export async function flushQueue(): Promise<{ synced: number; failed: number }> { function toPayload(s: QueuedSale) {
const pending = await getPendingSales(); return {
if (pending.length === 0) return { synced: 0, failed: 0 }; clientUuid: s.clientUuid,
staffId: s.userId,
branchId: s.branchId,
customerName: s.customerName || undefined,
customerPhone: s.customerPhone || undefined,
clientSubmittedAt: s.clientSubmittedAt,
lines: s.lines.map((l) => ({
productId: l.productId,
quantity: l.quantity,
// Adopted legacy rows never recorded a price (0); don't flag those as mismatches.
unitPrice: l.unitPrice > 0 ? l.unitPrice : undefined,
})),
};
}
try { /**
const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", { * Pre-v2 rows had no owner. They are adopted by the first cashier who
method: "POST", * syncs on this device — exactly what the old terminal did with them.
body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }), */
async function adoptLegacySales(owner: QueueOwner) {
if (!owner.branchId) return;
const db = await getDb();
const legacy = (await db.getAll("cash_sales")).filter((s) => !s.synced);
for (const s of legacy) {
await db.put("sales", {
clientUuid: s.clientUuid,
userId: owner.id,
businessId: owner.businessId ?? "",
branchId: owner.branchId,
customerName: s.customerName,
customerPhone: s.customerPhone,
lines: [{ productId: s.productId, label: "Sale", quantity: s.quantity, unitPrice: 0 }],
total: 0,
clientSubmittedAt: s.clientSubmittedAt,
state: "pending",
}); });
const syncedIds = result.inserted.map((r) => r.clientUuid); }
await markSynced(syncedIds); await db.clear("cash_sales");
return { synced: syncedIds.length, failed: pending.length - syncedIds.length }; }
} catch {
return { synced: 0, failed: pending.length }; async function pruneSynced(userId: string) {
const db = await getDb();
const cutoff = Date.now() - KEEP_SYNCED_MS;
for (const row of await salesFor(userId)) {
if (row.state === "synced" && row.syncedAt && Date.parse(row.syncedAt) < cutoff) {
await db.delete("sales", row.clientUuid);
}
} }
} }
export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) { export interface FlushResult {
synced: number;
rejected: number;
pending: number;
offline: boolean;
}
let flushInFlight: Promise<FlushResult> | null = null;
/**
* Send this cashier's pending sales in chunks the server accepts. Stops at
* the first network/auth failure (everything unsent stays pending).
*/
export function flushQueue(owner: QueueOwner): Promise<FlushResult> {
if (!flushInFlight) {
flushInFlight = doFlush(owner).finally(() => {
flushInFlight = null;
});
}
return flushInFlight;
}
async function doFlush(owner: QueueOwner): Promise<FlushResult> {
await adoptLegacySales(owner);
const pending = (await salesFor(owner.id))
.filter((s) => s.state === "pending")
.sort((a, b) => a.clientSubmittedAt.localeCompare(b.clientSubmittedAt));
let synced = 0;
let rejected = 0;
let offline = false;
if (pending.length === 0) {
// Nothing to send: still find out whether the server is reachable, so
// the terminal's Online/Offline badge is truthful.
offline = !(await fetch("/api/health", { cache: "no-store" }).then((r) => r.ok).catch(() => false));
}
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
try {
const { results } = await apiFetch<{ results: SaleResult[] }>("/api/transactions/cash", {
method: "POST",
body: JSON.stringify({ sales: chunk.map(toPayload) }),
});
await applyResults(results);
synced += results.filter((r) => r.status !== "rejected").length;
rejected += results.filter((r) => r.status === "rejected").length;
} catch (err) {
// 401/403: signed out or blocked — keep everything for later.
// Network error: offline — same. Nothing is ever dropped here.
offline = !(err instanceof ApiError);
break;
}
}
await pruneSynced(owner.id);
const counts = await queueCounts(owner.id);
return { synced, rejected, pending: counts.pending, offline };
}
/** Flush on reconnect and every 30s while online. Returns an unsubscribe. */
export function registerOfflineSync(owner: QueueOwner, onFlush?: (r: FlushResult) => void) {
if (typeof window === "undefined") return () => {}; if (typeof window === "undefined") return () => {};
const handler = async () => { const handler = async () => {
const result = await flushQueue(); const result = await flushQueue(owner);
onFlush?.(result); onFlush?.(result);
}; };
+45 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { isValidObjectId } from "mongoose";
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt"; import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
import { UserRole } from "@/lib/server/models/User"; import { UserRole } from "@/lib/server/models/User";
@@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?:
return auth.branchId; return auth.branchId;
} }
export const MANAGERS: UserRole[] = ["BRANCH_MANAGER", "SUPER_ADMIN"];
type Gate = { ok: true; auth: AccessTokenPayload } | { ok: false; response: NextResponse };
/**
* Authenticate the request and, when `roles` is given, authorize it.
* 401 = not signed in; 403 = signed in with the wrong role. Every route
* that limits roles must use this rather than relying on the admin pages
* redirecting — the PRD requires enforcement on the backend too.
*/
export function authorize(req: NextRequest, roles?: UserRole[]): Gate {
const auth = getAuth(req);
if (!auth) {
return { ok: false, response: NextResponse.json({ error: "Not authenticated" }, { status: 401 }) };
}
if (roles && !roles.includes(auth.role)) {
return {
ok: false,
response: NextResponse.json({ error: "You do not have permission to do this" }, { status: 403 }),
};
}
return { ok: true, auth };
}
type BranchParam = { ok: true; branchId: string | null } | { ok: false; response: NextResponse };
/**
* Resolve the `branchId` query parameter for a read route: owners may pick
* a branch or ALL, everyone else is pinned to their own branch. Rejects a
* malformed id with 400 instead of letting ObjectId() throw a 500.
*/
export function branchParam(auth: AccessTokenPayload, raw: string | null | undefined): BranchParam {
const branchId = resolveBranchScope(auth, raw);
if (branchId && !isValidObjectId(branchId)) {
return { ok: false, response: NextResponse.json({ error: "Invalid branchId" }, { status: 400 }) };
}
return { ok: true, branchId };
}
export function badRequest(message: string): NextResponse {
return NextResponse.json({ error: message }, { status: 400 });
}
export function isProd(): boolean { export function isProd(): boolean {
return process.env.NODE_ENV === "production"; return process.env.NODE_ENV === "production";
} }
+40
View File
@@ -0,0 +1,40 @@
// The business day for every tenant is the Kenyan calendar day
// (Africa/Nairobi, EAT). EAT is a fixed UTC+3 with no daylight saving, so a
// constant offset is exact — no timezone database needed.
//
// Never use `new Date().setHours(0)` or `toISOString().slice(0, 10)` for
// "today" on the server: on Vercel the server clock is UTC, which would
// start the business day at 03:00 EAT and push early-morning sales onto
// the previous day.
const EAT_OFFSET_MS = 3 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** Today's business day as YYYY-MM-DD. */
export function todayNairobi(now: Date = new Date()): string {
return new Date(now.getTime() + EAT_OFFSET_MS).toISOString().slice(0, 10);
}
export function isBusinessDate(value: string): boolean {
if (!DATE_RE.test(value)) return false;
const d = new Date(value + "T00:00:00.000Z");
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
/** The UTC instants [start, end) covering one business day. */
export function dayRangeNairobi(date: string): { start: Date; end: Date } {
if (!isBusinessDate(date)) throw new Error(`Invalid business date: ${date}`);
const start = new Date(new Date(date + "T00:00:00.000Z").getTime() - EAT_OFFSET_MS);
return { start, end: new Date(start.getTime() + DAY_MS) };
}
/** Start of the business day containing `now`. */
export function startOfTodayNairobi(now: Date = new Date()): Date {
return dayRangeNairobi(todayNairobi(now)).start;
}
/** Start of the current business month (1st, 00:00 EAT). */
export function startOfMonthNairobi(now: Date = new Date()): Date {
return dayRangeNairobi(todayNairobi(now).slice(0, 8) + "01").start;
}
+72 -19
View File
@@ -1,26 +1,60 @@
import { z } from "zod"; import { z } from "zod";
const schema = z.object({ const booleanFlag = z
NODE_ENV: z.enum(["development", "production", "test"]).default("development"), .enum(["true", "false", "1", "0", ""])
MONGODB_URI: z.string().min(1), .optional()
.transform((v) => v === "true" || v === "1");
JWT_ACCESS_SECRET: z.string().min(16), const schema = z
JWT_REFRESH_SECRET: z.string().min(16), .object({
JWT_ACCESS_TTL: z.string().default("15m"), NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
JWT_REFRESH_TTL: z.string().default("30d"), MONGODB_URI: z.string().min(1),
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"), JWT_ACCESS_SECRET: z.string().min(16),
DARAJA_CONSUMER_KEY: z.string().min(1), JWT_REFRESH_SECRET: z.string().min(16),
DARAJA_CONSUMER_SECRET: z.string().min(1), JWT_ACCESS_TTL: z.string().default("15m"),
DARAJA_SHORTCODE: z.string().min(1), JWT_REFRESH_TTL: z.string().default("30d"),
DARAJA_PASSKEY: z.string().min(1), DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
DARAJA_TRANSACTION_TYPE: z
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"]) // M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it
.default("CustomerPayBillOnline"), // is OFF unless explicitly enabled. When off, the Daraja variables are
DARAJA_CALLBACK_URL: z.string().url(), // not required and every /api/mpesa route answers 404.
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(), FEATURE_MPESA: booleanFlag,
});
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
DARAJA_CONSUMER_KEY: z.string().optional(),
DARAJA_CONSUMER_SECRET: z.string().optional(),
DARAJA_SHORTCODE: z.string().optional(),
DARAJA_PASSKEY: z.string().optional(),
DARAJA_TRANSACTION_TYPE: z
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
.default("CustomerPayBillOnline"),
DARAJA_CALLBACK_URL: z.string().url().optional(),
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
})
.superRefine((env, ctx) => {
if (!env.FEATURE_MPESA) return;
const required = [
"DARAJA_CONSUMER_KEY",
"DARAJA_CONSUMER_SECRET",
"DARAJA_SHORTCODE",
"DARAJA_PASSKEY",
"DARAJA_CALLBACK_URL",
] as const;
for (const key of required) {
if (!env[key]) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "required when FEATURE_MPESA=true" });
}
}
// An open callback endpoint lets anyone mark a pending sale as paid.
if (env.NODE_ENV === "production" && !env.DARAJA_CALLBACK_IP_ALLOWLIST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["DARAJA_CALLBACK_IP_ALLOWLIST"],
message: "required in production when FEATURE_MPESA=true",
});
}
});
type ServerEnv = z.infer<typeof schema>; type ServerEnv = z.infer<typeof schema>;
let cached: ServerEnv | null = null; let cached: ServerEnv | null = null;
@@ -35,3 +69,22 @@ export function getEnv(): ServerEnv {
cached = parsed.data; cached = parsed.data;
return cached; return cached;
} }
export function isMpesaEnabled(): boolean {
return getEnv().FEATURE_MPESA;
}
/** Daraja settings, only valid when FEATURE_MPESA is on (validated above). */
export function getDarajaEnv() {
const env = getEnv();
if (!env.FEATURE_MPESA) throw new Error("M-Pesa is disabled (FEATURE_MPESA is not true)");
return {
DARAJA_ENV: env.DARAJA_ENV,
DARAJA_CONSUMER_KEY: env.DARAJA_CONSUMER_KEY!,
DARAJA_CONSUMER_SECRET: env.DARAJA_CONSUMER_SECRET!,
DARAJA_SHORTCODE: env.DARAJA_SHORTCODE!,
DARAJA_PASSKEY: env.DARAJA_PASSKEY!,
DARAJA_TRANSACTION_TYPE: env.DARAJA_TRANSACTION_TYPE,
DARAJA_CALLBACK_URL: env.DARAJA_CALLBACK_URL!,
};
}
+2
View File
@@ -10,6 +10,7 @@ export interface IProduct extends Document {
unitPrice: number; unitPrice: number;
sortOrder: number; sortOrder: number;
isActive: boolean; isActive: boolean;
lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -24,6 +25,7 @@ const productSchema = new Schema<IProduct>(
unitPrice: { type: Number, required: true, min: 0 }, unitPrice: { type: Number, required: true, min: 0 },
sortOrder: { type: Number, default: 0 }, sortOrder: { type: Number, default: 0 },
isActive: { type: Boolean, default: true, index: true }, isActive: { type: Boolean, default: true, index: true },
lowStockThreshold: { type: Number, default: 10, min: 0 },
}, },
{ timestamps: true } { timestamps: true }
); );
+51
View File
@@ -0,0 +1,51 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
export type MovementType = (typeof MOVEMENT_TYPES)[number];
export interface IStockMovement extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId;
branchId: Types.ObjectId;
productId: Types.ObjectId;
type: MovementType;
quantity: number; // signed: + adds stock to the branch, - removes it
refId: string; // the document that caused it (transaction, purchase, transfer, adjustment)
note: string | null;
recordedBy: Types.ObjectId | null;
createdAt: Date; // authoritative — server clock
}
/**
* The stock ledger. A branch's stock of a product is the SUM of its
* movements — there is no separately stored balance that could drift out
* of step with history.
*
* Every movement names the document that caused it, and (type, refId) is
* unique. Writing a movement is therefore idempotent: replaying an offline
* sale, retrying a transfer, or a second M-Pesa callback can never deduct
* or add the same stock twice. A transfer writes two movements with the
* same refId but different types (TRANSFER_OUT, TRANSFER_IN).
*/
const stockMovementSchema = new Schema<IStockMovement>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
type: { type: String, enum: MOVEMENT_TYPES, required: true },
quantity: { type: Number, required: true },
refId: { type: String, required: true },
note: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: { createdAt: true, updatedAt: false } }
);
stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
// Stock-level aggregation and the per-branch movement history.
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
export const StockMovement =
(models.StockMovement as Model<IStockMovement>) ||
model<IStockMovement>("StockMovement", stockMovementSchema);
+40
View File
@@ -0,0 +1,40 @@
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IStockTransfer extends Document {
_id: Types.ObjectId;
businessId: Types.ObjectId;
fromBranchId: Types.ObjectId;
toBranchId: Types.ObjectId;
productId: Types.ObjectId;
quantity: number;
clientUuid: string; // idempotency key from the form, so a double-submit moves stock once
note: string | null;
recordedBy: Types.ObjectId;
createdAt: Date;
}
/**
* An inter-branch stock transfer. The stock itself moves through two
* StockMovement rows (TRANSFER_OUT at the source, TRANSFER_IN at the
* destination) whose refId is this transfer's id.
*/
const stockTransferSchema = new Schema<IStockTransfer>(
{
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true },
fromBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
toBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
quantity: { type: Number, required: true, min: 1 },
clientUuid: { type: String, required: true },
note: { type: String, default: null, trim: true },
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
},
{ timestamps: { createdAt: true, updatedAt: false } }
);
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
export const StockTransfer =
(models.StockTransfer as Model<IStockTransfer>) ||
model<IStockTransfer>("StockTransfer", stockTransferSchema);
+15 -3
View File
@@ -9,7 +9,7 @@ export interface ITransaction extends Document {
branchId: Types.ObjectId; branchId: Types.ObjectId;
staffId: Types.ObjectId; staffId: Types.ObjectId;
customerName: string | null; customerName: string | null;
customerPhone: string; customerPhone: string | null; // optional for cash sales; required for M-Pesa
productId: Types.ObjectId; productId: Types.ObjectId;
sizeLiters: number; // snapshotted at sale time sizeLiters: number; // snapshotted at sale time
quantity: number; quantity: number;
@@ -26,8 +26,15 @@ export interface ITransaction extends Document {
resultDesc: string | null; resultDesc: string | null;
}; };
clientUuid: string; // idempotency key generated on-device clientUuid: string; // idempotency key for this line: `${saleId}:${lineIndex}` for cash sales
saleId: string | null; // groups the lines of one multi-item sale (the device's sale UUID)
lineIndex: number;
syncedFromOffline: boolean; syncedFromOffline: boolean;
// What the device showed and collected per unit. The server price stays
// authoritative; a difference (e.g. the price changed while the device
// was offline) is kept and flagged rather than silently overwritten.
clientUnitPrice: number | null;
priceMismatch: boolean;
// TAMPER PREVENTION: this is what the device *claimed* the time was when // TAMPER PREVENTION: this is what the device *claimed* the time was when
// the sale happened — informational only, e.g. for offline-queue // the sale happened — informational only, e.g. for offline-queue
@@ -50,7 +57,7 @@ const transactionSchema = new Schema<ITransaction>(
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true }, staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
customerName: { type: String, default: null, trim: true }, customerName: { type: String, default: null, trim: true },
customerPhone: { type: String, required: true }, customerPhone: { type: String, default: null },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
sizeLiters: { type: Number, required: true }, sizeLiters: { type: Number, required: true },
quantity: { type: Number, required: true, min: 1 }, quantity: { type: Number, required: true, min: 1 },
@@ -73,7 +80,11 @@ const transactionSchema = new Schema<ITransaction>(
}, },
clientUuid: { type: String, required: true }, clientUuid: { type: String, required: true },
saleId: { type: String, default: null },
lineIndex: { type: Number, default: 0 },
syncedFromOffline: { type: Boolean, default: false }, syncedFromOffline: { type: Boolean, default: false },
clientUnitPrice: { type: Number, default: null },
priceMismatch: { type: Boolean, default: false },
clientSubmittedAt: { type: Date, default: null }, clientSubmittedAt: { type: Date, default: null },
}, },
{ timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input { timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input
@@ -92,6 +103,7 @@ transactionSchema.index({ clientUuid: 1 }, { unique: true });
transactionSchema.index({ branchId: 1, createdAt: -1 }); transactionSchema.index({ branchId: 1, createdAt: -1 });
transactionSchema.index({ businessId: 1, createdAt: -1 }); transactionSchema.index({ businessId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 }); transactionSchema.index({ paymentStatus: 1 });
transactionSchema.index({ businessId: 1, saleId: 1 });
export const Transaction = export const Transaction =
(models.Transaction as Model<ITransaction>) || (models.Transaction as Model<ITransaction>) ||
+1 -1
View File
@@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { getEnv } from "@/lib/server/env"; import { getDarajaEnv as getEnv } from "@/lib/server/env";
function baseUrl(): string { function baseUrl(): string {
return getEnv().DARAJA_ENV === "production" return getEnv().DARAJA_ENV === "production"
+140
View File
@@ -0,0 +1,140 @@
import mongoose, { Types } from "mongoose";
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
import { Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import type { ITransaction } from "@/lib/server/models/Transaction";
type Id = Types.ObjectId | string;
export interface MovementInput {
businessId: Id;
branchId: Id;
productId: Id;
type: MovementType;
quantity: number; // signed
refId: string;
note?: string | null;
recordedBy?: Id | null;
}
/**
* Write one ledger movement, idempotently: if a movement with the same
* (type, refId) already exists nothing changes. Safe to call again on any
* retry or replay.
*/
export async function recordMovement(m: MovementInput): Promise<void> {
await StockMovement.updateOne(
{ type: m.type, refId: m.refId },
{
$setOnInsert: {
businessId: m.businessId,
branchId: m.branchId,
productId: m.productId,
quantity: m.quantity,
note: m.note ?? null,
recordedBy: m.recordedBy ?? null,
},
},
{ upsert: true }
);
}
/**
* Deduct a completed sale from its branch's stock. Only SUCCESS sales move
* stock — a PENDING or FAILED M-Pesa prompt never does.
*/
export async function recordSaleMovement(
txn: Pick<ITransaction, "_id" | "businessId" | "branchId" | "productId" | "quantity" | "paymentStatus" | "staffId">
): Promise<void> {
if (txn.paymentStatus !== "SUCCESS") return;
await recordMovement({
businessId: txn.businessId,
branchId: txn.branchId,
productId: txn.productId,
type: "SALE",
quantity: -Math.abs(txn.quantity),
refId: txn._id.toString(),
recordedBy: txn.staffId,
});
}
/** Ledger refIds derived from client-generated keys are namespaced by tenant. */
export function clientRefId(businessId: Id, clientUuid: string): string {
return `${businessId.toString()}:${clientUuid}`;
}
/** Current stock of one product at one branch (the sum of its movements). */
export async function stockOf(businessId: Id, branchId: Id, productId: Id): Promise<number> {
const [row] = await StockMovement.aggregate<{ total: number }>([
{
$match: {
businessId: new mongoose.Types.ObjectId(businessId.toString()),
branchId: new mongoose.Types.ObjectId(branchId.toString()),
productId: new mongoose.Types.ObjectId(productId.toString()),
},
},
{ $group: { _id: null, total: { $sum: "$quantity" } } },
]);
return row?.total ?? 0;
}
export type StockStatus = "OK" | "LOW" | "OUT";
export interface StockLevel {
branchId: string;
branchName: string;
productId: string;
productLabel: string;
unit: string;
quantity: number;
lowStockThreshold: number;
status: StockStatus;
}
export function stockStatus(quantity: number, threshold: number): StockStatus {
if (quantity <= 0) return "OUT";
if (quantity <= threshold) return "LOW";
return "OK";
}
/**
* Stock of every active product at every branch in scope — including
* zero rows, so an owner sees "0" rather than a missing product. A
* negative quantity is reported as-is: it means sales were recorded
* (typically offline) for stock the ledger never received, and the branch
* needs a count/adjustment.
*/
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
const branchFilter: Record<string, unknown> = { businessId: bizObj };
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
const [branches, products, totals] = await Promise.all([
Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(),
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
]),
]);
const totalByKey = new Map(totals.map((t) => [`${t._id.branchId}:${t._id.productId}`, t.total]));
const levels: StockLevel[] = [];
for (const b of branches) {
for (const p of products) {
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
const lowStockThreshold = p.lowStockThreshold ?? 10;
levels.push({
branchId: b._id.toString(),
branchName: b.branchName,
productId: p._id.toString(),
productLabel: p.label,
unit: p.unit ?? "L",
quantity,
lowStockThreshold,
status: stockStatus(quantity, lowStockThreshold),
});
}
}
return levels;
}
+16 -1
View File
@@ -8,7 +8,7 @@ import { Types } from "mongoose";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { Business, IBusiness } from "@/lib/server/models/Business"; import { Business, IBusiness } from "@/lib/server/models/Business";
import { TIERS, type TierId } from "@/lib/tiers"; import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export function tierOf(business: Pick<IBusiness, "tier">): TierId { export function tierOf(business: Pick<IBusiness, "tier">): TierId {
return (business.tier as TierId) ?? "STARTER"; return (business.tier as TierId) ?? "STARTER";
@@ -53,3 +53,18 @@ export async function loadTier(businessId: Types.ObjectId | string): Promise<Tie
const biz = await Business.findById(businessId).select("tier").lean(); const biz = await Business.findById(businessId).select("tier").lean();
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness); return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
} }
/**
* Returns a user-facing reason this tenant may not use the app right now,
* or null when access is allowed. Checked at login and on every token
* refresh, so a suspension takes effect within one access-token lifetime
* (15 minutes) even for devices that are already signed in.
*/
export async function tenantAccessError(businessId: Types.ObjectId | string): Promise<string | null> {
const biz = await Business.findById(businessId).select("isActive subscriptionStatus").lean();
if (!biz || !biz.isActive) return "This business account has been deactivated.";
if (!isUsable((biz.subscriptionStatus as SubscriptionStatus) ?? "TRIALING")) {
return "This business account is suspended. Contact support to restore access.";
}
return null;
}
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
import { isValidObjectId } from "mongoose";
/** A MongoDB ObjectId string. Validating up front turns a would-be CastError 500 into a 400. */
export const objectId = z
.string()
.trim()
.refine((v) => isValidObjectId(v) && /^[0-9a-f]{24}$/i.test(v), { message: "Invalid id" });
+50
View File
@@ -0,0 +1,50 @@
import type { AuthUser, Product } from "@/lib/types";
/**
* The last confirmed session and catalogue, so the terminal can start and
* keep selling with no network. Only a snapshot for display and for
* labelling queued sales: the server still authenticates every sync with
* the httpOnly cookies and re-prices every line.
*/
const KEY = "aquaflow_terminal_session";
export interface SessionSnapshot {
user: AuthUser;
features: { mpesa: boolean };
products: Product[];
savedAt: string;
}
export function loadSession(): SessionSnapshot | null {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as SessionSnapshot) : null;
} catch {
return null;
}
}
export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser }): void {
try {
const prev = loadSession();
const sameUser = prev?.user.id === update.user.id;
const next: SessionSnapshot = {
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
products: update.products ?? (sameUser ? prev!.products : []),
user: update.user,
savedAt: new Date().toISOString(),
};
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
// Storage full or blocked: the terminal still works online.
}
}
export function clearSession(): void {
try {
localStorage.removeItem(KEY);
} catch {
// ignore
}
}
+11
View File
@@ -0,0 +1,11 @@
// Shared between the terminal's offline queue and POST /api/transactions/cash.
/** Largest batch the offline queue sends in one request. */
export const MAX_SALES_PER_REQUEST = 50;
/** Largest number of product lines in one sale. */
export const MAX_LINES_PER_SALE = 20;
export type SaleResult =
| { clientUuid: string; status: "saved" | "duplicate"; amountTotal: number; priceMismatch: boolean }
| { clientUuid: string; status: "rejected"; error: string };
+23 -1
View File
@@ -31,6 +31,7 @@ export interface Product {
unitPrice: number; unitPrice: number;
sortOrder: number; sortOrder: number;
isActive: boolean; isActive: boolean;
lowStockThreshold?: number;
} }
export interface Transaction { export interface Transaction {
@@ -38,7 +39,7 @@ export interface Transaction {
branchId: string | { _id: string; branchName: string }; branchId: string | { _id: string; branchName: string };
staffId: string; staffId: string;
customerName: string | null; customerName: string | null;
customerPhone: string; customerPhone: string | null;
sizeLiters: number; sizeLiters: number;
quantity: number; quantity: number;
unitPrice: number; unitPrice: number;
@@ -84,3 +85,24 @@ export interface ReconciliationRow {
expectedRemaining: number; expectedRemaining: number;
reconciledOk: boolean | null; reconciledOk: boolean | null;
} }
export interface StockLevel {
branchId: string;
branchName: string;
productId: string;
productLabel: string;
unit: string;
quantity: number;
lowStockThreshold: number;
status: "OK" | "LOW" | "OUT";
}
export interface StockMovementRow {
_id: string;
type: "SALE" | "PURCHASE" | "TRANSFER_OUT" | "TRANSFER_IN" | "ADJUSTMENT";
quantity: number;
note: string | null;
createdAt: string;
branchId: { _id: string; branchName: string } | null;
productId: { _id: string; label: string; unit?: string } | null;
recordedBy: { _id: string; name: string } | null;
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import bcrypt from "bcryptjs";
import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import * as profit from "@/app/api/reports/profit/route";
import * as expenses from "@/app/api/expenses/route";
import * as purchases from "@/app/api/purchases/route";
import * as products from "@/app/api/products/route";
import * as productById from "@/app/api/products/[id]/route";
import * as userById from "@/app/api/users/[id]/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as summary from "@/app/api/dashboard/summary/route";
import * as login from "@/app/api/auth/login/route";
import * as signup from "@/app/api/auth/signup/route";
import * as stkpush from "@/app/api/mpesa/stkpush/route";
import * as callback from "@/app/api/mpesa/callback/route";
import { json, makeTenant, params, req, uuid } from "./helpers";
describe("role-based access on the backend", () => {
it("blocks cashiers from owner-level money endpoints with 403", async () => {
const t = await makeTenant();
const as = t.cashierA;
const responses = await Promise.all([
profit.GET(req("GET", "/api/reports/profit", { as })),
expenses.GET(req("GET", "/api/expenses", { as })),
purchases.GET(req("GET", "/api/purchases", { as })),
expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })),
purchases.POST(
req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } })
),
]);
expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]);
});
it("still lets a branch manager use them for their own branch", async () => {
const t = await makeTenant();
const res = await expenses.POST(
req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } })
);
expect(res.status).toBe(201);
expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200);
});
it("returns 401, not 403, when signed out", async () => {
expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401);
});
it("only lets the owner create or reprice products", async () => {
const t = await makeTenant();
const body = { label: "1L", sizeLiters: 1, unitPrice: 20 };
expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403);
expect(
(await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 })))
.status
).toBe(403);
expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201);
});
it("rejects a malformed branchId with 400 instead of crashing", async () => {
const t = await makeTenant();
const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner }));
expect(res.status).toBe(400);
});
});
describe("user deactivation", () => {
it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => {
const t = await makeTenant();
await UserSession.create({
userId: t.cashierA.id,
deviceId: uuid(),
refreshTokenHash: "h",
expiresAt: new Date(Date.now() + 86_400_000),
});
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
params({ id: t.cashierA.id })
);
expect(res.status).toBe(200);
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
});
it("does not let a manager touch another branch's staff", async () => {
const t = await makeTenant();
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
params({ id: t.cashierB.id })
);
expect(res.status).toBe(403);
});
it("does not let the owner deactivate themselves", async () => {
const t = await makeTenant();
const res = await userById.PATCH(
req("PATCH", "/x", { as: t.owner, body: { isActive: false } }),
params({ id: t.owner.id })
);
expect(res.status).toBe(403);
});
});
describe("tenant status", () => {
it("refuses login for a suspended business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
const res = await login.POST(
req("POST", "/api/auth/login", {
body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() },
})
);
expect(res.status).toBe(403);
});
it("still allows login for an active business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
const res = await login.POST(
req("POST", "/api/auth/login", {
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
})
);
expect(res.status).toBe(200);
});
});
describe("platform and M-Pesa hardening", () => {
it("refuses public signup with a platform-operator email", async () => {
const res = await signup.POST(
req("POST", "/api/auth/signup", {
body: {
businessName: "Takeover Ltd",
ownerName: "Mallory",
email: "operator@aquaflow.test",
phoneNumber: "0712345678",
password: "password123",
deviceId: uuid(),
},
})
);
expect(res.status).toBe(403);
});
it("hides every M-Pesa route when FEATURE_MPESA is off", async () => {
const t = await makeTenant();
const push = await stkpush.POST(
req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } })
);
const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } }));
expect([push.status, cb.status]).toEqual([404, 404]);
});
it("lets only the owner rename a branch", async () => {
const t = await makeTenant();
const body = { branchName: "Renamed" };
expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403);
const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA })));
expect(ok.status).toBe(200);
expect(ok.body.branch.branchName).toBe("Renamed");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay";
describe("Nairobi business day", () => {
it("rolls over at midnight EAT, not midnight UTC", () => {
// 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct.
expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12");
// 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct.
expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11");
});
it("maps a business date to the UTC range 21:00 -> 21:00", () => {
const { start, end } = dayRangeNairobi("2026-10-12");
expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z");
expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z");
});
it("starts the month on the 1st at 00:00 EAT", () => {
expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z");
expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z");
});
it("rejects malformed dates", () => {
expect(isBusinessDate("2026-02-30")).toBe(false);
expect(isBusinessDate("2026-2-3")).toBe(false);
expect(isBusinessDate("2026-02-28")).toBe(true);
expect(() => dayRangeNairobi("nope")).toThrow();
});
});
+12
View File
@@ -0,0 +1,12 @@
import { MongoMemoryServer } from "mongodb-memory-server";
let server: MongoMemoryServer | null = null;
export async function setup({ provide }: { provide: (key: string, value: string) => void }) {
server = await MongoMemoryServer.create();
provide("mongoUri", server.getUri("aquaflow-test"));
}
export async function teardown() {
await server?.stop();
}
+133
View File
@@ -0,0 +1,133 @@
import { randomUUID } from "crypto";
import mongoose from "mongoose";
import { NextRequest } from "next/server";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User, UserRole } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { signAccessToken } from "@/lib/server/jwt";
import { ACCESS_COOKIE } from "@/lib/server/auth";
let phoneSeq = 0;
function nextPhone(): string {
phoneSeq += 1;
return "2547" + String(10_000_000 + phoneSeq).slice(-8);
}
export interface Actor {
id: string;
businessId: string;
role: UserRole;
branchId: string | null;
email: string;
}
export interface Tenant {
businessId: string;
owner: Actor;
branchA: string;
branchB: string;
managerA: Actor;
cashierA: Actor;
cashierB: Actor;
water20: string;
water5: string;
}
async function makeUser(
businessId: string,
role: UserRole,
branchId: string | null,
label: string
): Promise<Actor> {
const email = `${label}-${randomUUID().slice(0, 8)}@aquaflow.test`;
const user = await User.create({
businessId,
name: label,
role,
branchId,
phoneNumber: nextPhone(),
email,
passwordHash: "$2a$12$abcdefghijklmnopqrstuuJ0dBz7m6Y8yCjvC7vQ7hZqXbY8yCjvC", // never used to log in
});
return { id: user._id.toString(), businessId, role, branchId, email };
}
/** A complete tenant: owner, two branches, a manager, a cashier per branch, two products. */
export async function makeTenant(name = "Tenant"): Promise<Tenant> {
const business = await Business.create({
name,
ownerId: new mongoose.Types.ObjectId(),
tier: "ENTERPRISE",
subscriptionStatus: "ACTIVE",
});
const businessId = business._id.toString();
const owner = await makeUser(businessId, "SUPER_ADMIN", null, name + "-owner");
business.ownerId = new mongoose.Types.ObjectId(owner.id);
await business.save();
const a = await Branch.create({ businessId, branchName: name + " A", locationCity: "Nairobi" });
const b = await Branch.create({ businessId, branchName: name + " B", locationCity: "Thika" });
const managerA = await makeUser(businessId, "BRANCH_MANAGER", a._id.toString(), name + "-mgrA");
const cashierA = await makeUser(businessId, "DELIVERY", a._id.toString(), name + "-cashA");
const cashierB = await makeUser(businessId, "DELIVERY", b._id.toString(), name + "-cashB");
const [p20, p5] = await Product.insertMany([
{ businessId, label: "20L Jerrycan", sizeLiters: 20, unitPrice: 200, sortOrder: 1 },
{ businessId, label: "5L Jerrycan", sizeLiters: 5, unitPrice: 60, sortOrder: 2 },
]);
return {
businessId,
owner,
branchA: a._id.toString(),
branchB: b._id.toString(),
managerA,
cashierA,
cashierB,
water20: p20._id.toString(),
water5: p5._id.toString(),
};
}
export function tokenFor(actor: Actor, deviceId = "00000000-0000-4000-8000-000000000001"): string {
return signAccessToken({
sub: actor.id,
businessId: actor.businessId,
role: actor.role,
branchId: actor.branchId,
deviceId,
});
}
export function req(
method: string,
path: string,
opts: { as?: Actor; body?: unknown; cookies?: Record<string, string> } = {}
): NextRequest {
const cookies: Record<string, string> = { ...(opts.cookies ?? {}) };
if (opts.as) cookies[ACCESS_COOKIE] = tokenFor(opts.as);
const cookieHeader = Object.entries(cookies)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return new NextRequest(new URL(path, "http://localhost"), {
method,
headers: {
"content-type": "application/json",
...(cookieHeader ? { cookie: cookieHeader } : {}),
},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});
}
export function params<T extends Record<string, string>>(p: T) {
return { params: p };
}
export async function json<T = any>(res: Response): Promise<{ status: number; body: T }> {
return { status: res.status, body: (await res.json()) as T };
}
export function uuid(): string {
return randomUUID();
}
+26
View File
@@ -0,0 +1,26 @@
import { afterAll, beforeEach, inject } from "vitest";
import mongoose from "mongoose";
import { connectDb } from "@/lib/server/db";
declare module "vitest" {
export interface ProvidedContext {
mongoUri: string;
}
}
process.env.MONGODB_URI = inject("mongoUri");
process.env.JWT_ACCESS_SECRET = "test-access-secret-0123456789";
process.env.JWT_REFRESH_SECRET = "test-refresh-secret-0123456789";
process.env.PLATFORM_ADMIN_EMAILS = "operator@aquaflow.test";
beforeEach(async () => {
await connectDb();
const collections = await mongoose.connection.db!.collections();
await Promise.all(collections.map((c) => c.deleteMany({})));
await Promise.all(Object.values(mongoose.models).map((m) => m.syncIndexes()));
});
afterAll(async () => {
await mongoose.disconnect();
global.__mongooseCache = { conn: null, promise: null };
});
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { stockOf } from "@/lib/server/stock";
import * as stock from "@/app/api/stock/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import * as purchases from "@/app/api/purchases/route";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
async function addStock(t: Tenant, as: Actor, branchId: string, productId: string, quantity: number) {
const res = await adjustments.POST(
req("POST", "/x", { as, body: { clientUuid: uuid(), branchId, productId, mode: "DELTA", quantity, note: "opening" } })
);
expect(res.status).toBe(201);
}
describe("stock ledger", () => {
it("adds purchased stock to the receiving branch", async () => {
const t = await makeTenant();
const res = await purchases.POST(
req("POST", "/x", {
as: t.managerA,
body: { supplierName: "Kiwanja Water", productId: t.water20, quantity: 40, unitCost: 120 },
})
);
expect(res.status).toBe(201);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(40);
});
it("corrects stock to a physical count, once per submit", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchA, t.water20, 50);
const body = { clientUuid: uuid(), productId: t.water20, mode: "COUNT", quantity: 47, note: "evening count" };
const first = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body })));
expect(first.body.quantity).toBe(47);
// A double-submit of the same count changes nothing further.
const again = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body })));
expect(again.body.quantity).toBe(47);
});
it("does not let a manager adjust another branch", async () => {
const t = await makeTenant();
const res = await adjustments.POST(
req("POST", "/x", {
as: t.managerA,
body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 5 },
})
);
expect(res.status).toBe(403);
});
it("reports stock per branch with low and out-of-stock flags", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchA, t.water20, 8); // threshold defaults to 10
await addStock(t, t.owner, t.branchA, t.water5, 30);
const { body } = await json(await stock.GET(req("GET", "/api/stock", { as: t.owner })));
const find = (b: string, p: string) => body.levels.find((l: any) => l.branchId === b && l.productId === p);
expect(body.levels).toHaveLength(4); // 2 branches x 2 products, zero rows included
expect(find(t.branchA, t.water20)).toMatchObject({ quantity: 8, status: "LOW" });
expect(find(t.branchA, t.water5)).toMatchObject({ quantity: 30, status: "OK" });
expect(find(t.branchB, t.water20)).toMatchObject({ quantity: 0, status: "OUT" });
});
it("shows a cashier only their own branch's stock", async () => {
const t = await makeTenant();
const { body } = await json(await stock.GET(req("GET", "/api/stock?branchId=" + t.branchB, { as: t.cashierA })));
expect(new Set(body.levels.map((l: any) => l.branchId))).toEqual(new Set([t.branchA]));
});
});
describe("inter-branch transfers", () => {
it("moves stock out of one branch and into the other", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchA, t.water20, 30);
const res = await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 12 },
})
);
expect(res.status).toBe(201);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(18);
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(12);
});
it("applies a double-submitted transfer once", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchA, t.water20, 30);
const body = { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 10 };
await transfers.POST(req("POST", "/x", { as: t.managerA, body }));
const second = await transfers.POST(req("POST", "/x", { as: t.managerA, body }));
expect(second.status).toBe(200);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20);
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(10);
});
it("refuses to send more than the branch has", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchA, t.water20, 5);
const res = await transfers.POST(
req("POST", "/x", {
as: t.managerA,
body: { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 6 },
})
);
expect(res.status).toBe(409);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5);
});
it("only lets a manager send from their own branch", async () => {
const t = await makeTenant();
await addStock(t, t.owner, t.branchB, t.water20, 5);
const res = await transfers.POST(
req("POST", "/x", {
as: t.managerA,
body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 1 },
})
);
expect(res.status).toBe(403);
});
it("cannot move stock into another tenant's branch", async () => {
const t = await makeTenant("Alpha");
const other = await makeTenant("Beta");
await addStock(t, t.owner, t.branchA, t.water20, 5);
const res = await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: other.branchA, productId: t.water20, quantity: 1 },
})
);
expect(res.status).toBe(404);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { Transaction } from "@/lib/server/models/Transaction";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { stockOf } from "@/lib/server/stock";
import * as cash from "@/app/api/transactions/cash/route";
import type { SaleResult } from "@/lib/syncProtocol";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
return {
clientUuid: uuid(),
staffId: cashier.id,
branchId: cashier.branchId!,
clientSubmittedAt: new Date().toISOString(),
lines,
...extra,
};
}
async function post(as: Actor, sales: unknown[]) {
return json<{ results: SaleResult[] }>(await cash.POST(req("POST", "/api/transactions/cash", { as, body: { sales } })));
}
describe("offline sale sync", () => {
it("records a multi-item sale as one line per product and deducts each from stock", async () => {
const t = await makeTenant();
const s = sale(t, t.cashierA, [
{ productId: t.water20, quantity: 2, unitPrice: 200 },
{ productId: t.water5, quantity: 3, unitPrice: 60 },
]);
const { status, body } = await post(t.cashierA, [s]);
expect(status).toBe(200);
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
});
it("reports a replayed sale as duplicate, so the device can stop resending it", async () => {
const t = await makeTenant();
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]);
await post(t.cashierA, [s]);
const replay = await post(t.cashierA, [s]);
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
});
it("syncs a 120-sale backlog in chunks with no duplicates (the old 100-row dead end)", async () => {
const t = await makeTenant();
const backlog = Array.from({ length: 120 }, () => sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]));
// An over-sized batch is refused outright...
const tooBig = await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: backlog } }));
expect(tooBig.status).toBe(400);
// ...and the device sends it in chunks of 50, twice over (a full replay).
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < backlog.length; i += 50) {
const { status } = await post(t.cashierA, backlog.slice(i, i + 50));
expect(status).toBe(200);
}
}
expect(await Transaction.countDocuments({ businessId: t.businessId })).toBe(120);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-120);
});
it("rejects one bad sale without blocking the rest of the batch", async () => {
const t = await makeTenant();
const good = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]);
const malformed = { clientUuid: uuid(), lines: [] };
const { body } = await post(t.cashierA, [malformed, good]);
const byId = Object.fromEntries(body.results.map((r) => [r.clientUuid, r.status]));
expect(byId[malformed.clientUuid]).toBe("rejected");
expect(byId[good.clientUuid]).toBe("saved");
});
it("refuses to credit a queued sale to whoever happens to be signed in", async () => {
const t = await makeTenant();
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
const { body } = await post(t.cashierA, [otherCashiersSale]);
expect(body.results[0].status).toBe("rejected");
expect(await Transaction.countDocuments({})).toBe(0);
});
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
const t = await makeTenant();
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
const moved = { ...t.cashierA, branchId: t.branchB };
await post(moved, [s]);
const txn = await Transaction.findOne({ saleId: s.clientUuid });
expect(txn!.branchId.toString()).toBe(t.branchA);
});
it("rejects a sale naming another tenant's product or branch", async () => {
const t = await makeTenant("Alpha");
const other = await makeTenant("Beta");
const foreignProduct = sale(t, t.cashierA, [{ productId: other.water20, quantity: 1 }]);
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
expect(await Transaction.countDocuments({})).toBe(0);
});
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
const t = await makeTenant();
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
await Product.updateOne({ _id: t.water5 }, { isActive: false });
const s = sale(t, t.cashierA, [
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
{ productId: t.water5, quantity: 1, unitPrice: 60 },
]);
const { body } = await post(t.cashierA, [s]);
expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 240, priceMismatch: true });
});
it("accepts a cash sale with no customer phone, and keeps one with a bad phone", async () => {
const t = await makeTenant();
const noPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]);
const badPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { customerPhone: "12" });
const { body } = await post(t.cashierA, [noPhone, badPhone]);
expect(body.results.map((r) => r.status)).toEqual(["saved", "saved"]);
});
it("still accepts the old single-product payload", async () => {
const t = await makeTenant();
const legacy = { clientUuid: uuid(), customerPhone: "0712345678", productId: t.water20, quantity: 2 };
const { body } = await post(t.cashierA, [legacy]);
expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 400 });
});
});
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
globalSetup: ["tests/globalSetup.ts"],
setupFiles: ["tests/setup.ts"],
// Route handlers share one cached mongoose connection per worker, so
// run files sequentially against the single in-memory server.
fileParallelism: false,
testTimeout: 30_000,
hookTimeout: 120_000,
},
});