Compare commits

..

1 Commits

Author SHA1 Message Date
denis_ireri f865837b27 Address the 11 October test report findings
The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

- F-01: a business on its free trial may run 2 active branches whatever
  its tier, so an owner can try transfers and per-branch stock. Only
  active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
  the Branches page. A branch can only be deactivated once it holds no
  stock and no active staff; its history stays in every report, and it
  leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
  Reactivate. Deactivating warns about stock still on hand, which stays
  on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
  everything being litres.
- CAT-12: product prices must be above zero and names are unique per
  business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
  the page labels which period the cards show. Report dates are Nairobi
  business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
  bought in the period is reported separately, with a cash surplus line,
  so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
  7-day link; the person sets their own password. Managers can issue a
  new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
  sale immediately, online or offline; it warns before selling more than
  the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
  into stock, so existing tenants (and the report's ZERRO fixtures) start
  from what they bought.

tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:21 +03:00
42 changed files with 1486 additions and 193 deletions
+5
View File
@@ -17,6 +17,11 @@ DELIVERY_MAX_DEVICES=2
# accounts with `npm run seed`; public signup refuses these addresses. # accounts with `npm run seed`; public signup refuses these addresses.
PLATFORM_ADMIN_EMAILS= PLATFORM_ADMIN_EMAILS=
# ---- Invite links ----
# Public base URL used in staff invite links. Optional: defaults to the
# address the request came in on.
APP_URL=
# ---- M-Pesa (optional, off for the pilot) ---- # ---- M-Pesa (optional, off for the pilot) ----
# M-Pesa STK Push is out of scope for the pilot. While this is false every # 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. # /api/mpesa route returns 404 and the Daraja variables below are ignored.
+38 -7
View File
@@ -76,7 +76,7 @@ the decisions taken along the way, and what is still open.
sales from the old version are sent on the next sync. sales from the old version are sent on the next sync.
## Testing ## Testing
- `npm test` runs 37 API tests against an in-memory MongoDB, covering role - `npm test` runs 50 API tests against an in-memory MongoDB, covering role
enforcement, tenant isolation for sales, stock and transfers, suspension, enforcement, tenant isolation for sales, stock and transfers, suspension,
the sync protocol (including a 120-sale backlog replayed twice), and the the sync protocol (including a 120-sale backlog replayed twice), and the
business day. business day.
@@ -84,9 +84,38 @@ the decisions taken along the way, and what is still open.
in, stop the server, reload the terminal, record two sales, restart the in, stop the server, reload the terminal, record two sales, restart the
server, confirm they sync once and deduct stock. server, confirm they sync once and deduct stock.
## Follow-ups from the 11 October 2026 test report
The initial test report (run on the build before these changes) raised
seven findings and a retest plan. All of them are addressed on
`fix/test-report-followups`:
| Finding | What changed |
|---|---|
| F-01 Trial allows one branch | A business on its free trial may run **2 active branches** whatever its tier (`TRIAL_MAX_BRANCHES` in `src/lib/tiers.ts`). After the trial the tier's limit applies to adding branches. |
| F-02 No current stock | Done in the previous change (stock ledger). Purchases recorded before the ledger existed are added by `npm run backfill:stock`. |
| F-03 Reversed report dates | Refused in the page and by the API (400). The page labels which period the cards show. Report dates are Nairobi business days. |
| F-04 No way back for deactivated products | Products page lists deactivated products under a toggle, with Reactivate. Deactivating warns about stock still on hand; that stock stays on the Stock page and can be transferred or counted out. |
| F-05 Eggs shown as litres | Products carry a unit (L, ml, kg, g, pcs, tray, pack), chosen when the product is created and shown everywhere. |
| F-06 No branch or staff removal | Staff: deactivate/reactivate (previous change). Branches: rename and deactivate/reactivate on the Branches page. A branch can only be deactivated once it has no stock and no active staff; its history stays in reports. |
| F-07 Purchases shown as cost of goods | Cost of goods sold is now units sold x the product's average buying price. Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
| STA-04 No invites | Staff and new branch managers can be invited: the owner or manager gets a one-time link (7 days) to send, and the person sets their own password. A new link can be made from Staff. |
| CAT-12 Zero price, duplicates | Prices must be above zero; product names are unique per business, ignoring case. |
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
| POS-02 / INV-05 Oversell, cancel | The till warns before selling more than the recorded stock and asks to confirm (it does not block, because the sale may already have happened). A "Clear sale" button empties the cart. |
`tests/retest-plan.test.ts` runs the report's completion and retest plan
(online sale, transfer, two offline sales replayed twice, a Branch B sale)
and checks every balance and total it predicts.
### Before re-running the test report's plan on ZERRO
1. Deploy, then run `npm run backfill:stock` once so the existing test
purchases count as stock. Run it **before** anyone records an opening
count, or those units would be counted twice.
2. ZERRO is trialling, so it can now add Branch B without a plan change.
3. Create the manager and cashier logins with invite links from Staff.
## Still open ## 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, - **Setting branch prices.** Branch price overrides are applied everywhere,
but there is no screen or API to set them yet. but there is no screen or API to set them yet.
- **Global email and phone uniqueness.** One person cannot work for two - **Global email and phone uniqueness.** One person cannot work for two
@@ -96,9 +125,11 @@ the decisions taken along the way, and what is still open.
route. route.
- **Concurrent transfers.** Two transfers submitted at the same moment - **Concurrent transfers.** Two transfers submitted at the same moment
could together send more than the branch holds. could together send more than the branch holds.
- **Cash-basis profit.** The profit report counts all purchases in the - **Cost of goods is an average.** It uses each product's average buying
period as cost of goods. price across the business, not first-in-first-out per branch. Units sold
with no purchase on record are counted and flagged, but have no cost.
- **M-Pesa, if turned on.** Only one product per payment. A prompt that - **M-Pesa, if turned on.** Only one product per payment. A prompt that
times out still needs a manual check before taking cash. times out still needs a manual check before taking cash.
- **Not modelled yet:** invites and email verification, empty-bottle and - **Not modelled yet:** email verification at signup, sending invites by
deposit tracking, home-screen icons for the PWA. email (links are shared by hand, e.g. WhatsApp), empty-bottle and deposit
tracking, home-screen icons for the PWA.
+2 -1
View File
@@ -10,7 +10,8 @@
"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", "test": "vitest run",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
+30
View File
@@ -0,0 +1,30 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
import mongoose from "mongoose";
import { connectDb } from "../src/lib/server/db";
import { backfillPurchaseMovements } from "../src/lib/server/stock";
/**
* One-off (safe to re-run): purchases recorded before the stock ledger
* existed did not add stock. This adds the missing PURCHASE movements so
* each branch's stock starts from what it actually bought.
*
* npm run backfill:stock
*
* Run it once after deploying the stock ledger, BEFORE branches record
* opening counts — a count recorded first already includes those units,
* and backfilling afterwards would add them a second time.
*/
async function main() {
await connectDb();
const added = await backfillPurchaseMovements();
console.log(`Added ${added} purchase stock movement(s).`);
await mongoose.disconnect();
}
main().catch((err) => {
console.error("backfill failed:", err.message);
process.exit(1);
});
+156 -27
View File
@@ -27,6 +27,12 @@ export default function BranchesPage() {
const [mgrEmail, setMgrEmail] = useState(""); const [mgrEmail, setMgrEmail] = useState("");
const [mgrPhone, setMgrPhone] = useState(""); const [mgrPhone, setMgrPhone] = useState("");
const [mgrPassword, setMgrPassword] = useState(""); const [mgrPassword, setMgrPassword] = useState("");
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
// Inline rename.
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editCity, setEditCity] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -52,7 +58,7 @@ export default function BranchesPage() {
}, []); }, []);
async function loadBranches() { async function loadBranches() {
const result = await apiFetch<{ branches: Branch[] }>("/api/branches"); const result = await apiFetch<{ branches: Branch[] }>("/api/branches?includeInactive=1");
setBranches(result.branches); setBranches(result.branches);
} }
@@ -71,28 +77,29 @@ export default function BranchesPage() {
}; };
if (withManager) { if (withManager) {
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim() || !mgrPassword) { if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim()) {
setError("Fill in the manager's name, email, phone and password — or switch off the manager login."); setError("Fill in the manager's name, email and phone — or switch off the manager login.");
return; return;
} }
if (mgrPassword.length < 8) { if (mgrPassword && mgrPassword.length < 8) {
setError("The manager password must be at least 8 characters."); setError("The manager password must be at least 8 characters, or leave it blank to send an invite link.");
return; return;
} }
body.manager = { body.manager = {
name: mgrName.trim(), name: mgrName.trim(),
email: mgrEmail.trim(), email: mgrEmail.trim(),
phoneNumber: mgrPhone.trim(), phoneNumber: mgrPhone.trim(),
password: mgrPassword, password: mgrPassword || undefined,
}; };
} }
setSaving(true); setSaving(true);
try { try {
await apiFetch("/api/branches", { const result = await apiFetch<{ inviteUrl?: string | null }>("/api/branches", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
setInviteLink(result.inviteUrl ? { name: mgrName.trim(), url: result.inviteUrl } : null);
if (withManager) { if (withManager) {
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success"); show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
} else { } else {
@@ -113,6 +120,46 @@ export default function BranchesPage() {
setSaving(false); setSaving(false);
} }
async function saveRename(id: string) {
if (!editName.trim() || !editCity.trim()) {
setError("Enter both a branch name and a city.");
return;
}
try {
await apiFetch("/api/branches/" + id, {
method: "PATCH",
body: JSON.stringify({ branchName: editName.trim(), locationCity: editCity.trim() }),
});
show("Branch updated", "success");
setEditingId(null);
await loadBranches();
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not update branch", "error");
}
}
async function toggleBranch(b: Branch) {
const deactivating = b.isActive !== false;
if (
deactivating &&
!window.confirm(
`Deactivate ${b.branchName}? Its sales and stock history stay in your reports, but it takes no new staff, stock or sales. You can reactivate it later.`
)
) {
return;
}
try {
await apiFetch("/api/branches/" + b._id, { method: "PATCH", body: JSON.stringify({ isActive: !deactivating }) });
show(`${b.branchName} ${deactivating ? "deactivated" : "reactivated"}`, "success");
await loadBranches();
} catch (err) {
// e.g. stock or staff still at the branch, or the plan's branch limit.
const message = err instanceof ApiError ? err.message : "Could not update branch";
setError(message);
show(message, "error");
}
}
if (!me) { if (!me) {
return ( return (
<main className="min-h-screen bg-sand-50 pb-12"> <main className="min-h-screen bg-sand-50 pb-12">
@@ -150,6 +197,33 @@ export default function BranchesPage() {
</div> </div>
)} )}
{inviteLink && (
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. You can make a new one from Staff.</p>
<input
readOnly
value={inviteLink.url}
onFocus={(e) => e.target.select()}
aria-label="Invite link"
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
/>
<div className="mt-2 flex flex-wrap gap-2">
<a
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
>
Share on WhatsApp
</a>
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
Done
</button>
</div>
</div>
)}
{loading ? ( {loading ? (
<SkeletonList rows={3} /> <SkeletonList rows={3} />
) : ( ) : (
@@ -175,22 +249,77 @@ export default function BranchesPage() {
/> />
) : ( ) : (
<ul className="divide-y divide-depth-100"> <ul className="divide-y divide-depth-100">
{branches.map((b) => ( {branches.map((b) => {
<li key={b._id} className="flex items-center justify-between px-4 py-3 animate-fade-up"> const active = b.isActive !== false;
<div> return (
<p className="font-medium text-depth-900">{b.branchName}</p> <li key={b._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<p className="text-xs text-depth-500">{b.locationCity}</p> {editingId === b._id ? (
</div> <div className="flex flex-1 flex-wrap items-center gap-2">
<span <input
className={ autoFocus
"rounded-full px-2.5 py-1 text-xs font-medium " + value={editName}
(b.managerId ? "bg-confirm-500/15 text-confirm-600" : "bg-cash-500/15 text-cash-600") onChange={(e) => setEditName(e.target.value)}
} aria-label="Branch name"
> className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
{b.managerId ? "Has manager" : "No manager yet"} />
</span> <input
</li> value={editCity}
))} onChange={(e) => setEditCity(e.target.value)}
aria-label="City"
className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
/>
<button onClick={() => saveRename(b._id)} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
Save
</button>
<button onClick={() => setEditingId(null)} className="text-sm text-depth-500 underline">
Cancel
</button>
</div>
) : (
<div className={active ? "" : "opacity-50"}>
<p className="font-medium text-depth-900">
{b.branchName}
{!active && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
</p>
<p className="text-xs text-depth-500">{b.locationCity}</p>
</div>
)}
{editingId !== b._id && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{active && (
<span
className={
"rounded-full px-2.5 py-1 text-xs font-medium " +
(b.managerId ? "bg-confirm-500/15 text-confirm-600" : "bg-cash-500/15 text-cash-600")
}
>
{b.managerId ? "Has manager" : "No manager yet"}
</span>
)}
<button
onClick={() => {
setEditingId(b._id);
setEditName(b.branchName);
setEditCity(b.locationCity);
}}
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
>
Rename
</button>
<button
onClick={() => toggleBranch(b)}
className={
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
(active ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
}
>
{active ? "Deactivate" : "Reactivate"}
</button>
</div>
)}
</li>
);
})}
</ul> </ul>
)} )}
</div> </div>
@@ -238,8 +367,8 @@ export default function BranchesPage() {
Create this branch&apos;s manager login now Create this branch&apos;s manager login now
</span> </span>
<span className="block text-xs text-depth-500"> <span className="block text-xs text-depth-500">
The manager signs in with this email &amp; password, then adds their own delivery Leave the password blank to get an invite link the manager uses to choose their own.
staff. You can leave this off and assign a manager later from Staff. They then add their own delivery staff. You can also assign a manager later from Staff.
</span> </span>
</span> </span>
</label> </label>
@@ -285,14 +414,14 @@ export default function BranchesPage() {
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password"> <label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
Temporary password Password (optional)
</label> </label>
<input <input
id="mgr-password" id="mgr-password"
type="text" type="text"
value={mgrPassword} value={mgrPassword}
onChange={(e) => setMgrPassword(e.target.value)} onChange={(e) => setMgrPassword(e.target.value)}
placeholder="At least 8 characters" placeholder="Blank = send an invite link"
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3" className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
/> />
</div> </div>
+1 -1
View File
@@ -146,7 +146,7 @@ export default function AdminDashboardPage() {
Stock Stock
</Link> </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 Products
</Link> </Link>
<Link href="/admin/staff" 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/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Staff Staff
+65 -23
View File
@@ -4,11 +4,11 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; 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 { AuthUser, Product } from "@/lib/types"; import { AuthUser, Product, StockLevel } from "@/lib/types";
import { PRODUCT_UNITS, UNIT_LABELS, formatSize, type ProductUnit } from "@/lib/units";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton"; import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState"; import EmptyState from "@/components/ui/EmptyState";
import ConfirmButton from "@/components/ui/ConfirmButton";
export default function ProductsPage() { export default function ProductsPage() {
const router = useRouter(); const router = useRouter();
@@ -21,7 +21,9 @@ export default function ProductsPage() {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [sizeLiters, setSizeLiters] = useState(""); const [sizeLiters, setSizeLiters] = useState("");
const [unit, setUnit] = useState<ProductUnit>("L");
const [unitPrice, setUnitPrice] = useState(""); const [unitPrice, setUnitPrice] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editPrice, setEditPrice] = useState(""); const [editPrice, setEditPrice] = useState("");
@@ -50,7 +52,7 @@ export default function ProductsPage() {
}, []); }, []);
async function loadProducts() { async function loadProducts() {
const result = await apiFetch<{ products: Product[] }>("/api/products"); const result = await apiFetch<{ products: Product[] }>("/api/products?includeInactive=1");
setProducts(result.products); setProducts(result.products);
} }
@@ -60,8 +62,8 @@ export default function ProductsPage() {
const size = parseFloat(sizeLiters); const size = parseFloat(sizeLiters);
const price = parseFloat(unitPrice); const price = parseFloat(unitPrice);
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price < 0) { if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price <= 0) {
setError("Enter a valid label, size, and price."); setError("Enter a label, a size above zero and a price above zero.");
return; return;
} }
@@ -69,7 +71,7 @@ export default function ProductsPage() {
try { try {
await apiFetch("/api/products", { await apiFetch("/api/products", {
method: "POST", method: "POST",
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unitPrice: price, sortOrder: products.length }), body: JSON.stringify({ label: label.trim(), sizeLiters: size, unit, unitPrice: price, sortOrder: products.length }),
}); });
show(label.trim() + " added to the catalog", "success"); show(label.trim() + " added to the catalog", "success");
setLabel(""); setLabel("");
@@ -87,8 +89,8 @@ 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); 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 price above zero.");
return; return;
} }
if (!Number.isInteger(threshold) || threshold < 0) { if (!Number.isInteger(threshold) || threshold < 0) {
@@ -113,6 +115,24 @@ export default function ProductsPage() {
} }
async function handleToggleActive(product: Product) { async function handleToggleActive(product: Product) {
if (product.isActive) {
// Tell the owner what happens to stock still on hand before it goes.
let held: StockLevel[] = [];
try {
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
held = res.levels.filter((l) => l.productId === product._id && l.quantity !== 0);
} catch {
// Offline or failed: still ask for confirmation below.
}
const stockNote =
held.length > 0
? `\n\nStill in stock: ${held.map((l) => `${l.branchName} ${l.quantity}`).join(", ")}. ` +
"It stays on the Stock page, where it can be transferred or counted out, but cashiers can no longer sell it."
: "";
if (!window.confirm(`Deactivate ${product.label}? It disappears from the till and new purchases. Past sales and purchases are kept.${stockNote}`)) {
return;
}
}
try { try {
await apiFetch("/api/products/" + product._id, { await apiFetch("/api/products/" + product._id, {
method: "PATCH", method: "PATCH",
@@ -145,7 +165,7 @@ export default function ProductsPage() {
<div className="mx-auto flex max-w-3xl items-center justify-between"> <div className="mx-auto flex max-w-3xl items-center justify-between">
<div> <div>
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p> <p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Jerrycan sizes &amp; pricing</h1> <h1 className="font-display text-xl font-bold">Products &amp; pricing</h1>
</div> </div>
<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"> <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 ← Dashboard
@@ -166,6 +186,17 @@ export default function ProductsPage() {
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."} {!canEdit && " Only the business owner can change products and prices."}
</p> </p>
{products.some((p) => !p.isActive) && (
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="h-4 w-4 rounded border-depth-300"
/>
Show deactivated products ({products.filter((p) => !p.isActive).length})
</label>
)}
</div> </div>
{products.length === 0 ? ( {products.length === 0 ? (
@@ -177,11 +208,11 @@ export default function ProductsPage() {
/> />
) : ( ) : (
<ul className="divide-y divide-depth-100"> <ul className="divide-y divide-depth-100">
{products.map((p) => ( {products.filter((p) => showInactive || p.isActive).map((p) => (
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up"> <li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div> <div>
<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">· {formatSize(p.sizeLiters, p.unit)}</span>
</p> </p>
{!canEdit ? ( {!canEdit ? (
<p className="mt-0.5 font-mono text-sm text-depth-600"> <p className="mt-0.5 font-mono text-sm text-depth-600">
@@ -235,13 +266,12 @@ export default function ProductsPage() {
{p.isActive ? "Active" : "Inactive"} {p.isActive ? "Active" : "Inactive"}
</span> </span>
{!canEdit ? null : p.isActive ? ( {!canEdit ? null : p.isActive ? (
<ConfirmButton <button
label="Deactivate" onClick={() => handleToggleActive(p)}
confirmLabel="Deactivate" className="rounded-lg border border-alert-500/40 px-3 py-1.5 text-sm font-medium text-alert-600"
variant="danger" >
onConfirm={() => handleToggleActive(p)} Deactivate
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-700" </button>
/>
) : ( ) : (
<button <button
onClick={() => handleToggleActive(p)} onClick={() => handleToggleActive(p)}
@@ -259,8 +289,8 @@ export default function ProductsPage() {
{canEdit && ( {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 product</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<div> <div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label"> <label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
Label Label
@@ -269,19 +299,31 @@ export default function ProductsPage() {
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size"> <label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
Size (litres) Size
</label> </label>
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" /> <input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div> </div>
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="unit">
Unit
</label>
<select id="unit" value={unit} onChange={(e) => setUnit(e.target.value as ProductUnit)} className="tap-target w-full rounded-xl border border-depth-200 px-3">
{PRODUCT_UNITS.map((u) => (
<option key={u} value={u}>
{UNIT_LABELS[u]} ({u})
</option>
))}
</select>
</div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price"> <label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
Price (KES) Price (KES)
</label> </label>
<input id="price" type="number" min="0" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" /> <input id="price" type="number" min="0.01" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div> </div>
</div> </div>
<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"> <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 ? "Adding…" : "Add size"} {saving ? "Adding…" : "Add product"}
</button> </button>
</form> </form>
)} )}
+68 -20
View File
@@ -15,16 +15,22 @@ interface ProfitReport {
cashTotal: number; cashTotal: number;
mpesaTotal: number; mpesaTotal: number;
salesCount: number; salesCount: number;
costOfGoods: number; unitsSold: number;
purchaseCount: number; costOfGoodsSold: number;
unitsWithoutCost: number;
grossProfit: number; grossProfit: number;
expenses: number; expenses: number;
expenseCount: number; expenseCount: number;
netProfit: number; netProfit: number;
stockPurchases: number;
purchaseCount: number;
cashSurplus: number;
} }
/** YYYY-MM-DD in the device's own calendar (the owner's business day). */
function isoDay(d: Date): string { function isoDay(d: Date): string {
return d.toISOString().slice(0, 10); const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} }
export default function ReportsPage() { export default function ReportsPage() {
@@ -35,6 +41,10 @@ export default function ReportsPage() {
const [report, setReport] = useState<ProfitReport | null>(null); const [report, setReport] = useState<ProfitReport | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [rangeError, setRangeError] = useState<string | null>(null);
// The period the cards currently show, so a failed or invalid run can
// never be mistaken for the new period.
const [shown, setShown] = useState<{ from: string; to: string } | null>(null);
const monthStart = new Date(); const monthStart = new Date();
monthStart.setDate(1); monthStart.setDate(1);
@@ -75,22 +85,27 @@ export default function ReportsPage() {
}, []); }, []);
async function runReport(forcedBranch?: string) { async function runReport(forcedBranch?: string) {
if (!from || !to) {
setRangeError("Choose both a start and an end date.");
return;
}
if (from > to) {
setRangeError("The start date must be on or before the end date.");
return;
}
setRangeError(null);
setFetching(true); setFetching(true);
try { try {
const b = forcedBranch ?? branchId; const b = forcedBranch ?? branchId;
const params = new URLSearchParams(); const params = new URLSearchParams({ fromDate: from, toDate: to });
if (b && b !== "ALL") params.set("branchId", b); if (b && b !== "ALL") params.set("branchId", b);
if (from) params.set("from", new Date(from).toISOString());
// include the whole "to" day
if (to) {
const end = new Date(to);
end.setHours(23, 59, 59, 999);
params.set("to", end.toISOString());
}
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString()); const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
setReport(result); setReport(result);
setShown({ from, to });
} catch (err) { } catch (err) {
show(err instanceof ApiError ? err.message : "Could not load report", "error"); const message = err instanceof ApiError ? err.message : "Could not load report";
setRangeError(message);
show(message, "error");
} }
setFetching(false); setFetching(false);
} }
@@ -99,23 +114,27 @@ export default function ReportsPage() {
if (!report) return; if (!report) return;
const rows: [string, string | number][] = [ const rows: [string, string | number][] = [
["Metric", "Value (KES)"], ["Metric", "Value (KES)"],
["Range from", report.range.from.slice(0, 10)], ["From", shown?.from ?? ""],
["Range to", report.range.to.slice(0, 10)], ["To", shown?.to ?? ""],
["Revenue", report.revenue], ["Revenue", report.revenue],
[" Cash", report.cashTotal], [" Cash", report.cashTotal],
[" M-Pesa", report.mpesaTotal], [" M-Pesa", report.mpesaTotal],
["Sales count", report.salesCount], ["Sales count", report.salesCount],
["Cost of goods", report.costOfGoods], ["Units sold", report.unitsSold],
["Cost of goods sold (average purchase cost)", report.costOfGoodsSold],
["Units sold with no purchase cost on record", report.unitsWithoutCost],
["Gross profit", report.grossProfit], ["Gross profit", report.grossProfit],
["Expenses", report.expenses], ["Expenses", report.expenses],
["Estimated net profit", report.netProfit], ["Estimated net profit", report.netProfit],
["Stock purchased in period (cash spent)", report.stockPurchases],
["Cash surplus (sales - stock purchased - expenses)", report.cashSurplus],
]; ];
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n"); const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `vessel-profit-${report.range.from.slice(0, 10)}_${report.range.to.slice(0, 10)}.csv`; a.download = `vessel-profit-${shown?.from}_${shown?.to}.csv`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@@ -179,6 +198,7 @@ export default function ReportsPage() {
<input <input
type="date" type="date"
value={from} value={from}
max={to || undefined}
onChange={(e) => setFrom(e.target.value)} onChange={(e) => setFrom(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 px-3" className="tap-target w-full rounded-xl border border-depth-200 px-3"
/> />
@@ -188,6 +208,7 @@ export default function ReportsPage() {
<input <input
type="date" type="date"
value={to} value={to}
min={from || undefined}
onChange={(e) => setTo(e.target.value)} onChange={(e) => setTo(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 px-3" className="tap-target w-full rounded-xl border border-depth-200 px-3"
/> />
@@ -202,8 +223,16 @@ export default function ReportsPage() {
</button> </button>
</div> </div>
</div> </div>
{rangeError && <p className="mt-3 text-sm text-alert-600">{rangeError}</p>}
</div> </div>
{report && shown && (
<p className="text-sm text-depth-600">
Showing {shown.from === shown.to ? shown.from : `${shown.from} to ${shown.to}`}
{rangeError ? " (the dates above were not run)" : ""}
</p>
)}
{report && ( {report && (
<> <>
{/* Profit cards */} {/* Profit cards */}
@@ -216,14 +245,17 @@ export default function ReportsPage() {
</p> </p>
</div> </div>
<div className="rounded-xl2 bg-white p-5 shadow-soft"> <div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods</p> <p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoods)}</p> <p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoodsSold)}</p>
<p className="mt-1 text-xs text-depth-500">{report.purchaseCount} purchases in range</p> <p className="mt-1 text-xs text-depth-500">
{report.unitsSold} units sold x average buying price
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
</p>
</div> </div>
<div className="rounded-xl2 bg-white p-5 shadow-soft"> <div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p> <p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p> <p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods</p> <p className="mt-1 text-xs text-depth-500">Revenue − cost of goods sold</p>
</div> </div>
<div className="rounded-xl2 bg-white p-5 shadow-soft"> <div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p> <p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
@@ -244,6 +276,22 @@ export default function ReportsPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Stock purchased</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
<p className="mt-1 text-xs text-depth-500">
Cash spent on stock in this period ({report.purchaseCount} purchases). Unsold stock is not a cost
until it is sold.
</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Cash surplus</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</p>
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
</div>
</div>
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3"> <div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
<p className="text-xs text-depth-500"> <p className="text-xs text-depth-500">
Management / profit overview — a decision aid, not full accounting software. Management / profit overview — a decision aid, not full accounting software.
+92 -8
View File
@@ -17,6 +17,7 @@ interface StaffMember {
phoneNumber: string; phoneNumber: string;
email: string; email: string;
isActive: boolean; isActive: boolean;
invitePending?: boolean;
} }
const roleBadgeStyles: Record<UserRole, string> = { const roleBadgeStyles: Record<UserRole, string> = {
@@ -42,6 +43,10 @@ export default function StaffPage() {
const [phoneNumber, setPhoneNumber] = useState(""); const [phoneNumber, setPhoneNumber] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
// Inviting is the default: the person sets their own password, so the
// owner never has to know or pass on anyone's password.
const [setPasswordNow, setSetPasswordNow] = useState(false);
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
const isSuperAdmin = me?.role === "SUPER_ADMIN"; const isSuperAdmin = me?.role === "SUPER_ADMIN";
@@ -75,14 +80,14 @@ export default function StaffPage() {
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required."; if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to."; if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
if (!email.trim()) return "Email is required — every account signs in with email and password."; if (!email.trim()) return "Email is required — every account signs in with email and password.";
if (password.length < 8) return "Password must be at least 8 characters."; if (setPasswordNow && password.length < 8) return "Password must be at least 8 characters.";
return null; return null;
} }
async function submitCreate() { async function submitCreate() {
setSaving(true); setSaving(true);
try { try {
await apiFetch("/api/users", { const result = await apiFetch<{ inviteUrl: string | null }>("/api/users", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
name: name.trim(), name: name.trim(),
@@ -90,10 +95,11 @@ export default function StaffPage() {
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined, branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
phoneNumber, phoneNumber,
email, email,
password, password: setPasswordNow ? password : undefined,
}), }),
}); });
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success"); show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl } : null);
setName(""); setName("");
setBranchId(""); setBranchId("");
setPhoneNumber(""); setPhoneNumber("");
@@ -115,6 +121,26 @@ export default function StaffPage() {
return s.role === "DELIVERY" && s.branchId === me.branchId; return s.role === "DELIVERY" && s.branchId === me.branchId;
} }
async function newInviteLink(s: StaffMember) {
try {
const result = await apiFetch<{ inviteUrl: string }>("/api/users/" + s._id + "/invite", { method: "POST" });
setInviteLink({ name: s.name, url: result.inviteUrl });
show("New invite link created. The old link no longer works.", "success");
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not create an invite link", "error");
}
}
async function copyInvite() {
if (!inviteLink) return;
try {
await navigator.clipboard.writeText(inviteLink.url);
show("Invite link copied", "success");
} catch {
show("Could not copy - select the link and copy it manually", "error");
}
}
async function toggleActive(s: StaffMember) { async function toggleActive(s: StaffMember) {
const next = !s.isActive; 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.`)) { if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
@@ -191,6 +217,36 @@ export default function StaffPage() {
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div> <div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
)} )}
{inviteLink && (
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. Anyone with the link can claim the account, so send it only to them.</p>
<input
readOnly
value={inviteLink.url}
onFocus={(e) => e.target.select()}
aria-label="Invite link"
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
/>
<div className="mt-2 flex flex-wrap gap-2">
<button onClick={copyInvite} className="rounded-lg bg-flow-500 px-3 py-1.5 text-xs font-semibold text-depth-950">
Copy link
</button>
<a
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
>
Share on WhatsApp
</a>
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
Done
</button>
</div>
</div>
)}
<div className="rounded-xl2 bg-white shadow-soft"> <div className="rounded-xl2 bg-white shadow-soft">
<div className="border-b border-depth-100 px-4 py-3"> <div className="border-b border-depth-100 px-4 py-3">
<h2 className="font-display font-semibold text-depth-900">Team</h2> <h2 className="font-display font-semibold text-depth-900">Team</h2>
@@ -211,6 +267,9 @@ export default function StaffPage() {
<p className="font-medium text-depth-900"> <p className="font-medium text-depth-900">
{s.name} {s.name}
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>} {!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
{s.isActive && s.invitePending && (
<span className="ml-2 text-xs font-normal text-cash-700">Invite pending</span>
)}
</p> </p>
<p className="text-xs text-depth-500"> <p className="text-xs text-depth-500">
{s.email} {s.email}
@@ -221,6 +280,14 @@ export default function StaffPage() {
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}> <span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
{s.role.replace("_", " ")} {s.role.replace("_", " ")}
</span> </span>
{canManage(s) && s.isActive && s.invitePending && (
<button
onClick={() => newInviteLink(s)}
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
>
New link
</button>
)}
{canManage(s) && ( {canManage(s) && (
<button <button
onClick={() => toggleActive(s)} onClick={() => toggleActive(s)}
@@ -289,12 +356,29 @@ export default function StaffPage() {
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label> <label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div> </div>
<div> {setPasswordNow && (
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label> <div>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" /> <label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
</div> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
</div>
)}
</div> </div>
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-depth-700">
<input
type="checkbox"
checked={setPasswordNow}
onChange={(e) => setSetPasswordNow(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-depth-300"
/>
<span>
Set their password myself instead of sending an invite link
<span className="block text-xs text-depth-500">
By default you get a one-time link to send them, and they choose their own password.
</span>
</span>
</label>
{confirmSuperAdmin ? ( {confirmSuperAdmin ? (
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3"> <div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
<p className="mb-2 text-sm text-alert-700"> <p className="mb-2 text-sm text-alert-700">
@@ -324,7 +408,7 @@ export default function StaffPage() {
disabled={saving} 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" 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 ? "Adding…" : "Add staff member"} {saving ? "Adding…" : setPasswordNow ? "Add staff member" : "Add and create invite link"}
</button> </button>
)} )}
</form> </form>
+8 -2
View File
@@ -73,7 +73,9 @@ export default function StockPage() {
} }
const [br, pr] = await Promise.all([ const [br, pr] = await Promise.all([
apiFetch<{ branches: Branch[] }>("/api/branches"), apiFetch<{ branches: Branch[] }>("/api/branches"),
apiFetch<{ products: Product[] }>("/api/products"), // Deactivated products too: their remaining stock can still be
// transferred or counted out.
apiFetch<{ products: Product[] }>("/api/products?includeInactive=1"),
]); ]);
setBranches(br.branches); setBranches(br.branches);
setProducts(pr.products); setProducts(pr.products);
@@ -252,7 +254,10 @@ export default function StockPage() {
<ul className="divide-y divide-depth-100"> <ul className="divide-y divide-depth-100">
{group.rows.map((l) => ( {group.rows.map((l) => (
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5"> <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="min-w-0 truncate text-sm text-depth-800">
{l.productLabel}
{!l.productActive && <span className="ml-1 text-xs text-depth-500">(deactivated)</span>}
</span>
<span className="flex shrink-0 items-center gap-2"> <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")}> <span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
{l.quantity} {l.quantity}
@@ -347,6 +352,7 @@ export default function StockPage() {
{products.map((p) => ( {products.map((p) => (
<option key={p._id} value={p._id}> <option key={p._id} value={p._id}>
{p.label} {p.label}
{p.isActive ? "" : " (deactivated)"}
</option> </option>
))} ))}
</select> </select>
+2 -2
View File
@@ -6,7 +6,7 @@ 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 { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -47,7 +47,7 @@ export async function GET(req: NextRequest) {
status, status,
trialEndsAt: business?.trialEndsAt ?? null, trialEndsAt: business?.trialEndsAt ?? null,
currentPeriodEnd: business?.currentPeriodEnd ?? null, currentPeriodEnd: business?.currentPeriodEnd ?? null,
maxBranches: tierDef.maxBranches, maxBranches: effectiveMaxBranches(tier, status),
maxUsers: tierDef.maxUsers, maxUsers: tierDef.maxUsers,
}, },
features: { mpesa: isMpesaEnabled() }, features: { mpesa: isMpesaEnabled() },
+1 -1
View File
@@ -29,7 +29,7 @@ const DEFAULT_CATALOG = [
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 }, { label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 }, { label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 }, { label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 }, { label: "Tray of Eggs (30)", sizeLiters: 1, unit: "tray", category: "Eggs", unitPrice: 400, sortOrder: 4 },
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 }, { label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 }, { label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
]; ];
+44 -4
View File
@@ -3,7 +3,10 @@ import { z } from "zod";
import { isValidObjectId } from "mongoose"; import { isValidObjectId } from "mongoose";
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 { authorize } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
import { branchLimitError } from "@/lib/server/subscription";
import { getStockLevels } from "@/lib/server/stock";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,10 +14,21 @@ const updateSchema = z
.object({ .object({
branchName: z.string().trim().min(1).max(120).optional(), branchName: z.string().trim().min(1).max(120).optional(),
locationCity: z.string().trim().min(1).max(120).optional(), locationCity: z.string().trim().min(1).max(120).optional(),
isActive: z.boolean().optional(),
}) })
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" }); .refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
// PATCH /api/branches/:id — owner renames a branch or changes its city. /**
* PATCH /api/branches/:id — owner renames a branch, or deactivates /
* reactivates it. This is how an owner "removes" a branch: nothing is
* deleted, so its sales, purchases and stock history stay in every report.
*
* A branch can only be deactivated once it is empty, so nothing is left
* stranded in it:
* - no stock on hand (transfer it out or count it to zero first), and
* - no active staff (move or deactivate them first).
* Reactivating counts against the plan's branch limit again.
*/
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 gate = authorize(req, ["SUPER_ADMIN"]); const gate = authorize(req, ["SUPER_ADMIN"]);
@@ -28,10 +42,36 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
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 { isActive, ...details } = parsed.data;
const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, { const branch = await Branch.findOne({ _id: params.id, businessId: auth.businessId });
new: true,
}).lean();
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 }); if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
const wasActive = branch.isActive !== false;
if (isActive === false && wasActive) {
const stock = (await getStockLevels(auth.businessId, branch._id)).filter((l) => l.quantity !== 0);
if (stock.length > 0) {
const list = stock.map((l) => `${l.quantity} x ${l.productLabel}`).join(", ");
return NextResponse.json(
{ error: `Move or count out this branch's stock first (${list}).`, stock },
{ status: 409 }
);
}
const staff = await User.countDocuments({ businessId: auth.businessId, branchId: branch._id, isActive: true });
if (staff > 0) {
return NextResponse.json(
{ error: `${staff} active staff member(s) still work at this branch. Move or deactivate them first.` },
{ status: 409 }
);
}
}
if (isActive === true && !wasActive) {
const limitError = await branchLimitError(auth.businessId);
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 });
}
Object.assign(branch, details);
if (isActive !== undefined) branch.isActive = isActive;
await branch.save();
return NextResponse.json({ branch }); return NextResponse.json({ branch });
} }
+15 -7
View File
@@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User"; import { User } from "@/lib/server/models/User";
import { authorize, getAuth } 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";
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,7 +18,11 @@ export async function GET(req: NextRequest) {
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
if (auth.role === "SUPER_ADMIN") { if (auth.role === "SUPER_ADMIN") {
const branches = await Branch.find({ businessId: auth.businessId }).sort({ branchName: 1 }).lean(); // Deactivated branches are only listed on request (the Branches page),
// so every picker elsewhere offers active branches only.
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, ...ACTIVE_BRANCH };
const branches = await Branch.find(filter).sort({ branchName: 1 }).lean();
return NextResponse.json({ branches }); return NextResponse.json({ branches });
} }
if (!auth.branchId) return NextResponse.json({ branches: [] }); if (!auth.branchId) return NextResponse.json({ branches: [] });
@@ -33,7 +38,8 @@ const createSchema = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
email: z.string().email(), email: z.string().email(),
phoneNumber: z.string().min(9).max(15), phoneNumber: z.string().min(9).max(15),
password: z.string().min(8, "Password must be at least 8 characters"), // Omit to invite: the response carries a one-time link for the manager.
password: z.string().min(8, "Password must be at least 8 characters").optional(),
}) })
.optional(), .optional(),
}); });
@@ -53,7 +59,7 @@ export async function POST(req: NextRequest) {
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);
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 }); if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
let managerPhone: string | null = null; let managerPhone: string | null = null;
@@ -80,8 +86,8 @@ export async function POST(req: NextRequest) {
if (manager && managerPhone) { if (manager && managerPhone) {
try { try {
const passwordHash = await bcrypt.hash(manager.password, 12); const passwordHash = manager.password ? await bcrypt.hash(manager.password, 12) : await unusablePasswordHash();
const managerUser = await User.create({ const managerUser = new User({
businessId, businessId,
name: manager.name, name: manager.name,
role: "BRANCH_MANAGER", role: "BRANCH_MANAGER",
@@ -90,10 +96,12 @@ export async function POST(req: NextRequest) {
email: manager.email.toLowerCase(), email: manager.email.toLowerCase(),
passwordHash, passwordHash,
}); });
const inviteUrl = manager.password ? null : issueInvite(managerUser, req);
await managerUser.save();
branch.managerId = managerUser._id; branch.managerId = managerUser._id;
await branch.save(); await branch.save();
return NextResponse.json( return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } }, { branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email }, inviteUrl },
{ status: 201 } { status: 201 }
); );
} catch (err: any) { } catch (err: any) {
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Business } from "@/lib/server/models/Business";
import { hashInviteToken } from "@/lib/server/invites";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function findInvitee(token: string) {
if (!token || token.length > 100) return null;
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
return user;
}
const INVALID = { error: "This invite link is invalid or has expired. Ask your manager for a new one." };
// GET /api/invites/:token — who the invite is for, so the page can greet them.
export async function GET(_req: NextRequest, { params }: { params: { token: string } }) {
await connectDb();
const user = await findInvitee(params.token);
if (!user) return NextResponse.json(INVALID, { status: 404 });
const business = await Business.findById(user.businessId).select("name").lean();
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null });
}
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
// POST /api/invites/:token — set the password; the link then stops working.
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
await connectDb();
const parsed = acceptSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const user = await findInvitee(params.token);
if (!user) return NextResponse.json(INVALID, { status: 404 });
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
user.inviteTokenHash = null;
user.inviteExpiresAt = null;
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await user.save();
return NextResponse.json({ ok: true, email: user.email });
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { Business } from "@/lib/server/models/Business";
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 { requirePlatformAdmin } from "@/lib/server/platform"; import { requirePlatformAdmin } from "@/lib/server/platform";
import { TIERS, type TierId } from "@/lib/tiers"; import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,6 +28,7 @@ export async function GET(req: NextRequest) {
{ $group: { _id: "$businessId", count: { $sum: 1 } } }, { $group: { _id: "$businessId", count: { $sum: 1 } } },
]), ]),
Branch.aggregate<{ _id: unknown; count: number }>([ Branch.aggregate<{ _id: unknown; count: number }>([
{ $match: { isActive: { $ne: false } } },
{ $group: { _id: "$businessId", count: { $sum: 1 } } }, { $group: { _id: "$businessId", count: { $sum: 1 } } },
]), ]),
]); ]);
@@ -58,7 +59,7 @@ export async function GET(req: NextRequest) {
userCount: userMap.get(String(b._id)) ?? 0, userCount: userMap.get(String(b._id)) ?? 0,
branchCount: branchMap.get(String(b._id)) ?? 0, branchCount: branchMap.get(String(b._id)) ?? 0,
maxUsers: TIERS[tier].maxUsers, maxUsers: TIERS[tier].maxUsers,
maxBranches: TIERS[tier].maxBranches, maxBranches: effectiveMaxBranches(tier, (b.subscriptionStatus as SubscriptionStatus) ?? "TRIALING"),
owner: owner owner: owner
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber } ? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
: null, : null,
+9 -1
View File
@@ -4,13 +4,16 @@ import { connectDb } from "@/lib/server/db";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { authorize } from "@/lib/server/auth"; import { authorize } from "@/lib/server/auth";
import { isValidObjectId } from "mongoose"; import { isValidObjectId } from "mongoose";
import { PRODUCT_UNITS } from "@/lib/units";
import { labelTakenError } from "@/lib/server/products";
export const runtime = "nodejs"; export const runtime = "nodejs";
const updateSchema = z.object({ const updateSchema = z.object({
label: z.string().trim().min(1).max(60).optional(), label: z.string().trim().min(1).max(60).optional(),
sizeLiters: z.number().positive().max(1000).optional(), sizeLiters: z.number().positive().max(1000).optional(),
unitPrice: z.number().nonnegative().optional(), unit: z.enum(PRODUCT_UNITS).optional(),
unitPrice: z.number().positive("Price must be more than zero").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(), lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
@@ -33,6 +36,11 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
return NextResponse.json({ error: "No fields to update" }, { status: 400 }); return NextResponse.json({ error: "No fields to update" }, { status: 400 });
} }
if (parsed.data.label) {
const taken = await labelTakenError(auth.businessId, parsed.data.label, params.id);
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
}
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,
+13 -5
View File
@@ -2,12 +2,15 @@ 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, BranchPricing } from "@/lib/server/models/Product"; import { Product, BranchPricing } from "@/lib/server/models/Product";
import { authorize } from "@/lib/server/auth"; import { authorize, MANAGERS } from "@/lib/server/auth";
import { PRODUCT_UNITS } from "@/lib/units";
import { labelTakenError } from "@/lib/server/products";
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 // GET /api/products — the tenant's active catalogue (?includeInactive=1
// adds deactivated products, for managers and owners). For branch-scoped
// staff the price returned is the price THEIR branch charges (the branch // staff the price returned is the price THEIR branch charges (the branch
// override when one exists), so the terminal shows exactly what the // override when one exists), so the terminal shows exactly what the
// server will record. // server will record.
@@ -17,7 +20,10 @@ export async function GET(req: NextRequest) {
if (!gate.ok) return gate.response; if (!gate.ok) return gate.response;
const auth = gate.auth; const auth = gate.auth;
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean(); const includeInactive =
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
if (auth.branchId) { if (auth.branchId) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean(); const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
@@ -31,9 +37,9 @@ export async function GET(req: NextRequest) {
const createSchema = z.object({ const createSchema = z.object({
label: z.string().trim().min(1).max(60), label: z.string().trim().min(1).max(60),
sizeLiters: z.number().positive().max(1000), sizeLiters: z.number().positive().max(1000),
unit: z.string().trim().min(1).max(12).optional(), unit: z.enum(PRODUCT_UNITS).optional(),
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().positive("Price must be more than zero"),
sortOrder: z.number().int().optional(), sortOrder: z.number().int().optional(),
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(), lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
}); });
@@ -51,6 +57,8 @@ 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 });
} }
const taken = await labelTakenError(auth.businessId, parsed.data.label);
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
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 });
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { connectDb } from "@/lib/server/db"; 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 { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { objectId } from "@/lib/server/validation"; import { objectId } from "@/lib/server/validation";
import { recordMovement } from "@/lib/server/stock"; import { recordMovement } from "@/lib/server/stock";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
@@ -91,7 +91,7 @@ export async function POST(req: NextRequest) {
} }
// The target branch MUST belong to this tenant. // The target branch MUST belong to this tenant.
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean(); const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
if (!branchDoc) { if (!branchDoc) {
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 }); return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
} }
+103 -44
View File
@@ -1,21 +1,32 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose"; import mongoose, { Types } 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 { 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 { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
import { startOfMonthNairobi } from "@/lib/server/businessDay"; import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Management / profit overview — NOT full accounting. /**
// Revenue = sum of SUCCESS sales in range * GET /api/reports/profit?branchId=&fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD
// Cost of Goods= sum of purchase cost in range (stock bought in) *
// Gross Profit = Revenue - Cost of Goods * Management overview, NOT accounting (full accounting is out of the PRD's scope).
// Expenses = sum of operating expenses in range * Revenue SUCCESS sales in the period
// Net Profit = Gross Profit - Expenses * Cost of goods sold units sold x that product's average purchase cost
* (all purchases up to the end of the period)
* Gross profit revenue - cost of goods sold
* Expenses operating expenses in the period
* Net profit gross profit - expenses
* Shown separately, because buying stock is not the same as using it:
* Stock purchased cash spent on stock in the period
* Cash surplus revenue - stock purchased - expenses
*
* Dates are Nairobi business days, inclusive. `from`/`to` (ISO instants)
* are still accepted for older clients.
*/
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
await connectDb(); await connectDb();
// Revenue, costs and profit are owner/manager information, never a cashier's. // Revenue, costs and profit are owner/manager information, never a cashier's.
@@ -28,63 +39,111 @@ export async function GET(req: NextRequest) {
if (!scope.ok) return scope.response; if (!scope.ok) return scope.response;
const branchId = scope.branchId; const branchId = scope.branchId;
// Default range: start of the current business month (EAT) -> now. const range = parseRange(url.searchParams);
const now = new Date(); if ("error" in range) return badRequest(range.error);
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now); const { from, to } = range;
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 businessObjId = new mongoose.Types.ObjectId(auth.businessId); const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
const dateMatch = { createdAt: { $gte: from, $lte: to } }; const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" }; const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([
if (branchObjId) salesMatch.branchId = branchObjId; Transaction.aggregate<{
_id: Types.ObjectId;
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId }; revenue: number;
if (branchObjId) costMatch.branchId = branchObjId; cashTotal: number;
mpesaTotal: number;
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([ units: number;
Transaction.aggregate([ lines: number;
{ $match: salesMatch }, }>([
{ $match: { ...inPeriod, paymentStatus: "SUCCESS" } },
{ {
$group: { $group: {
_id: null, _id: "$productId",
revenue: { $sum: "$amountTotal" }, revenue: { $sum: "$amountTotal" },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } }, cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } }, mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
salesCount: { $sum: 1 }, units: { $sum: "$quantity" },
lines: { $sum: 1 },
}, },
}, },
]), ]),
Purchase.aggregate([ // Average buying price per product across the business (suppliers
{ $match: costMatch }, // charge every branch alike), using everything bought up to period end.
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } }, Purchase.aggregate<{ _id: Types.ObjectId; cost: number; qty: number }>([
{ $match: { businessId: businessObjId, productId: { $ne: null }, createdAt: { $lt: to } } },
{ $group: { _id: "$productId", cost: { $sum: "$totalCost" }, qty: { $sum: "$quantity" } } },
]), ]),
Expense.aggregate([ Purchase.aggregate<{ total: number; count: number }>([
{ $match: costMatch }, { $match: inPeriod },
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } }, { $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
]),
Expense.aggregate<{ total: number; count: number }>([
{ $match: inPeriod },
{ $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } },
]), ]),
]); ]);
const revenue = salesAgg[0]?.revenue ?? 0; const avgCost = new Map(avgCosts.filter((c) => c.qty > 0).map((c) => [c._id.toString(), c.cost / c.qty]));
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0; let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0, unitsWithoutCost = 0, costOfGoodsSold = 0;
const expenses = expenseAgg[0]?.expenses ?? 0; for (const s of salesByProduct) {
const grossProfit = revenue - costOfGoods; revenue += s.revenue;
const netProfit = grossProfit - expenses; cashTotal += s.cashTotal;
mpesaTotal += s.mpesaTotal;
salesCount += s.lines;
unitsSold += s.units;
const unitCost = avgCost.get(s._id.toString());
if (unitCost === undefined) unitsWithoutCost += s.units;
else costOfGoodsSold += s.units * unitCost;
}
costOfGoodsSold = Math.round(costOfGoodsSold * 100) / 100;
const stockPurchases = purchaseAgg[0]?.total ?? 0;
const expenses = expenseAgg[0]?.total ?? 0;
const grossProfit = Math.round((revenue - costOfGoodsSold) * 100) / 100;
return NextResponse.json({ return NextResponse.json({
scope: branchId ? "branch" : "global", scope: branchId ? "branch" : "global",
range: { from: from.toISOString(), to: to.toISOString() }, range: { from: from.toISOString(), to: to.toISOString() },
revenue, revenue,
cashTotal: salesAgg[0]?.cashTotal ?? 0, cashTotal,
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0, mpesaTotal,
salesCount: salesAgg[0]?.salesCount ?? 0, salesCount,
costOfGoods, unitsSold,
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0, costOfGoodsSold,
// Units sold of products never bought through Purchases: their cost
// is unknown, so gross profit overstates by their cost.
unitsWithoutCost,
grossProfit, grossProfit,
expenses, expenses,
expenseCount: expenseAgg[0]?.expenseCount ?? 0, expenseCount: expenseAgg[0]?.count ?? 0,
netProfit, netProfit: Math.round((grossProfit - expenses) * 100) / 100,
stockPurchases,
purchaseCount: purchaseAgg[0]?.count ?? 0,
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
}); });
} }
/** The half-open UTC range [from, to) for the request, or a validation error. */
function parseRange(params: URLSearchParams): { from: Date; to: Date } | { error: string } {
const fromDate = params.get("fromDate");
const toDate = params.get("toDate");
let from: Date;
let to: Date;
if (fromDate || toDate) {
if ((fromDate && !isBusinessDate(fromDate)) || (toDate && !isBusinessDate(toDate))) {
return { error: "Dates must be YYYY-MM-DD" };
}
from = fromDate ? dayRangeNairobi(fromDate).start : startOfMonthNairobi();
to = toDate ? dayRangeNairobi(toDate).end : new Date();
} else {
from = params.get("from") ? new Date(params.get("from")!) : startOfMonthNairobi();
to = params.get("to") ? new Date(params.get("to")!) : new Date();
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return { error: "Invalid from/to date" };
}
// A reversed period must not look like a period with no activity.
if (from >= to) return { error: "The start date must be on or before the end date." };
return { from, to };
}
+2 -2
View File
@@ -1,7 +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 { Branch } from "@/lib/server/models/Branch"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer"; import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
@@ -97,7 +97,7 @@ export async function POST(req: NextRequest) {
} }
const [branchCount, product] = await Promise.all([ const [branchCount, product] = await Promise.all([
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId }), Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId, ...ACTIVE_BRANCH }),
Product.exists({ _id: data.productId, 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 (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { issueInvite } from "@/lib/server/invites";
export const runtime = "nodejs";
/**
* POST /api/users/:id/invite — a new invite link for someone who has not
* accepted theirs yet (lost or expired). The previous link stops working.
* Owners: anyone in the business. Managers: their own branch's cashiers.
*/
export async function POST(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 user = await User.findOne({ _id: params.id, businessId: auth.businessId });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
return NextResponse.json({ error: "Branch managers may only invite delivery staff in their own branch" }, { status: 403 });
}
if (!user.inviteExpiresAt) {
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 });
}
const inviteUrl = issueInvite(user, req);
await user.save();
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt });
}
+3 -2
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { isValidObjectId } from "mongoose"; import { isValidObjectId } from "mongoose";
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 { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { UserSession } from "@/lib/server/models/UserSession"; import { UserSession } from "@/lib/server/models/UserSession";
import { authorize, MANAGERS } from "@/lib/server/auth"; import { authorize, MANAGERS } from "@/lib/server/auth";
@@ -67,7 +67,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
if (target.role === "SUPER_ADMIN") { if (target.role === "SUPER_ADMIN") {
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 }); 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(); const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).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 });
target.branchId = branch._id; target.branchId = branch._id;
} }
@@ -82,5 +82,6 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
const obj = target.toObject() as unknown as Record<string, unknown>; const obj = target.toObject() as unknown as Record<string, unknown>;
delete obj.passwordHash; delete obj.passwordHash;
delete obj.inviteTokenHash;
return NextResponse.json({ user: obj }); return NextResponse.json({ user: obj });
} }
+17 -8
View File
@@ -3,11 +3,12 @@ import bcrypt from "bcryptjs";
import { z } from "zod"; 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 { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation"; 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";
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,8 +23,11 @@ export async function GET(req: NextRequest) {
if (!scope.ok) return scope.response; if (!scope.ok) return scope.response;
const filter: Record<string, unknown> = { businessId: auth.businessId }; const filter: Record<string, unknown> = { businessId: auth.businessId };
if (scope.branchId) filter.branchId = scope.branchId; 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 -inviteTokenHash").sort({ name: 1 }).lean();
return NextResponse.json({ users }); // invitePending: the person has not set their password yet.
return NextResponse.json({
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
});
} }
const createSchema = z.object({ const createSchema = z.object({
@@ -32,7 +36,9 @@ const createSchema = z.object({
branchId: objectId.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), // Omit the password to invite instead: the response carries a one-time
// link the person uses to set their own password.
password: z.string().min(8).optional(),
}); });
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -63,7 +69,7 @@ export async function POST(req: NextRequest) {
} }
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, ...ACTIVE_BRANCH }).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 });
} }
@@ -81,10 +87,10 @@ export async function POST(req: NextRequest) {
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 = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
try { try {
const user = await User.create({ const user = new User({
businessId: auth.businessId, businessId: auth.businessId,
name: data.name, name: data.name,
role: data.role, role: data.role,
@@ -93,6 +99,8 @@ export async function POST(req: NextRequest) {
email: data.email.toLowerCase(), email: data.email.toLowerCase(),
passwordHash, passwordHash,
}); });
const inviteUrl = data.password ? null : issueInvite(user, req);
await user.save();
if (data.role === "BRANCH_MANAGER" && branchId) { if (data.role === "BRANCH_MANAGER" && branchId) {
await Branch.findOneAndUpdate( await Branch.findOneAndUpdate(
@@ -103,7 +111,8 @@ export async function POST(req: NextRequest) {
const obj: any = user.toObject(); const obj: any = user.toObject();
delete obj.passwordHash; delete obj.passwordHash;
return NextResponse.json({ user: obj }, { status: 201 }); delete obj.inviteTokenHash;
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
} catch (err: any) { } catch (err: any) {
if (err?.code === 11000) { if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 }); return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api";
interface Invite {
name: string;
email: string;
businessName: string | null;
}
const inputClass =
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
/** Where an invited staff member sets their own password. */
export default function InvitePage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
const [invite, setInvite] = useState<Invite | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiFetch<Invite>("/api/invites/" + encodeURIComponent(token))
.then(setInvite)
.catch((err) => setLoadError(err instanceof ApiError ? err.message : "Could not load this invite."));
}, [token]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password.length < 8) return setError("Use at least 8 characters.");
if (password !== confirm) return setError("The two passwords do not match.");
setSaving(true);
try {
await apiFetch("/api/invites/" + encodeURIComponent(token), {
method: "POST",
body: JSON.stringify({ password }),
});
router.replace("/login?invited=1");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not save your password.");
setSaving(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
{loadError ? (
<p role="alert" className="text-sm text-alert-600">
{loadError}
</p>
) : !invite ? (
<div className="h-32 animate-pulse rounded-xl2 bg-sand-100" />
) : (
<>
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p>
<h1 className="mt-1 font-display text-2xl font-bold text-depth-900">Welcome, {invite.name}</h1>
<p className="mt-2 text-sm text-depth-600">
Choose a password. You will sign in with <strong>{invite.email}</strong> and this password.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
New password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
Confirm password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className={inputClass}
/>
</div>
{error && (
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
{error}
</p>
)}
<button
type="submit"
disabled={saving}
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
>
{saving ? "Saving…" : "Set password"}
</button>
</form>
</>
)}
</div>
</main>
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, 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 { getDeviceId, getDeviceLabel } from "@/lib/deviceId"; import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
@@ -21,6 +21,11 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null); const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
const [invited, setInvited] = useState(false);
useEffect(() => {
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
}, []);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -174,6 +179,11 @@ export default function LoginPage() {
<span className="font-medium text-flow-600">Forgot password?</span> <span className="font-medium text-flow-600">Forgot password?</span>
</div> </div>
{invited && !error && (
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
Your password is set. Sign in with your email and new password.
</p>
)}
{error && ( {error && (
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600"> <p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
{error} {error}
+65 -6
View File
@@ -4,7 +4,7 @@ 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, StockLevel } from "@/lib/types";
import { import {
dismissRejected, dismissRejected,
flushQueue, flushQueue,
@@ -13,10 +13,11 @@ import {
queueSale, queueSale,
registerOfflineSync, registerOfflineSync,
salesFor, salesFor,
unsyncedQuantities,
type QueuedSale, type QueuedSale,
type QueueOwner, type QueueOwner,
} from "@/lib/offlineQueue"; } from "@/lib/offlineQueue";
import { clearSession, loadSession, saveSession } from "@/lib/sessionCache"; import { clearSession, loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol"; import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import ProductGrid from "@/components/terminal/ProductGrid"; import ProductGrid from "@/components/terminal/ProductGrid";
@@ -51,6 +52,11 @@ export default function TerminalPage() {
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true); const [productsLoading, setProductsLoading] = useState(true);
const [cart, setCart] = useState<CartLine[]>([]); const [cart, setCart] = useState<CartLine[]>([]);
// Branch stock from the server, minus this phone's sales the server has
// not counted yet — so the till deducts stock the moment a sale is
// recorded, even offline (PRD 5.3).
const [stockSnapshot, setStockSnapshot] = useState<StockSnapshot | null>(null);
const [unsynced, setUnsynced] = useState<Record<string, number>>({});
const [showCustomer, setShowCustomer] = useState(false); const [showCustomer, setShowCustomer] = useState(false);
const [customerName, setCustomerName] = useState(""); const [customerName, setCustomerName] = useState("");
@@ -72,8 +78,37 @@ export default function TerminalPage() {
const refreshCounts = useCallback(async (userId: string) => { const refreshCounts = useCallback(async (userId: string) => {
setCounts(await queueCounts(userId)); setCounts(await queueCounts(userId));
const snapshotAt = loadSession()?.stock?.fetchedAt ?? null;
setUnsynced(unsyncedQuantities(await salesFor(userId), snapshotAt));
}, []); }, []);
const refreshStock = useCallback(
async (user: AuthUser) => {
const fetchedAt = new Date().toISOString();
try {
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
const snapshot: StockSnapshot = {
byProduct: Object.fromEntries(res.levels.map((l) => [l.productId, l.quantity])),
fetchedAt,
};
saveSession({ user, stock: snapshot });
setStockSnapshot(snapshot);
} catch {
// Offline: keep showing the last snapshot minus local sales.
const saved = loadSession();
if (saved?.user.id === user.id && saved.stock) setStockSnapshot(saved.stock);
}
await refreshCounts(user.id);
},
[refreshCounts]
);
const stockByProduct: Record<string, number> | null = stockSnapshot
? Object.fromEntries(
products.map((p) => [p._id, (stockSnapshot.byProduct[p._id] ?? 0) - (unsynced[p._id] ?? 0)])
)
: null;
// Boot: confirm the session online, or fall back to the last confirmed // 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. // session on this device so a cashier can keep selling with no network.
useEffect(() => { useEffect(() => {
@@ -106,7 +141,7 @@ export default function TerminalPage() {
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info"); show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
} }
setMe(user); setMe(user);
await refreshCounts(user.id); await refreshStock(user);
try { try {
const productsResult = await apiFetch<{ products: Product[] }>("/api/products"); const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
@@ -142,7 +177,10 @@ export default function TerminalPage() {
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => { const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
setCounts((c) => ({ ...c, pending: r.pending })); setCounts((c) => ({ ...c, pending: r.pending }));
setServerReachable(!r.offline); setServerReachable(!r.offline);
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success"); if (r.synced > 0) {
show(r.synced + " saved sale(s) synced", "success");
refreshStock(me);
}
if (r.rejected > 0) { if (r.rejected > 0) {
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error"); show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
refreshCounts(me.id); refreshCounts(me.id);
@@ -210,6 +248,19 @@ export default function TerminalPage() {
setFormError("Your account is not assigned to a branch."); setFormError("Your account is not assigned to a branch.");
return; return;
} }
// Warn, never block: the jerrycans may be physically there even if the
// system was not told (no opening count, an unrecorded delivery), and a
// sale that really happened must be recorded. Negative stock then shows
// up on the manager's Stock page to be corrected with a count.
if (stockByProduct) {
const short = lines.filter((l) => l.quantity > (stockByProduct[l.productId] ?? 0));
if (short.length > 0) {
const detail = short
.map((l) => `${l.product.label}: ${Math.max(stockByProduct[l.productId] ?? 0, 0)} in stock, selling ${l.quantity}`)
.join("\n");
if (!window.confirm(`Not enough stock recorded at this branch:\n${detail}\n\nRecord the sale anyway?`)) return;
}
}
setSubmitting(true); setSubmitting(true);
// The IndexedDB write is the only step that must finish before we show // The IndexedDB write is the only step that must finish before we show
@@ -233,7 +284,7 @@ export default function TerminalPage() {
setSubmitting(false); setSubmitting(false);
setScreen({ kind: "receipt", sale }); setScreen({ kind: "receipt", sale });
setCart([]); setCart([]);
await refreshCounts(me.id); await refreshCounts(me.id); // deducts this sale from the stock shown
// Sync in the background; a failure leaves the sale queued for the // Sync in the background; a failure leaves the sale queued for the
// next automatic retry. // next automatic retry.
@@ -241,6 +292,7 @@ export default function TerminalPage() {
flushQueue(owner).then(async (r) => { flushQueue(owner).then(async (r) => {
setCounts((c) => ({ ...c, pending: r.pending })); setCounts((c) => ({ ...c, pending: r.pending }));
setServerReachable(!r.offline); setServerReachable(!r.offline);
if (r.synced > 0) refreshStock(me);
const rows = await salesFor(me.id); const rows = await salesFor(me.id);
const updated = rows.find((row) => row.clientUuid === sale.clientUuid); const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s)); if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
@@ -450,7 +502,7 @@ export default function TerminalPage() {
{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" />
) : ( ) : (
<ProductGrid products={products} quantities={quantities} onAdd={addProduct} /> <ProductGrid products={products} quantities={quantities} stock={stockByProduct} onAdd={addProduct} />
)} )}
{lines.length > 0 && ( {lines.length > 0 && (
@@ -491,6 +543,13 @@ export default function TerminalPage() {
<span>Total</span> <span>Total</span>
<span>KES {total.toLocaleString()}</span> <span>KES {total.toLocaleString()}</span>
</div> </div>
<button
type="button"
onClick={() => setCart([])}
className="mt-2 text-sm font-medium text-alert-600 underline underline-offset-2"
>
Clear sale
</button>
</div> </div>
)} )}
+13 -1
View File
@@ -5,11 +5,13 @@ import { Product } from "@/lib/types";
interface Props { interface Props {
products: Product[]; products: Product[];
quantities: Record<string, number>; quantities: Record<string, number>;
/** Stock left at this branch after this phone's own sales; null if unknown. */
stock: Record<string, number> | null;
onAdd: (productId: string) => void; onAdd: (productId: string) => void;
} }
/** One tap adds one unit of a product to the current sale. */ /** One tap adds one unit of a product to the current sale. */
export default function ProductGrid({ products, quantities, onAdd }: Props) { export default function ProductGrid({ products, quantities, stock, onAdd }: Props) {
if (products.length === 0) { if (products.length === 0) {
return ( return (
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500"> <div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
@@ -37,6 +39,16 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
> >
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span> <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> <span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
{stock && (
<span
className={
"text-xs font-medium " +
((stock[p._id] ?? 0) - inCart <= 0 ? "text-alert-600" : "text-depth-500")
}
>
{Math.max((stock[p._id] ?? 0) - inCart, 0)} in stock
</span>
)}
{inCart > 0 && ( {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"> <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} {inCart}
+17
View File
@@ -108,6 +108,23 @@ export async function queueCounts(userId: string): Promise<{ pending: number; re
}; };
} }
/**
* Units this cashier has sold that the given server stock snapshot does
* not include yet: sales still pending, plus sales that synced after the
* snapshot was taken. Subtracting these from the snapshot gives the stock
* the till should show right now, online or offline.
*/
export function unsyncedQuantities(rows: QueuedSale[], snapshotAt: string | null): Record<string, number> {
const out: Record<string, number> = {};
for (const row of rows) {
const notInSnapshot =
row.state === "pending" || (row.state === "synced" && (!snapshotAt || (row.syncedAt ?? "") > snapshotAt));
if (!notInSnapshot) continue;
for (const l of row.lines) out[l.productId] = (out[l.productId] ?? 0) + l.quantity;
}
return out;
}
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */ /** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
export async function dismissRejected(clientUuid: string): Promise<void> { export async function dismissRejected(clientUuid: string): Promise<void> {
const db = await getDb(); const db = await getDb();
+27
View File
@@ -0,0 +1,27 @@
import crypto from "crypto";
import bcrypt from "bcryptjs";
import type { NextRequest } from "next/server";
import type { IUser } from "@/lib/server/models/User";
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export function hashInviteToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/** A password nobody knows, for an account whose owner has not accepted their invite yet. */
export async function unusablePasswordHash(): Promise<string> {
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
}
/**
* Give the user a fresh invite (any earlier link stops working) and return
* the link to send them. The caller saves the user.
*/
export function issueInvite(user: IUser, req: NextRequest): string {
const token = crypto.randomBytes(32).toString("base64url");
user.inviteTokenHash = hashInviteToken(token);
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS);
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
return `${origin}/invite/${token}`;
}
+10
View File
@@ -6,6 +6,12 @@ export interface IBranch extends Document {
branchName: string; branchName: string;
locationCity: string; locationCity: string;
managerId: Types.ObjectId | null; managerId: Types.ObjectId | null;
// A deactivated branch keeps all its sales, purchases and stock history
// and still appears in reports for past periods, but takes no new staff,
// transfers or purchases and does not count towards the plan's limit.
// Rows created before this field existed have no value: treat as active
// (query with `isActive: { $ne: false }`).
isActive: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -16,9 +22,13 @@ const branchSchema = new Schema<IBranch>(
branchName: { type: String, required: true, trim: true }, branchName: { type: String, required: true, trim: true },
locationCity: { type: String, required: true, trim: true }, locationCity: { type: String, required: true, trim: true },
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null }, managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
isActive: { type: Boolean, default: true },
}, },
{ timestamps: true } { timestamps: true }
); );
/** Filter for branches that are in use. */
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
export const Branch = export const Branch =
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema); (models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
+3 -1
View File
@@ -4,8 +4,10 @@ export interface IProduct extends Document {
_id: Types.ObjectId; _id: Types.ObjectId;
businessId: Types.ObjectId; // tenant this product belongs to businessId: Types.ObjectId; // tenant this product belongs to
label: string; label: string;
// Size measured in `unit` (litres for water, a count for side items).
// The field keeps its historical name so existing data needs no migration.
sizeLiters: number; sizeLiters: number;
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs unit: string; // see PRODUCT_UNITS in src/lib/units.ts: "L", "pcs", "tray"...
category: string; // e.g. "Water", "Eggs" category: string; // e.g. "Water", "Eggs"
unitPrice: number; unitPrice: number;
sortOrder: number; sortOrder: number;
+7
View File
@@ -16,6 +16,11 @@ export interface IUser extends Document {
isActive: boolean; isActive: boolean;
failedLoginAttempts: number; failedLoginAttempts: number;
lockedUntil: Date | null; lockedUntil: Date | null;
// Pending invite: the person sets their own password through a one-time
// link. Only the SHA-256 of the token is stored. Until they accept, the
// account has a random unknown password, so it cannot be signed into.
inviteTokenHash: string | null;
inviteExpiresAt: Date | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -34,6 +39,8 @@ const userSchema = new Schema<IUser>(
isActive: { type: Boolean, default: true }, isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 }, failedLoginAttempts: { type: Number, default: 0 },
lockedUntil: { type: Date, default: null }, lockedUntil: { type: Date, default: null },
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
inviteExpiresAt: { type: Date, default: null },
}, },
{ timestamps: true } { timestamps: true }
); );
+21
View File
@@ -0,0 +1,21 @@
import { Types } from "mongoose";
import { Product } from "@/lib/server/models/Product";
/**
* Two products with the same name (ignoring case) would be impossible to
* tell apart on the terminal and in reports, so names are unique per
* business — including deactivated products, which can be reactivated.
*/
export async function labelTakenError(
businessId: Types.ObjectId | string,
label: string,
exceptId?: string
): Promise<string | null> {
const filter: Record<string, unknown> = { businessId, label: label.trim() };
if (exceptId) filter._id = { $ne: exceptId };
const clash = await Product.findOne(filter).collation({ locale: "en", strength: 2 }).select("label isActive").lean();
if (!clash) return null;
return clash.isActive
? `A product called "${clash.label}" already exists.`
: `A deactivated product called "${clash.label}" already exists. Reactivate it instead.`;
}
+40 -5
View File
@@ -1,7 +1,8 @@
import mongoose, { Types } from "mongoose"; import mongoose, { Types } from "mongoose";
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement"; import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
import { Branch } from "@/lib/server/models/Branch"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import type { ITransaction } from "@/lib/server/models/Transaction"; import type { ITransaction } from "@/lib/server/models/Transaction";
type Id = Types.ObjectId | string; type Id = Types.ObjectId | string;
@@ -89,6 +90,7 @@ export interface StockLevel {
quantity: number; quantity: number;
lowStockThreshold: number; lowStockThreshold: number;
status: StockStatus; status: StockStatus;
productActive: boolean;
} }
export function stockStatus(quantity: number, threshold: number): StockStatus { export function stockStatus(quantity: number, threshold: number): StockStatus {
@@ -98,20 +100,22 @@ export function stockStatus(quantity: number, threshold: number): StockStatus {
} }
/** /**
* Stock of every active product at every branch in scope — including * Stock of every active product at every active branch in scope —
* zero rows, so an owner sees "0" rather than a missing product. A * including zero rows, so an owner sees "0" rather than a missing product.
* A deactivated product is listed too wherever it still has stock, so
* units bought before deactivation never silently disappear. A
* negative quantity is reported as-is: it means sales were recorded * negative quantity is reported as-is: it means sales were recorded
* (typically offline) for stock the ledger never received, and the branch * (typically offline) for stock the ledger never received, and the branch
* needs a count/adjustment. * needs a count/adjustment.
*/ */
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> { export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
const bizObj = new mongoose.Types.ObjectId(businessId.toString()); const bizObj = new mongoose.Types.ObjectId(businessId.toString());
const branchFilter: Record<string, unknown> = { businessId: bizObj }; const branchFilter: Record<string, unknown> = { businessId: bizObj, ...ACTIVE_BRANCH };
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString()); if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
const [branches, products, totals] = await Promise.all([ const [branches, products, totals] = await Promise.all([
Branch.find(branchFilter).sort({ branchName: 1 }).lean(), Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(), Product.find({ businessId: bizObj }).sort({ sortOrder: 1 }).lean(),
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([ StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } }, { $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } }, { $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
@@ -123,6 +127,7 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
for (const b of branches) { for (const b of branches) {
for (const p of products) { for (const p of products) {
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0; const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
if (!p.isActive && quantity === 0) continue;
const lowStockThreshold = p.lowStockThreshold ?? 10; const lowStockThreshold = p.lowStockThreshold ?? 10;
levels.push({ levels.push({
branchId: b._id.toString(), branchId: b._id.toString(),
@@ -133,8 +138,38 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
quantity, quantity,
lowStockThreshold, lowStockThreshold,
status: stockStatus(quantity, lowStockThreshold), status: stockStatus(quantity, lowStockThreshold),
productActive: p.isActive,
}); });
} }
} }
return levels; return levels;
} }
/**
* Write the PURCHASE movement for every catalogue purchase that has none.
* Purchases recorded before the stock ledger existed (or while a write
* failed) otherwise never show up as stock. Idempotent: run it as often
* as you like. Returns how many movements were added.
*/
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
const filter: Record<string, unknown> = { productId: { $ne: null } };
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
let added = 0;
for await (const p of Purchase.find(filter).lean().cursor()) {
const refId = p._id.toString();
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
await recordMovement({
businessId: p.businessId,
branchId: p.branchId,
productId: p.productId!,
type: "PURCHASE",
quantity: p.quantity,
refId,
note: `From ${p.supplierName} (backfilled)`,
recordedBy: p.recordedBy,
});
added += 1;
}
return added;
}
+12 -10
View File
@@ -8,25 +8,27 @@ 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, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, effectiveMaxBranches, 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";
} }
/** /**
* Returns an error message string if adding one more branch would exceed * Returns an error message string if adding (or reactivating) one more
* the business's plan, or null if it's allowed. * branch would exceed the business's plan, or null if it's allowed. Only
* active branches count; trials get TRIAL_MAX_BRANCHES.
*/ */
export async function branchLimitError( export async function branchLimitError(businessId: Types.ObjectId | string): Promise<string | null> {
businessId: Types.ObjectId | string, const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
tier: TierId const tier = (biz?.tier as TierId) ?? "STARTER";
): Promise<string | null> { const status = (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
const max = TIERS[tier].maxBranches; const max = effectiveMaxBranches(tier, status);
if (max === null) return null; // unlimited if (max === null) return null; // unlimited
const count = await Branch.countDocuments({ businessId }); const count = await Branch.countDocuments({ businessId, isActive: { $ne: false } });
if (count >= max) { if (count >= max) {
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`; const plan = status === "TRIALING" ? "free trial" : `${TIERS[tier].name} plan`;
return `Your ${plan} allows ${max} active branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
} }
return null; return null;
} }
+8
View File
@@ -9,10 +9,17 @@ import type { AuthUser, Product } from "@/lib/types";
const KEY = "aquaflow_terminal_session"; const KEY = "aquaflow_terminal_session";
/** The branch's stock as the server last reported it. */
export interface StockSnapshot {
byProduct: Record<string, number>;
fetchedAt: string;
}
export interface SessionSnapshot { export interface SessionSnapshot {
user: AuthUser; user: AuthUser;
features: { mpesa: boolean }; features: { mpesa: boolean };
products: Product[]; products: Product[];
stock?: StockSnapshot;
savedAt: string; savedAt: string;
} }
@@ -32,6 +39,7 @@ export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser
const next: SessionSnapshot = { const next: SessionSnapshot = {
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }), features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
products: update.products ?? (sameUser ? prev!.products : []), products: update.products ?? (sameUser ? prev!.products : []),
stock: update.stock ?? (sameUser ? prev!.stock : undefined),
user: update.user, user: update.user,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
+13
View File
@@ -66,6 +66,19 @@ export const DEFAULT_TIER: TierId = "STARTER";
// Days of free trial a brand-new business gets on signup. // Days of free trial a brand-new business gets on signup.
export const TRIAL_DAYS = 14; export const TRIAL_DAYS = 14;
// Branches allowed while a business is TRIALING, whatever its tier, so a
// new owner can try the multi-branch features (transfers, per-branch
// stock and reports) before paying. After the trial the tier's own limit
// applies to adding branches; existing branches are never removed.
export const TRIAL_MAX_BRANCHES = 2;
/** Branch limit for a business right now (null = unlimited). */
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
const max = TIERS[tier].maxBranches;
if (status !== "TRIALING" || max === null) return max;
return Math.max(max, TRIAL_MAX_BRANCHES);
}
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED"; export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
export function formatKsh(amount: number): string { export function formatKsh(amount: number): string {
+2
View File
@@ -7,6 +7,7 @@ export interface Branch {
branchName: string; branchName: string;
locationCity: string; locationCity: string;
managerId: string | null; managerId: string | null;
isActive?: boolean;
} }
export interface AuthUser { export interface AuthUser {
@@ -94,6 +95,7 @@ export interface StockLevel {
quantity: number; quantity: number;
lowStockThreshold: number; lowStockThreshold: number;
status: "OK" | "LOW" | "OUT"; status: "OK" | "LOW" | "OUT";
productActive: boolean;
} }
export interface StockMovementRow { export interface StockMovementRow {
+20
View File
@@ -0,0 +1,20 @@
// Units a product can be measured in. Water refills are sized in litres;
// side items (eggs, bottles, bags) are counted.
export const PRODUCT_UNITS = ["L", "ml", "kg", "g", "pcs", "tray", "pack"] as const;
export type ProductUnit = (typeof PRODUCT_UNITS)[number];
export const UNIT_LABELS: Record<ProductUnit, string> = {
L: "litres",
ml: "millilitres",
kg: "kilograms",
g: "grams",
pcs: "pieces",
tray: "trays",
pack: "packs",
};
/** "20L", "500ml", "30 pcs", "1 tray". */
export function formatSize(size: number, unit: string | undefined): string {
const u = unit || "L";
return u === "L" || u === "ml" || u === "kg" || u === "g" ? `${size}${u}` : `${size} ${u}`;
}
+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import { backfillPurchaseMovements, stockOf, getStockLevels } from "@/lib/server/stock";
import * as branches from "@/app/api/branches/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as users from "@/app/api/users/route";
import * as invites from "@/app/api/invites/[token]/route";
import * as reinvite from "@/app/api/users/[id]/invite/route";
import * as login from "@/app/api/auth/login/route";
import * as products from "@/app/api/products/route";
import * as productById from "@/app/api/products/[id]/route";
import * as purchases from "@/app/api/purchases/route";
import * as profit from "@/app/api/reports/profit/route";
import * as cash from "@/app/api/transactions/cash/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
}
const newBranch = (name: string) => ({ branchName: name, locationCity: "Nakuru" });
describe("F-01 trial branch allowance", () => {
it("lets a trialling Starter business run two branches, not three", async () => {
const t = await makeTenant(); // already has branches A and B
await setPlan(t, "STARTER", "TRIALING");
const third = await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }));
expect(third.status).toBe(403);
expect((await third.json()).error).toMatch(/free trial allows 2/);
});
it("counts only active branches, and a paid Starter plan allows one", async () => {
const t = await makeTenant();
await setPlan(t, "STARTER", "TRIALING");
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
await setPlan(t, "STARTER", "ACTIVE");
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("D") }))).status).toBe(403);
});
});
describe("F-06 branch deactivation", () => {
it("refuses while the branch still holds stock or active staff, then succeeds once emptied", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 4 } })
);
const deactivate = () =>
branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), params({ id: t.branchB }));
const withStock = await deactivate();
expect(withStock.status).toBe(409);
expect((await withStock.json()).error).toMatch(/4 x 20L Jerrycan/);
await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 4 },
})
);
const withStaff = await deactivate();
expect(withStaff.status).toBe(409);
expect((await withStaff.json()).error).toMatch(/active staff/);
await User.updateOne({ _id: t.cashierB.id }, { isActive: false });
expect((await deactivate()).status).toBe(200);
// Hidden from pickers, kept on the Branches page.
const active = await json(await branches.GET(req("GET", "/api/branches", { as: t.owner })));
const all = await json(await branches.GET(req("GET", "/api/branches?includeInactive=1", { as: t.owner })));
expect(active.body.branches.map((b: any) => b._id)).toEqual([t.branchA]);
expect(all.body.branches).toHaveLength(2);
});
it("will not reactivate a branch past the plan's limit", async () => {
const t = await makeTenant();
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
await setPlan(t, "STARTER", "ACTIVE");
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
expect(res.status).toBe(403);
});
});
describe("F-03 report period validation", () => {
it("rejects a reversed period instead of showing an empty report", async () => {
const t = await makeTenant();
const res = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-12&toDate=2026-10-11", { as: t.owner }));
expect(res.status).toBe(400);
const sameDay = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-11&toDate=2026-10-11", { as: t.owner }));
expect(sameDay.status).toBe(200);
});
});
describe("F-07 cost of goods sold", () => {
it("costs only the units sold, at the average buying price, and reports purchases separately", async () => {
const t = await makeTenant();
const buy = (quantity: number, unitCost: number) =>
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
await buy(20, 10);
await buy(10, 13); // average (200 + 130) / 30 = 11
await cash.POST(
req("POST", "/x", {
as: t.cashierA,
body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 3 }, { productId: t.water5, quantity: 1 }] }] },
})
);
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({
revenue: 660, // 3 x 200 + 1 x 60
unitsSold: 4,
costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased
unitsWithoutCost: 1,
grossProfit: 627,
stockPurchases: 330,
cashSurplus: 330,
});
});
});
describe("purchase-to-stock backfill", () => {
it("turns pre-ledger purchases into stock, once", async () => {
const t = await makeTenant();
await Purchase.create({
businessId: t.businessId,
branchId: t.branchA,
supplierName: "Old supplier",
productId: t.water20,
productLabel: "20L Jerrycan",
quantity: 20,
unitCost: 10,
totalCost: 200,
recordedBy: t.owner.id,
});
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
expect(await backfillPurchaseMovements()).toBe(1);
expect(await backfillPurchaseMovements()).toBe(0);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20);
});
});
describe("F-04 / F-05 / CAT-12 catalogue", () => {
it("lists deactivated products for managers and keeps their stock visible", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
);
await Product.updateOne({ _id: t.water5 }, { isActive: false });
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
expect(forManager.body.products).toHaveLength(2);
expect(forCashier.body.products).toHaveLength(1);
const levels = await getStockLevels(t.businessId);
expect(levels.find((l) => l.productId === t.water5 && l.branchId === t.branchA)).toMatchObject({
quantity: 7,
productActive: false,
});
expect(levels.some((l) => l.productId === t.water5 && l.branchId === t.branchB)).toBe(false);
});
it("stores count units, refuses duplicate names and zero prices", async () => {
const t = await makeTenant();
const create = (body: object) => products.POST(req("POST", "/x", { as: t.owner, body }));
const tray = await json(await create({ label: "Tray of Eggs", sizeLiters: 30, unit: "pcs", unitPrice: 400 }));
expect(tray.status).toBe(201);
expect(tray.body.product.unit).toBe("pcs");
expect((await create({ label: "tray of eggs", sizeLiters: 1, unit: "tray", unitPrice: 400 })).status).toBe(409);
expect((await create({ label: "Free sample", sizeLiters: 1, unit: "pcs", unitPrice: 0 })).status).toBe(400);
expect((await create({ label: "Bucket", sizeLiters: 1, unit: "buckets", unitPrice: 50 })).status).toBe(400);
const rename = await productById.PATCH(
req("PATCH", "/x", { as: t.owner, body: { label: "20L JERRYCAN" } }),
params({ id: t.water5 })
);
expect(rename.status).toBe(409);
});
});
describe("STA-04 staff invites", () => {
async function invite(t: Tenant) {
const res = await json(
await users.POST(
req("POST", "/api/users", {
as: t.managerA,
body: { name: "New Cashier", role: "DELIVERY", phoneNumber: "0711222333", email: "new.cashier@aquaflow.test" },
})
)
);
expect(res.status).toBe(201);
return { userId: res.body.user._id as string, token: (res.body.inviteUrl as string).split("/invite/")[1] };
}
const signIn = (password: string) =>
login.POST(req("POST", "/x", { body: { email: "new.cashier@aquaflow.test", password, deviceId: uuid() } }));
it("lets the invited person set their own password, once", async () => {
const t = await makeTenant();
const { token } = await invite(t);
const who = await json(await invites.GET(req("GET", "/x"), params({ token })));
expect(who.body).toMatchObject({ name: "New Cashier", email: "new.cashier@aquaflow.test" });
expect((await invites.POST(req("POST", "/x", { body: { password: "short" } }), params({ token }))).status).toBe(400);
expect((await invites.POST(req("POST", "/x", { body: { password: "my-own-secret" } }), params({ token }))).status).toBe(200);
expect((await signIn("my-own-secret")).status).toBe(200);
// The link is single-use.
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
});
it("issues a fresh link that replaces the old one, and rejects expired links", async () => {
const t = await makeTenant();
const { userId, token } = await invite(t);
const again = await json(await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: userId })));
expect(again.status).toBe(200);
const fresh = (again.body.inviteUrl as string).split("/invite/")[1];
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
await User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) });
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
});
it("does not let a manager re-invite another branch's staff", async () => {
const t = await makeTenant();
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
expect(res.status).toBe(403);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import { Transaction } from "@/lib/server/models/Transaction";
import { backfillPurchaseMovements, stockOf } from "@/lib/server/stock";
import * as cash from "@/app/api/transactions/cash/route";
import * as transfers from "@/app/api/stock/transfers/route";
import * as profit from "@/app/api/reports/profit/route";
import * as summary from "@/app/api/dashboard/summary/route";
import { json, makeTenant, req, uuid, type Actor } from "./helpers";
/**
* The "Completion and retest plan" from the 11 October 2026 test report
* (AquaFlow_Initial_Test_Report, pages 9-10), with its expected numbers.
*/
describe("initial test report: completion and retest plan", () => {
it("matches every planned balance and total", async () => {
const t = await makeTenant("ZERRO");
const [refill3] = await Product.insertMany([
{ businessId: t.businessId, label: "QA 20261011 3L Refill", sizeLiters: 3, unitPrice: 40 },
]);
const l3 = refill3._id.toString();
const l5 = t.water5; // KES 60
// Fixtures recorded before the ledger existed (QA-20261011-STOCK-A-3L / -5L),
// brought into stock by the one-off backfill.
await Purchase.insertMany([
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l3, productLabel: "3L", quantity: 20, unitCost: 10, totalCost: 200, recordedBy: t.owner.id },
{ businessId: t.businessId, branchId: t.branchA, supplierName: "QA", productId: l5, productLabel: "5L", quantity: 10, unitCost: 20, totalCost: 200, recordedBy: t.owner.id },
]);
await backfillPurchaseMovements(t.businessId);
const stock = async (branch: string, product: string) => stockOf(t.businessId, branch, product);
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([20, 10]);
const sell = async (as: Actor, lines: { productId: string; quantity: number }[], offline = false) =>
json(
await cash.POST(
req("POST", "/x", {
as,
body: {
sales: [
{
clientUuid: uuid(),
staffId: as.id,
branchId: as.branchId,
lines,
...(offline ? { clientSubmittedAt: new Date().toISOString() } : {}),
},
],
},
})
)
);
// Online sale: 2 x 3L + 1 x 5L = KES 140; A balances 18 and 9.
const online = await sell(t.cashierA, [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }]);
expect(online.body.results[0]).toMatchObject({ status: "saved", amountTotal: 140 });
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([18, 9]);
// Transfer 5 x 3L from A to B: A 13, B 5, combined 18.
const transfer = await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: l3, quantity: 5 },
})
);
expect(transfer.status).toBe(201);
expect([await stock(t.branchA, l3), await stock(t.branchB, l3)]).toEqual([13, 5]);
// Two offline sales replayed on reconnect (sale 2: 1 x 3L; sale 3: 2 x 3L + 1 x 5L),
// then replayed again: exactly two new sales (three lines), A 10 x 3L and 8 x 5L.
const before = await Transaction.countDocuments({ businessId: t.businessId });
const queued = [
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 1 }] },
{ clientUuid: uuid(), staffId: t.cashierA.id, branchId: t.branchA, clientSubmittedAt: new Date().toISOString(), lines: [{ productId: l3, quantity: 2 }, { productId: l5, quantity: 1 }] },
];
for (let pass = 0; pass < 2; pass++) {
const r = await json(await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: queued } })));
expect(r.body.results.map((x: any) => x.status)).toEqual(pass === 0 ? ["saved", "saved"] : ["duplicate", "duplicate"]);
}
expect((await Transaction.countDocuments({ businessId: t.businessId })) - before).toBe(3);
expect([await stock(t.branchA, l3), await stock(t.branchA, l5)]).toEqual([10, 8]);
// Branch B sells 1 x 3L for KES 40.
await sell(t.cashierB, [{ productId: l3, quantity: 1 }]);
// Final: A 3L 10, B 3L 4, A 5L 8; combined 3L 14 = 20 - 6 sold, 5L 8 = 10 - 2 sold.
expect([await stock(t.branchA, l3), await stock(t.branchB, l3), await stock(t.branchA, l5)]).toEqual([10, 4, 8]);
// Revenue: A KES 320, B KES 40, total KES 360 — on the report and the owner dashboard.
const report = async (q: string) => (await json(await profit.GET(req("GET", "/api/reports/profit" + q, { as: t.owner })))).body;
expect((await report("")).revenue).toBe(360);
expect((await report("?branchId=" + t.branchA)).revenue).toBe(320);
expect((await report("?branchId=" + t.branchB)).revenue).toBe(40);
const dash = (await json(await summary.GET(req("GET", "/api/dashboard/summary", { as: t.owner })))).body;
expect(dash.totals.cashTotal).toBe(360);
const ranking = Object.fromEntries(dash.branchRanking.map((b: any) => [b.branchName, b.revenue]));
expect(ranking).toEqual({ "ZERRO A": 320, "ZERRO B": 40 });
});
});