Compare commits

10 Commits

Author SHA1 Message Date
denis_ireri 34ea64c4d4 Merge pull request 'Send platform operators to the platform console after sign-in' (#9) from fix/operator-landing into main
Reviewed-on: #9
2026-10-13 15:06:36 +00:00
denis_ireri d15273e2a6 Send platform operators to the platform console after sign-in
The operator account is the owner of a placeholder business, so sign-in
landed it on that business's empty dashboard with a "Create your first
branch" nudge. Operators now land on /platform (sign-in and the root page),
the dashboard header says "Platform operator", and the nudge is hidden on
the operator's own business.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:04:58 +03:00
denis_ireri 5b7a2b847f Merge pull request 'Add password resets, change password, sign-out, a Branches link and moving staff' (#8) from fix/account-and-navigation-gaps into main
Reviewed-on: #8
2026-10-13 11:04:47 +00:00
denis_ireri 6f8a5fe969 Add password resets, change password, sign-out, a Branches link and moving staff
Gaps found while writing the user manual:

- Forgotten passwords: owners and managers can create a one-time
  password reset link from Staff (managers for their own cashiers). It
  expires after 24 hours; the old password works until it is used, and
  using it signs the account out everywhere. Reset and invite links share
  the invite page, which now says which it is. An owner who forgets their
  password gets a link from the platform operator (Platform console,
  "Owner password link"). The login page's "Forgot password?" explains
  this.
- Change password: any signed-in user, from the dashboard, the till or
  the platform console. Needs the current password; other devices are
  signed out.
- Sign out on the dashboard and the platform console (the till already
  had it), through one shared helper that also clears the till's cached
  session.
- A Branches link on the owner's dashboard; the page was only reachable
  by typing its address.
- Owners can move a manager or cashier to another branch from Staff. A
  moved manager stops being the old branch's manager and becomes the new
  branch's if it has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:57:04 +03:00
denis_ireri 418ad36b02 Merge pull request 'Cost sales first-in-first-out per branch instead of at an average' (#7) from fix/fifo-cost-of-goods into main
Reviewed-on: #7
2026-10-12 09:54:13 +00:00
denis_ireri ff75cf2fa7 Cost sales first-in-first-out per branch instead of at an average
Cost of goods sold used each product's average buying price across the
whole business. Ten jerrycans bought at 100 and ten at 140, with 15 sold,
cost 1,800 instead of the 1,700 actually paid for those units; a branch
that bought dearer was costed like one that bought cheaper; and units
moved between branches lost their cost.

src/lib/server/costing.ts replays the stock ledger in order with cost
layers per branch and product:
- purchases add stock at their unit cost; transfers carry the sending
  branch's layers to the receiving branch; opening stock and upward counts
  are valued at the product's purchase price at the time;
- sales take the oldest layers first, and that is cost of goods sold;
- stock written off by counts or adjustments is a separate "stock losses"
  figure, now deducted in net profit;
- a sale recorded before its stock (stock gone negative) is costed by the
  next stock to arrive at that branch, even after the period ended; until
  then it is reported as units with no purchase cost on record.

The report shows the method and a Stock losses card. Sales recorded
before the stock ledger existed are counted as uncosted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:15 +03:00
denis_ireri db9387c01a Merge pull request 'Enforce tenant isolation in the data layer instead of per route' (#6) from fix/enforced-tenant-isolation into main
Reviewed-on: #6
2026-10-12 08:58:31 +00:00
denis_ireri dc500972e0 Merge remote-tracking branch 'origin/main' into fix/enforced-tenant-isolation 2026-10-12 11:55:37 +03:00
denis_ireri 83ec5ecd36 Enforce tenant isolation in the data layer instead of per route
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().

- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
  find/update/delete/count/distinct, or an aggregate whose first stage is
  not a $match, that does not pin exactly one businessId throws
  TenantScopeError before reaching the database ($in, $exists and other
  operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
  queries that must span businesses - login's email lookup, refresh and
  invite-token lookups, the M-Pesa callback, the platform console and the
  maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
  restricted to the business.
- Every flagged query site now names the business (or is exempted with a
  reason).
- Tests: a check that every model with a businessId field carries the
  guard; the guard refusing unscoped queries of every kind; and a route
  sweep where business A sends business B's ids to every read route
  (nothing of B's comes back) and every write route (all refused, B's
  data unchanged). Test-side database inspections go through db(), an
  explicit test exemption. Removing one route's business filter makes the
  suite fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:52:35 +03:00
denis_ireri 21d530cd6d Merge remote-tracking branch 'origin/fix/per-business-identity' into fix/enforced-tenant-isolation 2026-10-12 11:40:38 +03:00
64 changed files with 1416 additions and 159 deletions
+45 -7
View File
@@ -98,7 +98,7 @@ seven findings and a retest plan. All of them are addressed on
| 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-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-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-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. | | F-07 Purchases shown as cost of goods | Cost of goods sold covers only units sold (now first-in-first-out per branch; see "Cost of goods sold" below). 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. | | 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. | | 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. | | OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
@@ -157,13 +157,51 @@ platform-wide unique indexes for per-business ones. Until it runs,
operators cannot open the Platform console and a second business cannot operators cannot open the Platform console and a second business cannot
reuse an email. It is safe to re-run. reuse an email. It is safe to re-run.
## Tenant isolation is enforced
Every model that holds one business's data carries a guard
(`src/lib/server/tenantGuard.ts`). A query that does not name exactly one
business (`businessId: <id>`) throws `TenantScopeError` before it reaches
the database, so a route that forgets the business filter fails loudly in
development and in the tests instead of reading or changing another
business's data.
- Guarded: User, Branch, Product, BranchPricing, Transaction,
DailyInventory, Expense, Purchase, StockMovement, StockTransfer. A test
fails if a new model with a `businessId` field is added without the guard.
- Queries that genuinely span businesses must be wrapped in
`crossTenant("reason", ...)`: login's email lookup, refresh-session and
invite-token lookups, the M-Pesa callback, the platform console, and the
maintenance scripts. `git grep crossTenant` lists every one.
- Related records are loaded with `scopedPopulate`, so a populated branch,
product or staff name is also restricted to the business.
- `tests/isolation.test.ts` sweeps every read route with another business's
ids (nothing of theirs comes back) and every write route (all refused,
their data unchanged).
- Not guarded, by design: Business (the tenant itself, always looked up by
the session's id), UserSession, MpesaCallbackLog and StockLock.
## Cost of goods sold
The profit report costs sales first-in-first-out, per branch
(`src/lib/server/costing.ts`). It replays the stock ledger in order. Each
branch keeps queues of stock at the price it was bought at, and a sale
takes the oldest first.
- **Transfers carry their cost.** Stock moved between branches arrives at
what the sending branch paid.
- **Opening stock and upward counts** are valued at the product's purchase
price at the time (or its first purchase price, if they came first).
- **A sale recorded before its stock** (e.g. an offline sale before the
purchase was entered) is costed by the next stock to arrive at that
branch. Until then it counts as "units with no purchase cost on record".
- **Stock written off** by counts or adjustments is shown as "Stock losses"
and deducted in net profit. It is not part of cost of goods sold.
- Each report replays the business's whole stock history. That is fast at
pilot volumes; a business with years of history would want stored cost
layers instead.
## Still open ## Still open
- **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every
route.
- **Cost of goods is an average.** It uses each product's average buying
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:** email verification at signup, sending invites by - **Not modelled yet:** email verification at signup, sending invites by
+2 -1
View File
@@ -13,6 +13,7 @@ import { Expense } from "../src/lib/server/models/Expense";
import { Purchase } from "../src/lib/server/models/Purchase"; import { Purchase } from "../src/lib/server/models/Purchase";
import { Business } from "../src/lib/server/models/Business"; import { Business } from "../src/lib/server/models/Business";
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers"; import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
import { crossTenant } from "../src/lib/server/tenantGuard";
async function migrate() { async function migrate() {
await connectDb(); await connectDb();
@@ -75,7 +76,7 @@ async function migrate() {
process.exit(0); process.exit(0);
} }
migrate().catch((err) => { crossTenant("one-off migration of data from before businesses existed", migrate).catch((err) => {
console.error("migration failed:", err.message); console.error("migration failed:", err.message);
process.exit(1); process.exit(1);
}); });
+2 -1
View File
@@ -6,6 +6,7 @@ import mongoose from "mongoose";
import { connectDb } from "../src/lib/server/db"; import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User"; import { User } from "../src/lib/server/models/User";
import { platformAdminEmails } from "../src/lib/server/platform"; import { platformAdminEmails } from "../src/lib/server/platform";
import { crossTenant } from "../src/lib/server/tenantGuard";
/** /**
* One-off, safe to re-run. Moves user identity from platform-wide to * One-off, safe to re-run. Moves user identity from platform-wide to
@@ -43,7 +44,7 @@ async function main() {
await mongoose.disconnect(); await mongoose.disconnect();
} }
main().catch((err) => { crossTenant("identity migration spans every business", main).catch((err) => {
console.error("migration failed:", err.message); console.error("migration failed:", err.message);
process.exit(1); process.exit(1);
}); });
+2 -1
View File
@@ -14,6 +14,7 @@ import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product"; import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business"; import { Business } from "../src/lib/server/models/Business";
import { isPlatformAdminEmail } from "../src/lib/server/platform"; import { isPlatformAdminEmail } from "../src/lib/server/platform";
import { crossTenant } from "../src/lib/server/tenantGuard";
/** /**
* Run once against a fresh database: `npm run seed`. * Run once against a fresh database: `npm run seed`.
@@ -107,7 +108,7 @@ async function seed() {
process.exit(0); process.exit(0);
} }
seed().catch((err) => { crossTenant("seeding may find a legacy admin outside any business", seed).catch((err) => {
console.error("seed failed:", err.message); console.error("seed failed:", err.message);
process.exit(1); process.exit(1);
}); });
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser } from "@/lib/types";
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";
/** The signed-in user changes their own password. */
export default function ChangePasswordPage() {
const router = useRouter();
const [me, setMe] = useState<AuthUser | null>(null);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiFetch<{ user: AuthUser }>("/api/auth/me")
.then((r) => setMe(r.user))
.catch(() => router.replace("/login"));
}, [router]);
const home = me?.role === "DELIVERY" ? "/terminal" : "/admin/dashboard";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (next.length < 8) return setError("The new password must be at least 8 characters.");
if (next !== confirm) return setError("The two new passwords do not match.");
setSaving(true);
try {
await apiFetch("/api/auth/password", {
method: "POST",
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
setDone(true);
setCurrent("");
setNext("");
setConfirm("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not change 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">
<h1 className="font-display text-2xl font-bold text-depth-900">Change password</h1>
{me && <p className="mt-1 text-sm text-depth-600">{me.email}</p>}
{done ? (
<div className="mt-6">
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
Your password is changed. Any other phone or computer signed in to your account has been signed out.
</p>
<button
onClick={() => router.replace(home)}
className="tap-target mt-4 w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white"
>
Back
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label htmlFor="current-password" className="mb-1.5 block text-sm font-medium text-depth-700">
Current password
</label>
<input id="current-password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} className={inputClass} />
</div>
<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={next} onChange={(e) => setNext(e.target.value)} className={inputClass} />
</div>
<div>
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
Confirm new 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…" : "Change password"}
</button>
<button type="button" onClick={() => router.replace(home)} className="w-full text-sm text-depth-500 underline">
Cancel
</button>
</form>
)}
</div>
</main>
);
}
+16 -2
View File
@@ -17,6 +17,7 @@ import BranchRankingList from "@/components/admin/BranchRankingList";
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix"; import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
import ReconciliationGrid from "@/components/admin/ReconciliationGrid"; import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
import StockSummaryCard from "@/components/admin/StockSummaryCard"; import StockSummaryCard from "@/components/admin/StockSummaryCard";
import AccountLinks from "@/components/AccountLinks";
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const router = useRouter(); const router = useRouter();
@@ -112,7 +113,8 @@ export default function AdminDashboardPage() {
} }
const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch"; const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch";
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && branchesLoaded && branches.length === 0; // The operator's own business is a placeholder; don't nudge it to set up branches.
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && !me.isPlatformAdmin && branchesLoaded && branches.length === 0;
return ( return (
<main className="min-h-screen bg-sand-50 pb-12"> <main className="min-h-screen bg-sand-50 pb-12">
@@ -129,6 +131,13 @@ export default function AdminDashboardPage() {
? `${me.branchName} Dashboard` ? `${me.branchName} Dashboard`
: "Branch Dashboard"} : "Branch Dashboard"}
</h1> </h1>
<p className="mt-1 text-xs text-sand-200/60">
{me.name}
{me.isPlatformAdmin && " · Platform operator"}
</p>
<div className="mt-2">
<AccountLinks />
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<GlobalSwitcher <GlobalSwitcher
@@ -137,7 +146,7 @@ export default function AdminDashboardPage() {
onChange={setSelectedBranchId} onChange={setSelectedBranchId}
isSuperAdmin={me.role === "SUPER_ADMIN"} isSuperAdmin={me.role === "SUPER_ADMIN"}
/> />
{me.isPlatformAdmin && ( {me.isPlatformAdmin && (
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90"> <Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
Platform Platform
</Link> </Link>
@@ -145,6 +154,11 @@ export default function AdminDashboardPage() {
<Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"> <Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Stock Stock
</Link> </Link>
{me.role === "SUPER_ADMIN" && (
<Link href="/admin/branches" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Branches
</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">
Products Products
</Link> </Link>
+15 -4
View File
@@ -18,6 +18,7 @@ interface ProfitReport {
unitsSold: number; unitsSold: number;
costOfGoodsSold: number; costOfGoodsSold: number;
unitsWithoutCost: number; unitsWithoutCost: number;
stockLosses: number;
grossProfit: number; grossProfit: number;
expenses: number; expenses: number;
expenseCount: number; expenseCount: number;
@@ -121,8 +122,9 @@ export default function ReportsPage() {
[" M-Pesa", report.mpesaTotal], [" M-Pesa", report.mpesaTotal],
["Sales count", report.salesCount], ["Sales count", report.salesCount],
["Units sold", report.unitsSold], ["Units sold", report.unitsSold],
["Cost of goods sold (average purchase cost)", report.costOfGoodsSold], ["Cost of goods sold (FIFO per branch)", report.costOfGoodsSold],
["Units sold with no purchase cost on record", report.unitsWithoutCost], ["Units sold with no purchase cost on record", report.unitsWithoutCost],
["Stock losses (counts and write-offs)", report.stockLosses],
["Gross profit", report.grossProfit], ["Gross profit", report.grossProfit],
["Expenses", report.expenses], ["Expenses", report.expenses],
["Estimated net profit", report.netProfit], ["Estimated net profit", report.netProfit],
@@ -248,7 +250,7 @@ export default function ReportsPage() {
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</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.costOfGoodsSold)}</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"> <p className="mt-1 text-xs text-depth-500">
{report.unitsSold} units sold x average buying price {report.unitsSold} units sold, each at what its branch paid for its oldest stock (first in, first out)
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`} {report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
</p> </p>
</div> </div>
@@ -272,11 +274,11 @@ export default function ReportsPage() {
> >
{money(report.netProfit)} {money(report.netProfit)}
</p> </p>
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses</p> <p className="mt-1 text-xs text-depth-500">Gross profit − expenses − stock losses</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<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">Stock purchased</p> <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 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
@@ -290,6 +292,15 @@ export default function ReportsPage() {
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</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> <p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
</div> </div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Stock losses</p>
<p className={"mt-1 font-display text-2xl font-bold " + (report.stockLosses > 0 ? "text-alert-600" : "text-depth-900")}>
{money(report.stockLosses)}
</p>
<p className="mt-1 text-xs text-depth-500">
Cost of stock written off by counts, breakage or adjustments. Deducted in net profit.
</p>
</div>
</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">
+61 -15
View File
@@ -46,7 +46,7 @@ export default function StaffPage() {
// Inviting is the default: the person sets their own password, so the // Inviting is the default: the person sets their own password, so the
// owner never has to know or pass on anyone's password. // owner never has to know or pass on anyone's password.
const [setPasswordNow, setSetPasswordNow] = useState(false); const [setPasswordNow, setSetPasswordNow] = useState(false);
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null); const [inviteLink, setInviteLink] = useState<{ name: string; url: string; purpose: "invite" | "reset" } | null>(null);
const isSuperAdmin = me?.role === "SUPER_ADMIN"; const isSuperAdmin = me?.role === "SUPER_ADMIN";
@@ -99,7 +99,7 @@ export default function StaffPage() {
}), }),
}); });
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); setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl, purpose: "invite" } : null);
setName(""); setName("");
setBranchId(""); setBranchId("");
setPhoneNumber(""); setPhoneNumber("");
@@ -121,13 +121,35 @@ 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) { // A new invite link while the person has no password yet; a password
// reset link once they have one.
async function newPasswordLink(s: StaffMember) {
if (!s.invitePending && !window.confirm(`Create a password reset link for ${s.name}? Their current password keeps working until they use it.`)) {
return;
}
try { try {
const result = await apiFetch<{ inviteUrl: string }>("/api/users/" + s._id + "/invite", { method: "POST" }); const result = await apiFetch<{ inviteUrl: string; purpose: "invite" | "reset" }>("/api/users/" + s._id + "/invite", {
setInviteLink({ name: s.name, url: result.inviteUrl }); method: "POST",
show("New invite link created. The old link no longer works.", "success"); });
setInviteLink({ name: s.name, url: result.inviteUrl, purpose: result.purpose });
show(result.purpose === "reset" ? "Reset link created" : "New invite link created. The old link no longer works.", "success");
} catch (err) { } catch (err) {
show(err instanceof ApiError ? err.message : "Could not create an invite link", "error"); show(err instanceof ApiError ? err.message : "Could not create a link", "error");
}
}
// Owner only: move a manager or cashier to another branch. They are signed
// out and sign in again; sales already recorded stay with the old branch.
async function moveToBranch(s: StaffMember, branchId: string) {
const to = branches.find((b) => b._id === branchId);
if (!to || branchId === s.branchId) return;
if (!window.confirm(`Move ${s.name} to ${to.branchName}? They will be signed out and must sign in again.`)) return;
try {
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ branchId }) });
show(`${s.name} moved to ${to.branchName}`, "success");
await loadStaff();
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not move this person", "error");
} }
} }
@@ -219,8 +241,15 @@ export default function StaffPage() {
{inviteLink && ( {inviteLink && (
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800"> <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="font-medium">
<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> {inviteLink.purpose === "reset"
? `Send this password reset link to ${inviteLink.name}.`
: `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 {inviteLink.purpose === "reset" ? "24 hours" : "7 days"}. Anyone with the link can
take over the account, so send it only to them.
</p>
<input <input
readOnly readOnly
value={inviteLink.url} value={inviteLink.url}
@@ -233,7 +262,7 @@ export default function StaffPage() {
Copy link Copy link
</button> </button>
<a <a
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`} href={`https://wa.me/?text=${encodeURIComponent(`${inviteLink.purpose === "reset" ? "Reset" : "Set"} your Vessel password here: ${inviteLink.url}`)}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700" className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
@@ -262,7 +291,7 @@ export default function StaffPage() {
{staff.map((s) => { {staff.map((s) => {
const branchName = branches.find((b) => b._id === s.branchId)?.branchName; const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
return ( return (
<li key={s._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up"> <li key={s._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
<div className={s.isActive ? "" : "opacity-50"}> <div className={s.isActive ? "" : "opacity-50"}>
<p className="font-medium text-depth-900"> <p className="font-medium text-depth-900">
{s.name} {s.name}
@@ -276,16 +305,33 @@ export default function StaffPage() {
{branchName ? " · " + branchName : ""} {branchName ? " · " + branchName : ""}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<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 && ( {isSuperAdmin && canManage(s) && s.isActive && s.role !== "SUPER_ADMIN" && branches.length > 1 && (
<select
value=""
onChange={(e) => moveToBranch(s, e.target.value)}
aria-label={`Move ${s.name} to another branch`}
className="rounded-lg border border-depth-200 bg-white px-2 py-1 text-xs text-depth-700"
>
<option value="">Move to…</option>
{branches
.filter((b) => b._id !== s.branchId)
.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName}
</option>
))}
</select>
)}
{canManage(s) && s.isActive && (
<button <button
onClick={() => newInviteLink(s)} onClick={() => newPasswordLink(s)}
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700" className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
> >
New link {s.invitePending ? "New link" : "Reset password"}
</button> </button>
)} )}
{canManage(s) && ( {canManage(s) && (
+8 -3
View File
@@ -9,6 +9,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdmin } from "@/lib/server/platform"; import { isPlatformAdmin } from "@/lib/server/platform";
import { objectId } from "@/lib/server/validation"; import { objectId } from "@/lib/server/validation";
import { crossTenant } from "@/lib/server/tenantGuard";
import { getEnv } from "@/lib/server/env"; import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription"; import { tenantAccessError } from "@/lib/server/subscription";
@@ -54,7 +55,9 @@ export async function POST(req: NextRequest) {
// several businesses, each with its own password. // several businesses, each with its own password.
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true }; const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
if (businessId) filter.businessId = businessId; if (businessId) filter.businessId = businessId;
const candidates = await User.find(filter).select("+passwordHash"); const candidates = await crossTenant("login looks an email up in every business", () =>
User.find(filter).select("+passwordHash").exec()
);
if (candidates.length === 0) { if (candidates.length === 0) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 }); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
} }
@@ -149,7 +152,7 @@ export async function POST(req: NextRequest) {
deviceId, deviceId,
}); });
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null; const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
const business = await Business.findById(user.businessId).lean(); const business = await Business.findById(user.businessId).lean();
const res = NextResponse.json({ const res = NextResponse.json({
@@ -164,7 +167,9 @@ export async function POST(req: NextRequest) {
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
}, },
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard", // Platform operators start on the console for every business, not on
// their own (placeholder) business's dashboard.
redirectTo: isPlatformAdmin(user) ? "/platform" : user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
isPlatformAdmin: isPlatformAdmin(user), isPlatformAdmin: isPlatformAdmin(user),
}); });
+2 -2
View File
@@ -16,11 +16,11 @@ export async function GET(req: NextRequest) {
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
const user = await User.findById(auth.sub).lean(); const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).lean();
if (!user || !user.isActive) { if (!user || !user.isActive) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
} }
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null; const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
const business = await Business.findById(user.businessId).lean(); const business = await Business.findById(user.businessId).lean();
const tier = (business?.tier as TierId) ?? "STARTER"; const tier = (business?.tier as TierId) ?? "STARTER";
+45
View File
@@ -0,0 +1,45 @@
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 { UserSession } from "@/lib/server/models/UserSession";
import { authorize } from "@/lib/server/auth";
export const runtime = "nodejs";
const changeSchema = z.object({
currentPassword: z.string().min(1, "Enter your current password"),
newPassword: z.string().min(8, "The new password must be at least 8 characters"),
});
/**
* POST /api/auth/password — the signed-in user changes their own password.
* Every other device is signed out; this one stays signed in.
*/
export async function POST(req: NextRequest) {
await connectDb();
const gate = authorize(req);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = changeSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { currentPassword, newPassword } = parsed.data;
if (currentPassword === newPassword) {
return NextResponse.json({ error: "Choose a password different from the current one" }, { status: 400 });
}
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).select("+passwordHash");
if (!user || !user.isActive) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
if (!(await user.comparePassword(currentPassword))) {
return NextResponse.json({ error: "Your current password is not correct" }, { status: 400 });
}
user.passwordHash = await bcrypt.hash(newPassword, 12);
await user.save();
await UserSession.deleteMany({ userId: user._id, deviceId: { $ne: auth.deviceId } });
return NextResponse.json({ ok: true });
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession"; import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { tenantAccessError } from "@/lib/server/subscription"; import { tenantAccessError } from "@/lib/server/subscription";
import { crossTenant } from "@/lib/server/tenantGuard";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -24,7 +25,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 }); return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
} }
const user = await User.findById(session.userId); const user = await crossTenant("a refresh session identifies its user before the business is known", () =>
User.findById(session.userId).exec()
);
if (!user || !user.isActive) { if (!user || !user.isActive) {
await UserSession.deleteOne({ _id: session._id }); await UserSession.deleteOne({ _id: session._id });
return NextResponse.json({ error: "Account no longer active" }, { status: 401 }); return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
+1 -1
View File
@@ -94,7 +94,7 @@ export async function POST(req: NextRequest) {
await owner.save(); await owner.save();
} catch (e) { } catch (e) {
await Business.deleteOne({ _id: businessId }).catch(() => {}); await Business.deleteOne({ _id: businessId }).catch(() => {});
await User.deleteOne({ _id: ownerId }).catch(() => {}); await User.deleteOne({ _id: ownerId, businessId }).catch(() => {});
const msg = (e as Error).message || ""; const msg = (e as Error).message || "";
if (msg.includes("duplicate key")) { if (msg.includes("duplicate key")) {
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 }); return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
+1 -1
View File
@@ -106,7 +106,7 @@ export async function PUT(req: NextRequest) {
} }
const row = await BranchPricing.findOneAndUpdate( const row = await BranchPricing.findOneAndUpdate(
{ branchId, productId }, { businessId: auth.businessId, branchId, productId },
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub }, { businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
).lean(); ).lean();
+1 -1
View File
@@ -106,7 +106,7 @@ export async function POST(req: NextRequest) {
{ status: 201 } { status: 201 }
); );
} catch (err: any) { } catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {}); await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
if (err?.code === 11000) { if (err?.code === 11000) {
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 }); return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
} }
+3 -2
View File
@@ -5,6 +5,7 @@ import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { objectId } from "@/lib/server/validation"; import { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
} }
const expenses = await Expense.find(filter) const expenses = await Expense.find(filter)
.populate("branchId", "branchName") .populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate("recordedBy", "name") .populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(500) .limit(500)
.lean(); .lean();
+1 -1
View File
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
const date = parsed.data.date ?? todayNairobi(); const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId }, { morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
+1 -1
View File
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
const date = parsed.data.date ?? todayNairobi(); const date = parsed.data.date ?? todayNairobi();
const row = await DailyInventory.findOneAndUpdate( const row = await DailyInventory.findOneAndUpdate(
{ branchId, productId: parsed.data.productId, date }, { businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId }, { eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
{ upsert: true, new: true, setDefaultsOnInsert: true } { upsert: true, new: true, setDefaultsOnInsert: true }
); );
+15 -2
View File
@@ -4,14 +4,18 @@ import bcrypt from "bcryptjs";
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 { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { UserSession } from "@/lib/server/models/UserSession";
import { hashInviteToken } from "@/lib/server/invites"; import { hashInviteToken } from "@/lib/server/invites";
import { crossTenant } from "@/lib/server/tenantGuard";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
async function findInvitee(token: string) { async function findInvitee(token: string) {
if (!token || token.length > 100) return null; if (!token || token.length > 100) return null;
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true }); const user = await crossTenant("an invite token identifies its account before sign-in", () =>
User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true }).exec()
);
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null; if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
return user; return user;
} }
@@ -24,7 +28,12 @@ export async function GET(_req: NextRequest, { params }: { params: { token: stri
const user = await findInvitee(params.token); const user = await findInvitee(params.token);
if (!user) return NextResponse.json(INVALID, { status: 404 }); if (!user) return NextResponse.json(INVALID, { status: 404 });
const business = await Business.findById(user.businessId).select("name").lean(); const business = await Business.findById(user.businessId).select("name").lean();
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null }); return NextResponse.json({
name: user.name,
email: user.email,
businessName: business?.name ?? null,
purpose: user.invitePurpose === "reset" ? "reset" : "invite",
});
} }
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") }); const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
@@ -42,8 +51,12 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri
user.passwordHash = await bcrypt.hash(parsed.data.password, 12); user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
user.inviteTokenHash = null; user.inviteTokenHash = null;
user.inviteExpiresAt = null; user.inviteExpiresAt = null;
user.invitePurpose = null;
user.failedLoginAttempts = 0; user.failedLoginAttempts = 0;
user.lockedUntil = null; user.lockedUntil = null;
await user.save(); await user.save();
// A new password ends every existing session, so whoever had the old one
// (a lost phone, a former colleague) is signed out.
await UserSession.deleteMany({ userId: user._id });
return NextResponse.json({ ok: true, email: user.email }); return NextResponse.json({ ok: true, email: user.email });
} }
+4 -1
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env"; import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { recordSaleMovement } from "@/lib/server/stock"; import { recordSaleMovement } from "@/lib/server/stock";
import { crossTenant } from "@/lib/server/tenantGuard";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -69,7 +70,9 @@ export async function POST(req: NextRequest) {
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null; mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
} }
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId }); const txn = await crossTenant("the payment provider's callback is matched by checkout id", () =>
Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId }).exec()
);
if (!txn) { if (!txn) {
await MpesaCallbackLog.create({ await MpesaCallbackLog.create({
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId); const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
const code = Number(result.ResultCode); const code = Number(result.ResultCode);
if (!Number.isNaN(code) && code !== 1032) { if (!Number.isNaN(code) && code !== 1032) {
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" }); const fresh = await Transaction.findOne({ _id: txn._id, businessId: auth.businessId, paymentStatus: "PENDING" });
if (fresh) { if (fresh) {
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED"; fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
fresh.mpesaDetails.resultCode = code; fresh.mpesaDetails.resultCode = code;
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { isValidObjectId } from "mongoose";
import { connectDb } from "@/lib/server/db";
import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { invitePending, issueInvite } from "@/lib/server/invites";
export const runtime = "nodejs";
/**
* POST /api/platform/businesses/:id/owner-reset — operator only. A one-time
* password link for a business's owner of record: the way back in for an
* owner who has forgotten their password (nobody inside the business can
* reset the owner). Send the link to the owner yourself, by a channel you
* know is theirs. Their current password keeps working until it is used.
*/
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb();
const admin = await requirePlatformAdmin(req);
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Business not found" }, { status: 404 });
const business = await Business.findById(params.id).select("ownerId").lean();
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
const owner = await User.findOne({ _id: business.ownerId, businessId: business._id });
if (!owner) return NextResponse.json({ error: "This business has no owner account" }, { status: 404 });
if (owner._id.toString() === admin.userId) {
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
}
if (!owner.isActive) return NextResponse.json({ error: "The owner's account is deactivated" }, { status: 409 });
const purpose = invitePending(owner) ? "invite" : "reset";
const inviteUrl = issueInvite(owner, req, purpose);
await owner.save();
return NextResponse.json({ inviteUrl, purpose, email: owner.email, name: owner.name, expiresAt: owner.inviteExpiresAt });
}
+12 -8
View File
@@ -4,6 +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 { crossTenant } from "@/lib/server/tenantGuard";
import { TIERS, effectiveMaxBranches, 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";
@@ -23,23 +24,26 @@ export async function GET(req: NextRequest) {
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean(); const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
// Usage counts per business in two grouped queries instead of N+1. // Usage counts per business in two grouped queries instead of N+1.
const [userCounts, branchCounts] = await Promise.all([ const [userCounts, branchCounts] = await crossTenant("the platform console counts every business", () =>
User.aggregate<{ _id: unknown; count: number }>([ Promise.all([
{ $group: { _id: "$businessId", count: { $sum: 1 } } }, User.aggregate<{ _id: unknown; count: number }>([{ $group: { _id: "$businessId", count: { $sum: 1 } } }]).exec(),
]),
Branch.aggregate<{ _id: unknown; count: number }>([ Branch.aggregate<{ _id: unknown; count: number }>([
{ $match: { isActive: { $ne: false } } }, { $match: { isActive: { $ne: false } } },
{ $group: { _id: "$businessId", count: { $sum: 1 } } }, { $group: { _id: "$businessId", count: { $sum: 1 } } },
]), ]).exec(),
]); ])
);
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count])); const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count])); const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
// Owner contact lookup. // Owner contact lookup.
const ownerIds = businesses.map((b) => b.ownerId); const ownerIds = businesses.map((b) => b.ownerId);
const owners = await User.find({ _id: { $in: ownerIds } }) const owners = await crossTenant("the platform console lists every business's owner", () =>
User.find({ _id: { $in: ownerIds } })
.select("name email phoneNumber") .select("name email phoneNumber")
.lean(); .lean()
.exec()
);
const ownerMap = new Map(owners.map((o) => [String(o._id), o])); const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
const rows = businesses.map((b) => { const rows = businesses.map((b) => {
+3 -2
View File
@@ -7,6 +7,7 @@ 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";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
} }
const purchases = await Purchase.find(filter) const purchases = await Purchase.find(filter)
.populate("branchId", "branchName") .populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate("recordedBy", "name") .populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(500) .limit(500)
.lean(); .lean();
+18 -19
View File
@@ -6,6 +6,7 @@ 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 { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay"; import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
import { costOfGoodsSold as fifoCost } from "@/lib/server/costing";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,11 +16,13 @@ export const dynamic = "force-dynamic";
* *
* Management overview, NOT accounting (full accounting is out of the PRD's scope). * Management overview, NOT accounting (full accounting is out of the PRD's scope).
* Revenue SUCCESS sales in the period * Revenue SUCCESS sales in the period
* Cost of goods sold units sold x that product's average purchase cost * Cost of goods sold first-in-first-out per branch: each unit sold costs
* (all purchases up to the end of the period) * what that branch paid for its oldest stock on hand
* (see src/lib/server/costing.ts)
* Gross profit revenue - cost of goods sold * Gross profit revenue - cost of goods sold
* Expenses operating expenses in the period * Expenses operating expenses in the period
* Net profit gross profit - expenses * Stock losses cost of stock written off by counts and adjustments
* Net profit gross profit - expenses - stock losses
* Shown separately, because buying stock is not the same as using it: * Shown separately, because buying stock is not the same as using it:
* Stock purchased cash spent on stock in the period * Stock purchased cash spent on stock in the period
* Cash surplus revenue - stock purchased - expenses * Cash surplus revenue - stock purchased - expenses
@@ -47,7 +50,7 @@ export async function GET(req: NextRequest) {
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null; const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) }; const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([ const [salesByProduct, cost, purchaseAgg, expenseAgg] = await Promise.all([
Transaction.aggregate<{ Transaction.aggregate<{
_id: Types.ObjectId; _id: Types.ObjectId;
revenue: number; revenue: number;
@@ -68,12 +71,7 @@ export async function GET(req: NextRequest) {
}, },
}, },
]), ]),
// Average buying price per product across the business (suppliers fifoCost(auth.businessId, { from, to, branchId }),
// charge every branch alike), using everything bought up to period end.
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" } } },
]),
Purchase.aggregate<{ total: number; count: number }>([ Purchase.aggregate<{ total: number; count: number }>([
{ $match: inPeriod }, { $match: inPeriod },
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } }, { $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
@@ -84,19 +82,18 @@ export async function GET(req: NextRequest) {
]), ]),
]); ]);
const avgCost = new Map(avgCosts.filter((c) => c.qty > 0).map((c) => [c._id.toString(), c.cost / c.qty])); let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0;
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0, unitsWithoutCost = 0, costOfGoodsSold = 0;
for (const s of salesByProduct) { for (const s of salesByProduct) {
revenue += s.revenue; revenue += s.revenue;
cashTotal += s.cashTotal; cashTotal += s.cashTotal;
mpesaTotal += s.mpesaTotal; mpesaTotal += s.mpesaTotal;
salesCount += s.lines; salesCount += s.lines;
unitsSold += s.units; 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 costOfGoodsSold = cost.costOfGoodsSold;
// Sales recorded before the stock ledger existed have no stock movement,
// so FIFO cannot cost them: count them as uncosted too.
const unitsWithoutCost = Math.max(cost.unitsWithoutCost, unitsSold - cost.unitsCosted);
const stockPurchases = purchaseAgg[0]?.total ?? 0; const stockPurchases = purchaseAgg[0]?.total ?? 0;
const expenses = expenseAgg[0]?.total ?? 0; const expenses = expenseAgg[0]?.total ?? 0;
@@ -111,13 +108,15 @@ export async function GET(req: NextRequest) {
salesCount, salesCount,
unitsSold, unitsSold,
costOfGoodsSold, costOfGoodsSold,
// Units sold of products never bought through Purchases: their cost costMethod: "FIFO per branch",
// is unknown, so gross profit overstates by their cost. // Units sold whose cost is unknown (no purchase price for that stock on
// record): gross profit overstates by their cost.
unitsWithoutCost, unitsWithoutCost,
stockLosses: cost.stockLosses,
grossProfit, grossProfit,
expenses, expenses,
expenseCount: expenseAgg[0]?.count ?? 0, expenseCount: expenseAgg[0]?.count ?? 0,
netProfit: Math.round((grossProfit - expenses) * 100) / 100, netProfit: Math.round((grossProfit - expenses - cost.stockLosses) * 100) / 100,
stockPurchases, stockPurchases,
purchaseCount: purchaseAgg[0]?.count ?? 0, purchaseCount: purchaseAgg[0]?.count ?? 0,
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100, cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
+1 -1
View File
@@ -64,7 +64,7 @@ export async function POST(req: NextRequest) {
const lockedBranch = branchId; const lockedBranch = branchId;
try { try {
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => { const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId }); const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
if (!already) { if (!already) {
const before = await stockOf(auth.businessId, lockedBranch, data.productId); const before = await stockOf(auth.businessId, lockedBranch, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity; const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { StockMovement } from "@/lib/server/models/StockMovement"; import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -23,9 +24,9 @@ export async function GET(req: NextRequest) {
const movements = await StockMovement.find(filter) const movements = await StockMovement.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(limit) .limit(limit)
.populate("branchId", "branchName") .populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate("productId", "label unit") .populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate("recordedBy", "name") .populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean(); .lean();
return NextResponse.json({ movements }); return NextResponse.json({ movements });
} }
+5 -4
View File
@@ -5,6 +5,7 @@ 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";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { objectId } from "@/lib/server/validation"; import { objectId } from "@/lib/server/validation";
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock"; import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
@@ -27,10 +28,10 @@ export async function GET(req: NextRequest) {
const transfers = await StockTransfer.find(filter) const transfers = await StockTransfer.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(100) .limit(100)
.populate("fromBranchId", "branchName") .populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
.populate("toBranchId", "branchName") .populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
.populate("productId", "label unit") .populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate("recordedBy", "name") .populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean(); .lean();
return NextResponse.json({ transfers }); return NextResponse.json({ transfers });
} }
+2 -4
View File
@@ -167,10 +167,8 @@ export async function POST(req: NextRequest) {
} catch (err: unknown) { } catch (err: unknown) {
if ((err as { code?: number })?.code !== 11000) throw err; if ((err as { code?: number })?.code !== 11000) throw err;
// Already recorded by an earlier attempt: reuse it. // Already recorded by an earlier attempt: reuse it.
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` }); const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
if (!existing || existing.businessId.toString() !== auth.businessId) { if (!existing) return reject("This sale id is already in use.");
return reject("This sale id is already in use.");
}
txn = existing; txn = existing;
} }
// Idempotent, and also repairs a line whose stock movement was not // Idempotent, and also repairs a line whose stock movement was not
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db"; import { connectDb } from "@/lib/server/db";
import { Transaction } from "@/lib/server/models/Transaction"; import { Transaction } from "@/lib/server/models/Transaction";
import { authorize, branchParam } from "@/lib/server/auth"; import { authorize, branchParam } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { startOfTodayNairobi } from "@/lib/server/businessDay"; import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
const transactions = await Transaction.find(filter) const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
.limit(limit) .limit(limit)
.populate("branchId", "branchName") .populate(scopedPopulate("branchId", "branchName", auth.businessId))
.lean(); .lean();
return NextResponse.json({ transactions }); return NextResponse.json({ transactions });
+16 -9
View File
@@ -3,14 +3,17 @@ 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 { authorize, MANAGERS } from "@/lib/server/auth"; import { authorize, MANAGERS } from "@/lib/server/auth";
import { issueInvite } from "@/lib/server/invites"; import { invitePending, issueInvite } from "@/lib/server/invites";
export const runtime = "nodejs"; export const runtime = "nodejs";
/** /**
* POST /api/users/:id/invite — a new invite link for someone who has not * POST /api/users/:id/invite — a one-time password link for a staff member:
* accepted theirs yet (lost or expired). The previous link stops working. * - an INVITE link while they have not set a password yet (lost or expired invite);
* Owners: anyone in the business. Managers: their own branch's cashiers. * - a RESET link once they have (forgotten password). Their current password
* keeps working until the link is used; using it signs them out everywhere.
* The previous link stops working either way.
* Owners: anyone in the business but themselves. Managers: their own branch's cashiers.
*/ */
export async function POST(req: NextRequest, { params }: { params: { id: string } }) { export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
await connectDb(); await connectDb();
@@ -23,13 +26,17 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 }); if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) { 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 }); return NextResponse.json({ error: "Branch managers may only do this for delivery staff in their own branch" }, { status: 403 });
} }
if (!user.inviteExpiresAt) { if (user._id.toString() === auth.sub) {
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 }); return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
}
if (!user.isActive) {
return NextResponse.json({ error: "Reactivate this account first" }, { status: 409 });
} }
const inviteUrl = issueInvite(user, req); const purpose = invitePending(user) ? "invite" : "reset";
const inviteUrl = issueInvite(user, req, purpose);
await user.save(); await user.save();
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt }); return NextResponse.json({ inviteUrl, purpose, expiresAt: user.inviteExpiresAt });
} }
+7
View File
@@ -69,7 +69,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
} }
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).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 });
const fromBranch = target.branchId;
target.branchId = branch._id; target.branchId = branch._id;
// A moved manager stops being the old branch's manager, and becomes the
// new branch's manager if it has none.
if (target.role === "BRANCH_MANAGER" && fromBranch?.toString() !== branch._id.toString()) {
await Branch.updateOne({ _id: fromBranch, businessId: auth.businessId, managerId: target._id }, { managerId: null });
await Branch.updateOne({ _id: branch._id, businessId: auth.businessId, managerId: null }, { managerId: target._id });
}
} }
if (isActive !== undefined) target.isActive = isActive; if (isActive !== undefined) target.isActive = isActive;
await target.save(); await target.save();
+2 -2
View File
@@ -8,7 +8,7 @@ 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"; import { invitePending, issueInvite, unusablePasswordHash } from "@/lib/server/invites";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean(); const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
// invitePending: the person has not set their password yet. // invitePending: the person has not set their password yet.
return NextResponse.json({ return NextResponse.json({
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })), users: users.map((u) => ({ ...u, invitePending: invitePending(u) })),
}); });
} }
+6 -2
View File
@@ -8,6 +8,7 @@ interface Invite {
name: string; name: string;
email: string; email: string;
businessName: string | null; businessName: string | null;
purpose: "invite" | "reset";
} }
const inputClass = const inputClass =
@@ -60,9 +61,12 @@ export default function InvitePage() {
) : ( ) : (
<> <>
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p> <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> <h1 className="mt-1 font-display text-2xl font-bold text-depth-900">
{invite.purpose === "reset" ? `Reset your password, ${invite.name}` : `Welcome, ${invite.name}`}
</h1>
<p className="mt-2 text-sm text-depth-600"> <p className="mt-2 text-sm text-depth-600">
Choose a password. You will sign in with <strong>{invite.email}</strong> and this password. {invite.purpose === "reset" ? "Choose a new password. Every device signed in to your account will be signed out." : "Choose a password."}{" "}
You will sign in with <strong>{invite.email}</strong> and this password.
</p> </p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4"> <form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div> <div>
+10 -1
View File
@@ -36,6 +36,7 @@ export default function LoginPage() {
// Set when this email and password open accounts in more than one business. // Set when this email and password open accounts in more than one business.
const [choices, setChoices] = useState<BusinessChoice[] | null>(null); const [choices, setChoices] = useState<BusinessChoice[] | null>(null);
const [invited, setInvited] = useState(false); const [invited, setInvited] = useState(false);
const [showForgot, setShowForgot] = useState(false);
useEffect(() => { useEffect(() => {
setInvited(new URLSearchParams(window.location.search).get("invited") === "1"); setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
@@ -226,9 +227,17 @@ export default function LoginPage() {
/> />
Remember me Remember me
</label> </label>
<span className="font-medium text-flow-600">Forgot password?</span> <button type="button" onClick={() => setShowForgot((v) => !v)} className="font-medium text-flow-600">
Forgot password?
</button>
</div> </div>
{showForgot && (
<p className="rounded-lg bg-sand-100 px-3 py-2 text-sm text-depth-700">
Ask your branch manager or business owner for a password reset link (Staff page, Reset password). If
you are the owner, contact Digital Chances support for one.
</p>
)}
{invited && !error && ( {invited && !error && (
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600"> <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. Your password is set. Sign in with your email and new password.
+3 -1
View File
@@ -12,7 +12,9 @@ export default function RootPage() {
(async () => { (async () => {
try { try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me"); const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") { if (result.user.isPlatformAdmin) {
router.replace("/platform");
} else if (result.user.role === "DELIVERY") {
router.replace("/terminal"); router.replace("/terminal");
} else { } else {
router.replace("/admin/dashboard"); router.replace("/admin/dashboard");
+59
View File
@@ -6,6 +6,7 @@ import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser } from "@/lib/types"; import { AuthUser } from "@/lib/types";
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 AccountLinks from "@/components/AccountLinks";
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE"; type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED"; type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
@@ -63,6 +64,7 @@ export default function PlatformPage() {
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
const [ownerLink, setOwnerLink] = useState<{ business: string; name: string; email: string; url: string } | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -107,6 +109,23 @@ export default function PlatformPage() {
setSavingId(null); setSavingId(null);
} }
// A one-time password link for a business's owner (an owner who forgot
// their password has nobody inside the business who can reset it).
async function ownerReset(b: BizRow) {
if (!window.confirm(`Create a password link for the owner of ${b.name}? Send it only to the owner, by a channel you know is theirs.`)) {
return;
}
try {
const r = await apiFetch<{ inviteUrl: string; email: string; name: string }>(
"/api/platform/businesses/" + b.id + "/owner-reset",
{ method: "POST" }
);
setOwnerLink({ business: b.name, name: r.name, email: r.email, url: r.inviteUrl });
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not create the link", "error");
}
}
if (!ready) { if (!ready) {
return ( return (
<main className="min-h-screen bg-depth-950 pb-12"> <main className="min-h-screen bg-depth-950 pb-12">
@@ -130,6 +149,12 @@ export default function PlatformPage() {
Every business on your platform, across all tenants. Change plans, extend billing periods, Every business on your platform, across all tenants. Change plans, extend billing periods,
and suspend accounts here. and suspend accounts here.
</p> </p>
<div className="mt-3 flex items-center gap-4">
<a href="/admin/dashboard" className="text-sm text-sand-200/70 underline underline-offset-2 hover:text-white">
Your business dashboard
</a>
<AccountLinks />
</div>
</div> </div>
</header> </header>
@@ -144,6 +169,28 @@ export default function PlatformPage() {
</div> </div>
)} )}
{ownerLink && (
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm">
<p className="font-medium text-white">
Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business}
</p>
<p className="mt-1 text-xs text-white/60">
It works once and expires within 24 hours (7 days if they never set a password). Whoever opens it can take
over the account: send it only to the owner, by a channel you know is theirs.
</p>
<input
readOnly
value={ownerLink.url}
onFocus={(e) => e.target.select()}
aria-label="Owner password link"
className="mt-3 w-full rounded-lg border border-white/20 bg-depth-900 px-3 py-2 font-mono text-xs text-white"
/>
<button onClick={() => setOwnerLink(null)} className="mt-2 text-xs text-white/60 underline">
Done
</button>
</div>
)}
{loading ? ( {loading ? (
<SkeletonList rows={4} /> <SkeletonList rows={4} />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
@@ -158,6 +205,7 @@ export default function PlatformPage() {
b={b} b={b}
saving={savingId === b.id} saving={savingId === b.id}
onPatch={patch} onPatch={patch}
onOwnerReset={ownerReset}
/> />
))} ))}
</div> </div>
@@ -180,10 +228,12 @@ function BusinessCard({
b, b,
saving, saving,
onPatch, onPatch,
onOwnerReset,
}: { }: {
b: BizRow; b: BizRow;
saving: boolean; saving: boolean;
onPatch: (id: string, update: Record<string, unknown>, label: string) => void; onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
onOwnerReset: (b: BizRow) => void;
}) { }) {
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches; const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers; const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
@@ -287,6 +337,15 @@ function BusinessCard({
Reactivate Reactivate
</button> </button>
)} )}
{b.owner && (
<button
type="button"
onClick={() => onOwnerReset(b)}
className="tap-target rounded-xl2 border border-white/20 px-4 text-sm font-medium text-white/80"
>
Owner password link
</button>
)}
</div> </div>
</div> </div>
); );
+8 -7
View File
@@ -17,7 +17,8 @@ import {
type QueuedSale, type QueuedSale,
type QueueOwner, type QueueOwner,
} from "@/lib/offlineQueue"; } from "@/lib/offlineQueue";
import { clearSession, loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache"; import { loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
import { signOut } from "@/lib/signOut";
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";
@@ -392,12 +393,7 @@ export default function TerminalPage() {
); );
if (!ok) return; if (!ok) return;
} }
try { await signOut();
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
// Offline: the cookies expire on their own; still clear this device.
}
clearSession();
router.replace("/login"); router.replace("/login");
} }
@@ -451,11 +447,16 @@ export default function TerminalPage() {
> >
{connected ? "● Online" : "● Offline"} {connected ? "● Online" : "● Offline"}
</span> </span>
<div className="flex gap-3">
<button onClick={() => router.push("/account/password")} className="text-xs text-sand-200/60 underline underline-offset-2">
Password
</button>
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2"> <button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
Sign out Sign out
</button> </button>
</div> </div>
</div> </div>
</div>
</header> </header>
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5"> <div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
+26
View File
@@ -0,0 +1,26 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/signOut";
/** Change password + Sign out, for the dark page headers. */
export default function AccountLinks() {
const router = useRouter();
return (
<div className="flex items-center gap-3 whitespace-nowrap text-sm">
<Link href="/account/password" className="text-sand-200/70 underline underline-offset-2 hover:text-white">
Change password
</Link>
<button
onClick={async () => {
await signOut();
router.replace("/login");
}}
className="text-sand-200/70 underline underline-offset-2 hover:text-white"
>
Sign out
</button>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import mongoose, { Types } from "mongoose";
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
import { Purchase } from "@/lib/server/models/Purchase";
/**
* Cost of goods sold, first-in-first-out, per branch.
*
* The stock ledger is replayed in order. Each branch keeps, per product, a
* queue of cost layers (quantity at a unit cost). Stock coming in adds a
* layer; stock going out takes from the oldest layers first:
*
* PURCHASE in, at the purchase's unit cost
* TRANSFER_IN in, at the cost of the layers the sending branch gave up
* ADJUSTMENT + in (opening stock, a count that found more), at an
* estimated cost: the latest purchase price for the product
* at that time, or the first one after it if none yet
* SALE out: its cost is cost of goods sold
* TRANSFER_OUT out: its cost travels with the stock to the other branch
* ADJUSTMENT - out (breakage, a count that found less): its cost is a
* stock loss, reported separately from cost of goods
*
* Stock can go negative (an offline sale recorded before the stock it came
* from). The units sold short wait as a deficit and are costed by the next
* stock that arrives at that branch, which is where they must have come
* from. Units still uncosted at the end are reported as unitsWithoutCost.
*
* The replay reads the whole history, in order, so a sale is costed against
* what was actually on hand when it happened, whatever period is being
* reported; only events inside the period (and branch) are added up. A sale
* made short in the period is costed by stock that arrived later, even
* after the period ended.
*/
type Id = Types.ObjectId | string;
interface Layer {
qty: number;
unitCost: number | null; // null: cost unknown
}
interface Deficit {
qty: number;
kind: "sale" | "loss" | "transfer";
counts: boolean; // inside the reported period and branch
}
interface Position {
layers: Layer[];
deficits: Deficit[];
}
export interface CostOfGoods {
costOfGoodsSold: number;
unitsCosted: number; // units sold in the period that have a cost
unitsWithoutCost: number; // units sold in the period whose cost is unknown
stockLosses: number; // cost of stock written off by counts or adjustments in the period
}
// Process transfers' outgoing side before the incoming side, so the cost is known.
const TYPE_ORDER: Record<MovementType, number> = {
PURCHASE: 0,
ADJUSTMENT: 1,
TRANSFER_OUT: 2,
TRANSFER_IN: 3,
SALE: 4,
};
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Take `qty` units from the front of `layers`; returns what was taken and the shortfall. */
function take(layers: Layer[], qty: number): { taken: Layer[]; short: number } {
const taken: Layer[] = [];
let need = qty;
while (need > 0 && layers.length > 0) {
const head = layers[0];
const n = Math.min(head.qty, need);
taken.push({ qty: n, unitCost: head.unitCost });
head.qty -= n;
need -= n;
if (head.qty === 0) layers.shift();
}
return { taken, short: need };
}
export async function costOfGoodsSold(
businessId: Id,
period: { from: Date; to: Date; branchId?: Id | null }
): Promise<CostOfGoods> {
const biz = new mongoose.Types.ObjectId(businessId.toString());
const branchFilter = period.branchId ? period.branchId.toString() : null;
const [movements, purchases] = await Promise.all([
StockMovement.find({ businessId: biz })
.select("branchId productId type quantity refId createdAt")
.sort({ createdAt: 1, _id: 1 })
.lean(),
Purchase.find({ businessId: biz, productId: { $ne: null } })
.select("productId unitCost createdAt")
.sort({ createdAt: 1 })
.lean(),
]);
movements.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime() || TYPE_ORDER[a.type] - TYPE_ORDER[b.type]
);
const purchaseCost = new Map(purchases.map((p) => [p._id.toString(), p.unitCost]));
const priceHistory = new Map<string, { at: number; unitCost: number }[]>();
for (const p of purchases) {
const key = p.productId!.toString();
if (!priceHistory.has(key)) priceHistory.set(key, []);
priceHistory.get(key)!.push({ at: p.createdAt.getTime(), unitCost: p.unitCost });
}
const estimateCost = (productId: string, at: number): number | null => {
const history = priceHistory.get(productId);
if (!history || history.length === 0) return null;
let latest: number | null = null;
for (const h of history) {
if (h.at <= at) latest = h.unitCost;
else break;
}
return latest ?? history[0].unitCost;
};
const positions = new Map<string, Position>();
const position = (branch: string, product: string) => {
const key = `${branch}:${product}`;
if (!positions.has(key)) positions.set(key, { layers: [], deficits: [] });
return positions.get(key)!;
};
const inTransit = new Map<string, Layer[]>(); // transfer refId -> layers given up by the sender
const result = { costOfGoodsSold: 0, unitsCosted: 0, unitsWithoutCost: 0, stockLosses: 0 };
const charge = (kind: Deficit["kind"], layers: Layer[]) => {
for (const l of layers) {
if (kind === "sale") {
if (l.unitCost === null) result.unitsWithoutCost += l.qty;
else {
result.costOfGoodsSold += l.qty * l.unitCost;
result.unitsCosted += l.qty;
}
} else if (kind === "loss" && l.unitCost !== null) {
result.stockLosses += l.qty * l.unitCost;
}
}
};
for (const m of movements) {
const branch = m.branchId.toString();
const product = m.productId.toString();
const pos = position(branch, product);
const at = m.createdAt.getTime();
const counts =
at >= period.from.getTime() && at < period.to.getTime() && (!branchFilter || branch === branchFilter);
if (m.quantity > 0) {
let incoming: Layer[];
if (m.type === "PURCHASE") incoming = [{ qty: m.quantity, unitCost: purchaseCost.get(m.refId) ?? null }];
else if (m.type === "TRANSFER_IN") incoming = (inTransit.get(m.refId) ?? [{ qty: m.quantity, unitCost: null }]).map((l) => ({ ...l }));
else incoming = [{ qty: m.quantity, unitCost: estimateCost(product, at) }];
// Stock that was already sold short is covered first, at this stock's cost.
while (pos.deficits.length > 0 && incoming.length > 0) {
const d = pos.deficits[0];
const { taken, short } = take(incoming, d.qty);
if (d.counts) charge(d.kind, taken);
d.qty = short;
if (d.qty === 0) pos.deficits.shift();
}
pos.layers.push(...incoming);
} else if (m.quantity < 0) {
const kind: Deficit["kind"] = m.type === "SALE" ? "sale" : m.type === "TRANSFER_OUT" ? "transfer" : "loss";
const { taken, short } = take(pos.layers, -m.quantity);
if (kind === "transfer") {
// The receiving branch inherits this cost; any shortfall arrives with no known cost.
inTransit.set(m.refId, short > 0 ? [...taken, { qty: short, unitCost: null }] : taken);
} else if (counts) {
charge(kind, taken);
}
if (short > 0) pos.deficits.push({ qty: short, kind, counts: counts && kind !== "transfer" });
}
}
// Short sales in the period that no later stock has covered yet.
for (const pos of positions.values()) {
for (const d of pos.deficits) if (d.counts && d.kind === "sale") result.unitsWithoutCost += d.qty;
}
return {
costOfGoodsSold: round2(result.costOfGoodsSold),
unitsCosted: result.unitsCosted,
unitsWithoutCost: result.unitsWithoutCost,
stockLosses: round2(result.stockLosses),
};
}
+14 -4
View File
@@ -4,6 +4,10 @@ import type { NextRequest } from "next/server";
import type { IUser } from "@/lib/server/models/User"; import type { IUser } from "@/lib/server/models/User";
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Reset links are shorter-lived: they can take over an account that is already in use. */
export const RESET_TTL_MS = 24 * 60 * 60 * 1000;
export type LinkPurpose = "invite" | "reset";
export function hashInviteToken(token: string): string { export function hashInviteToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex"); return crypto.createHash("sha256").update(token).digest("hex");
@@ -14,14 +18,20 @@ export async function unusablePasswordHash(): Promise<string> {
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12); return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
} }
/** Whether the account is waiting for its first password (not a reset). */
export function invitePending(user: Pick<IUser, "inviteExpiresAt" | "invitePurpose">): boolean {
return !!user.inviteExpiresAt && user.invitePurpose !== "reset";
}
/** /**
* Give the user a fresh invite (any earlier link stops working) and return * Give the user a fresh one-time password link (any earlier link stops
* the link to send them. The caller saves the user. * working) and return it to send them. The caller saves the user.
*/ */
export function issueInvite(user: IUser, req: NextRequest): string { export function issueInvite(user: IUser, req: NextRequest, purpose: LinkPurpose = "invite"): string {
const token = crypto.randomBytes(32).toString("base64url"); const token = crypto.randomBytes(32).toString("base64url");
user.inviteTokenHash = hashInviteToken(token); user.inviteTokenHash = hashInviteToken(token);
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS); user.inviteExpiresAt = new Date(Date.now() + (purpose === "reset" ? RESET_TTL_MS : INVITE_TTL_MS));
user.invitePurpose = purpose;
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin; const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
return `${origin}/invite/${token}`; return `${origin}/invite/${token}`;
} }
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IBranch extends Document { export interface IBranch extends Document {
@@ -30,5 +31,7 @@ const branchSchema = new Schema<IBranch>(
/** Filter for branches that are in use. */ /** Filter for branches that are in use. */
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const; export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
branchSchema.plugin(tenantGuard);
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
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IDailyInventory extends Document { export interface IDailyInventory extends Document {
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
// One ledger row per branch+product+day. // One ledger row per branch+product+day.
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true }); dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
dailyInventorySchema.plugin(tenantGuard);
export const DailyInventory = export const DailyInventory =
(models.DailyInventory as Model<IDailyInventory>) || (models.DailyInventory as Model<IDailyInventory>) ||
model<IDailyInventory>("DailyInventory", dailyInventorySchema); model<IDailyInventory>("DailyInventory", dailyInventorySchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
// Business expense categories. Kept as a constrained string set so the UI // Business expense categories. Kept as a constrained string set so the UI
@@ -46,5 +47,7 @@ expenseSchema.index({ category: 1 });
// `models.Expense` guard avoids OverwriteModelError on serverless warm // `models.Expense` guard avoids OverwriteModelError on serverless warm
// re-invocations where the module is evaluated more than once. // re-invocations where the module is evaluated more than once.
expenseSchema.plugin(tenantGuard);
export const Expense = export const Expense =
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema); (models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
+5
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IProduct extends Document { export interface IProduct extends Document {
@@ -32,6 +33,8 @@ const productSchema = new Schema<IProduct>(
{ timestamps: true } { timestamps: true }
); );
productSchema.plugin(tenantGuard);
export const Product = export const Product =
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema); (models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
@@ -57,6 +60,8 @@ const branchPricingSchema = new Schema<IBranchPricing>(
); );
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true }); branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
branchPricingSchema.plugin(tenantGuard);
export const BranchPricing = export const BranchPricing =
(models.BranchPricing as Model<IBranchPricing>) || (models.BranchPricing as Model<IBranchPricing>) ||
model<IBranchPricing>("BranchPricing", branchPricingSchema); model<IBranchPricing>("BranchPricing", branchPricingSchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
// A recorded stock purchase from a supplier. Completing a purchase is what // A recorded stock purchase from a supplier. Completing a purchase is what
@@ -40,5 +41,7 @@ const purchaseSchema = new Schema<IPurchase>(
purchaseSchema.index({ branchId: 1, createdAt: -1 }); purchaseSchema.index({ branchId: 1, createdAt: -1 });
purchaseSchema.index({ productId: 1 }); purchaseSchema.index({ productId: 1 });
purchaseSchema.plugin(tenantGuard);
export const Purchase = export const Purchase =
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema); (models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const; export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
@@ -46,6 +47,8 @@ stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 }); stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 }); stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
stockMovementSchema.plugin(tenantGuard);
export const StockMovement = export const StockMovement =
(models.StockMovement as Model<IStockMovement>) || (models.StockMovement as Model<IStockMovement>) ||
model<IStockMovement>("StockMovement", stockMovementSchema); model<IStockMovement>("StockMovement", stockMovementSchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IStockTransfer extends Document { export interface IStockTransfer extends Document {
@@ -35,6 +36,8 @@ const stockTransferSchema = new Schema<IStockTransfer>(
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true }); stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
stockTransferSchema.index({ businessId: 1, createdAt: -1 }); stockTransferSchema.index({ businessId: 1, createdAt: -1 });
stockTransferSchema.plugin(tenantGuard);
export const StockTransfer = export const StockTransfer =
(models.StockTransfer as Model<IStockTransfer>) || (models.StockTransfer as Model<IStockTransfer>) ||
model<IStockTransfer>("StockTransfer", stockTransferSchema); model<IStockTransfer>("StockTransfer", stockTransferSchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
export type PaymentMethod = "CASH" | "MPESA"; export type PaymentMethod = "CASH" | "MPESA";
@@ -105,6 +106,8 @@ transactionSchema.index({ businessId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 }); transactionSchema.index({ paymentStatus: 1 });
transactionSchema.index({ businessId: 1, saleId: 1 }); transactionSchema.index({ businessId: 1, saleId: 1 });
transactionSchema.plugin(tenantGuard);
export const Transaction = export const Transaction =
(models.Transaction as Model<ITransaction>) || (models.Transaction as Model<ITransaction>) ||
model<ITransaction>("Transaction", transactionSchema); model<ITransaction>("Transaction", transactionSchema);
+11 -3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose"; import { Schema, model, models, Model, Types, Document } from "mongoose";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -24,11 +25,15 @@ 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 // A one-time link through which the person sets their own password.
// link. Only the SHA-256 of the token is stored. Until they accept, the // Only the SHA-256 of the token is stored.
// account has a random unknown password, so it cannot be signed into. // invite: a new account; until accepted it has a random unknown password.
// reset: a forgotten password; the old password keeps working until the
// link is used, and using it signs the account out everywhere.
// Rows from before invitePurpose existed have null, which means invite.
inviteTokenHash: string | null; inviteTokenHash: string | null;
inviteExpiresAt: Date | null; inviteExpiresAt: Date | null;
invitePurpose: "invite" | "reset" | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -50,6 +55,7 @@ const userSchema = new Schema<IUser>(
lockedUntil: { type: Date, default: null }, lockedUntil: { type: Date, default: null },
inviteTokenHash: { type: String, default: null, index: { sparse: true } }, inviteTokenHash: { type: String, default: null, index: { sparse: true } },
inviteExpiresAt: { type: Date, default: null }, inviteExpiresAt: { type: Date, default: null },
invitePurpose: { type: String, enum: ["invite", "reset", null], default: null },
}, },
{ timestamps: true } { timestamps: true }
); );
@@ -80,5 +86,7 @@ userSchema.methods.comparePassword = async function (password: string): Promise<
return bcrypt.compare(password, this.passwordHash); return bcrypt.compare(password, this.passwordHash);
}; };
userSchema.plugin(tenantGuard);
export const User = export const User =
(models.User as Model<IUser>) || model<IUser>("User", userSchema); (models.User as Model<IUser>) || model<IUser>("User", userSchema);
+3 -1
View File
@@ -48,7 +48,9 @@ export async function requirePlatformAdmin(
): Promise<{ userId: string; email: string } | null> { ): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return null; if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean(); const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId })
.select("email isActive platformAdmin")
.lean();
if (!user || !user.isActive) return null; if (!user || !user.isActive) return null;
if (!isPlatformAdmin(user)) return null; if (!isPlatformAdmin(user)) return null;
return { userId: auth.sub, email: user.email }; return { userId: auth.sub, email: user.email };
+7 -3
View File
@@ -4,6 +4,7 @@ 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 { Purchase } from "@/lib/server/models/Purchase";
import { StockLock } from "@/lib/server/models/StockLock"; import { StockLock } from "@/lib/server/models/StockLock";
import { crossTenant } from "@/lib/server/tenantGuard";
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;
@@ -26,7 +27,7 @@ export interface MovementInput {
*/ */
export async function recordMovement(m: MovementInput): Promise<void> { export async function recordMovement(m: MovementInput): Promise<void> {
await StockMovement.updateOne( await StockMovement.updateOne(
{ type: m.type, refId: m.refId }, { type: m.type, refId: m.refId, businessId: m.businessId },
{ {
$setOnInsert: { $setOnInsert: {
businessId: m.businessId, businessId: m.businessId,
@@ -155,11 +156,14 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> { export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
const filter: Record<string, unknown> = { productId: { $ne: null } }; const filter: Record<string, unknown> = { productId: { $ne: null } };
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString()); if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
const purchases = businessId
? await Purchase.find(filter).lean()
: await crossTenant("the backfill runs over every business", () => Purchase.find(filter).lean().exec());
let added = 0; let added = 0;
for await (const p of Purchase.find(filter).lean().cursor()) { for (const p of purchases) {
const refId = p._id.toString(); const refId = p._id.toString();
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue; if (await StockMovement.exists({ type: "PURCHASE", refId, businessId: p.businessId })) continue;
await recordMovement({ await recordMovement({
businessId: p.businessId, businessId: p.businessId,
branchId: p.branchId, branchId: p.branchId,
+114
View File
@@ -0,0 +1,114 @@
import { AsyncLocalStorage } from "async_hooks";
import { Types, type MongooseQueryMiddleware, type Schema } from "mongoose";
/**
* Tenant isolation, enforced instead of remembered.
*
* Every model that holds one business's data is guarded: a query against
* it that does not name exactly one business (`businessId: <id>`) throws
* TenantScopeError before it reaches the database. A route that forgets
* the business filter therefore fails loudly in development and in the
* test suite, instead of quietly reading or changing another business's
* data in production.
*
* The few queries that legitimately span businesses (login looking an
* email up everywhere, an invite token, the payment provider's callback,
* the platform console, maintenance scripts) must say so explicitly:
*
* await crossTenant("login looks the email up in every business", () => User.find(...));
*
* Guarded: find, findOne (and findById / exists), findOneAndUpdate /
* Delete / Replace, updateOne / updateMany, replaceOne, deleteOne /
* deleteMany, countDocuments, distinct, and aggregate (its first stage
* must be a $match naming the business). populate() runs a find on the
* referenced model, so populate through `scopedPopulate` below.
*
* Not guarded, by design: Business (the tenant itself, always looked up by
* the id from the session), UserSession (keyed by the signed-in user),
* MpesaCallbackLog (provider audit log), StockLock (key already contains
* the business). Document saves and inserts are covered by `businessId`
* being a required field.
*/
export class TenantScopeError extends Error {
constructor(model: string, op: string) {
super(
`Tenant isolation: ${model}.${op} must filter by a single businessId ` +
`(or run inside crossTenant("why", ...) if it really spans businesses).`
);
this.name = "TenantScopeError";
}
}
const crossTenantReason = new AsyncLocalStorage<string>();
/**
* Run `fn`, whose queries deliberately span businesses. `reason` documents
* why. The result is awaited inside the scope, so a lazy Mongoose query
* returned by `fn` executes while the exemption applies.
*/
export function crossTenant<T>(reason: string, fn: () => PromiseLike<T>): Promise<T> {
if (!reason.trim()) throw new Error("crossTenant needs a reason");
return crossTenantReason.run(reason, async () => await fn());
}
/** True when the filter pins exactly one business. */
export function namesOneBusiness(filter: unknown): boolean {
if (!filter || typeof filter !== "object") return false;
const f = filter as Record<string, unknown>;
const v = f.businessId;
if (typeof v === "string" || v instanceof Types.ObjectId) return true;
if (v && typeof v === "object" && "$eq" in (v as object)) {
const eq = (v as { $eq: unknown }).$eq;
return typeof eq === "string" || eq instanceof Types.ObjectId;
}
return Array.isArray(f.$and) && f.$and.some(namesOneBusiness);
}
const QUERY_OPS: MongooseQueryMiddleware[] = [
"find",
"findOne",
"findOneAndUpdate",
"findOneAndDelete",
"findOneAndReplace",
"updateOne",
"updateMany",
"replaceOne",
"deleteOne",
"deleteMany",
"countDocuments",
"distinct",
];
const guardedSchemas = new WeakSet<Schema>();
/** Whether a schema has the guard (the test suite checks every business-scoped model does). */
export function isTenantGuarded(schema: Schema): boolean {
return guardedSchemas.has(schema);
}
/** Mongoose plugin: apply to every schema with a `businessId` path. */
export function tenantGuard(schema: Schema): void {
guardedSchemas.add(schema);
schema.pre(QUERY_OPS, function (this: any) {
if (crossTenantReason.getStore()) return;
if (!namesOneBusiness(this.getFilter())) throw new TenantScopeError(this.model.modelName, this.op);
});
schema.pre("aggregate", function (this: any) {
if (crossTenantReason.getStore()) return;
const first = this.pipeline()[0];
if (!first || !namesOneBusiness(first.$match)) {
throw new TenantScopeError(this._model?.modelName ?? "Model", "aggregate");
}
});
}
/**
* populate() options that keep the referenced documents inside the
* business too (populate queries the referenced model, which is guarded).
*/
export function scopedPopulate(path: string, select: string, businessId: string | Types.ObjectId) {
return { path, select, match: { businessId } };
}
+16
View File
@@ -0,0 +1,16 @@
import { apiFetch } from "@/lib/api";
import { clearSession } from "@/lib/sessionCache";
/**
* End this device's session and forget the cached terminal session. Works
* offline too: the server session then expires on its own, and this device
* keeps nothing that would sign it back in.
*/
export async function signOut(): Promise<void> {
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
// Offline or already signed out.
}
clearSession();
}
+4 -4
View File
@@ -15,7 +15,7 @@ import * as login from "@/app/api/auth/login/route";
import * as signup from "@/app/api/auth/signup/route"; import * as signup from "@/app/api/auth/signup/route";
import * as stkpush from "@/app/api/mpesa/stkpush/route"; import * as stkpush from "@/app/api/mpesa/stkpush/route";
import * as callback from "@/app/api/mpesa/callback/route"; import * as callback from "@/app/api/mpesa/callback/route";
import { json, makeTenant, params, req, uuid } from "./helpers"; import { json, makeTenant, params, req, uuid, db } from "./helpers";
describe("role-based access on the backend", () => { describe("role-based access on the backend", () => {
it("blocks cashiers from owner-level money endpoints with 403", async () => { it("blocks cashiers from owner-level money endpoints with 403", async () => {
@@ -78,7 +78,7 @@ describe("user deactivation", () => {
params({ id: t.cashierA.id }) params({ id: t.cashierA.id })
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false); expect((await db(() => User.findById(t.cashierA.id)))!.isActive).toBe(false);
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0); expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
}); });
@@ -105,7 +105,7 @@ describe("tenant status", () => {
it("refuses login for a suspended business", async () => { it("refuses login for a suspended business", async () => {
const t = await makeTenant(); const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4); const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.cashierA.id }, { passwordHash }); await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" }); await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
const res = await login.POST( const res = await login.POST(
@@ -119,7 +119,7 @@ describe("tenant status", () => {
it("still allows login for an active business", async () => { it("still allows login for an active business", async () => {
const t = await makeTenant(); const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4); const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.managerA.id }, { passwordHash }); await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
const res = await login.POST( const res = await login.POST(
req("POST", "/api/auth/login", { req("POST", "/api/auth/login", {
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() }, body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import bcrypt from "bcryptjs";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { UserSession } from "@/lib/server/models/UserSession";
import * as invite from "@/app/api/users/[id]/invite/route";
import * as invites from "@/app/api/invites/[token]/route";
import * as users from "@/app/api/users/route";
import * as userById from "@/app/api/users/[id]/route";
import * as password from "@/app/api/auth/password/route";
import * as login from "@/app/api/auth/login/route";
import * as ownerReset from "@/app/api/platform/businesses/[id]/owner-reset/route";
import { db, json, makeTenant, params, req, uuid, type Actor } from "./helpers";
const DEVICE = "00000000-0000-4000-8000-000000000001"; // the device tokenFor() signs in on
async function setPassword(a: Actor, pw: string) {
await db(() => User.updateOne({ _id: a.id }, { passwordHash: bcrypt.hashSync(pw, 4) }));
}
const signIn = (a: Actor, pw: string) =>
login.POST(req("POST", "/x", { body: { email: a.email, password: pw, deviceId: uuid() } }));
const tokenOf = (url: string) => url.split("/invite/")[1];
const sessions = (a: Actor) => db(() => UserSession.countDocuments({ userId: a.id }));
describe("password reset links", () => {
it("lets a manager reset their cashier's forgotten password; the old one works until the link is used", async () => {
const t = await makeTenant();
await setPassword(t.cashierA, "old-password");
await signIn(t.cashierA, "old-password"); // a session on some phone
const r = await json(await invite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierA.id })));
expect(r.status).toBe(200);
expect(r.body.purpose).toBe("reset");
expect((await signIn(t.cashierA, "old-password")).status).toBe(200);
const page = await json(await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) })));
expect(page.body.purpose).toBe("reset");
const accept = await invites.POST(req("POST", "/x", { body: { password: "new-password" } }), params({ token: tokenOf(r.body.inviteUrl) }));
expect(accept.status).toBe(200);
expect(await sessions(t.cashierA)).toBe(0); // every device signed out
expect((await signIn(t.cashierA, "old-password")).status).toBe(401);
expect((await signIn(t.cashierA, "new-password")).status).toBe(200);
});
it("does not show a reset as a pending invite", async () => {
const t = await makeTenant();
await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id }));
const list = await json(await users.GET(req("GET", "/api/users", { as: t.owner })));
expect(list.body.users.find((u: any) => u._id === t.cashierA.id).invitePending).toBe(false);
});
it("expires a reset link after 24 hours", async () => {
const t = await makeTenant();
const r = await json(await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id })));
const user = await db(() => User.findById(t.cashierA.id).lean());
const hours = (user!.inviteExpiresAt!.getTime() - Date.now()) / 3_600_000;
expect(hours).toBeGreaterThan(23.9);
expect(hours).toBeLessThanOrEqual(24);
await db(() => User.updateOne({ _id: t.cashierA.id }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
expect((await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) }))).status).toBe(404);
});
it("keeps managers to their own cashiers, and nobody resets themselves", async () => {
const t = await makeTenant();
const post = (as: Actor, id: string) => invite.POST(req("POST", "/x", { as }), params({ id }));
expect((await post(t.managerA, t.cashierB.id)).status).toBe(403);
expect((await post(t.managerA, t.owner.id)).status).toBe(403);
expect((await post(t.owner, t.owner.id)).status).toBe(409);
expect((await post(t.cashierA, t.cashierB.id)).status).toBe(403);
});
it("lets the platform operator give a business owner a reset link, and nobody else", async () => {
const t = await makeTenant("Customer Co");
const home = await makeTenant("Operator Co");
await db(() => User.updateOne({ _id: home.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
const operator = { ...home.owner, email: "operator@aquaflow.test" };
const ok = await json(await ownerReset.POST(req("POST", "/x", { as: operator }), params({ id: t.businessId })));
expect(ok.status).toBe(200);
expect(ok.body).toMatchObject({ purpose: "reset", email: t.owner.email.toLowerCase() });
expect((await ownerReset.POST(req("POST", "/x", { as: t.owner }), params({ id: t.businessId }))).status).toBe(403);
});
});
describe("changing your own password", () => {
it("needs the current password, then signs out every other device but this one", async () => {
const t = await makeTenant();
await setPassword(t.managerA, "current-pass");
await signIn(t.managerA, "current-pass"); // another device
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: DEVICE, refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
expect(await sessions(t.managerA)).toBe(2);
const change = (currentPassword: string, newPassword: string) =>
password.POST(req("POST", "/x", { as: t.managerA, body: { currentPassword, newPassword } }));
expect((await change("wrong-pass", "brand-new-pass")).status).toBe(400);
expect((await change("current-pass", "short")).status).toBe(400);
expect((await change("current-pass", "brand-new-pass")).status).toBe(200);
expect(await sessions(t.managerA)).toBe(1);
expect(await db(() => UserSession.exists({ userId: t.managerA.id, deviceId: DEVICE }))).toBeTruthy();
expect((await signIn(t.managerA, "brand-new-pass")).status).toBe(200);
});
});
describe("moving staff between branches", () => {
it("moves a manager, updates both branches' manager, and signs them out", async () => {
const t = await makeTenant();
await db(() => Branch.updateOne({ _id: t.branchA }, { managerId: t.managerA.id }));
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: uuid(), refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
const res = await userById.PATCH(req("PATCH", "/x", { as: t.owner, body: { branchId: t.branchB } }), params({ id: t.managerA.id }));
expect(res.status).toBe(200);
const [a, b, mgr] = await Promise.all([
db(() => Branch.findById(t.branchA).lean()),
db(() => Branch.findById(t.branchB).lean()),
db(() => User.findById(t.managerA.id).lean()),
]);
expect(a!.managerId).toBeNull();
expect(b!.managerId!.toString()).toBe(t.managerA.id);
expect(mgr!.branchId!.toString()).toBe(t.branchB);
expect(await sessions(t.managerA)).toBe(0);
});
it("only lets the owner move people, and only to an active branch", async () => {
const t = await makeTenant();
const move = (as: Actor, branchId: string) =>
userById.PATCH(req("PATCH", "/x", { as, body: { branchId } }), params({ id: t.cashierA.id }));
expect((await move(t.managerA, t.branchB)).status).toBe(403);
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
expect((await move(t.owner, t.branchB)).status).toBe(404);
});
});
describe("where sign-in lands", () => {
it("sends the platform operator to the platform console, owners to the dashboard, cashiers to the till", async () => {
const t = await makeTenant();
await Promise.all([setPassword(t.owner, "owner-pass"), setPassword(t.cashierA, "cashier-pass")]);
expect((await json(await signIn(t.owner, "owner-pass"))).body.redirectTo).toBe("/admin/dashboard");
expect((await json(await signIn(t.cashierA, "cashier-pass"))).body.redirectTo).toBe("/terminal");
await db(() => User.updateOne({ _id: t.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
const operator = { ...t.owner, email: "operator@aquaflow.test" };
expect((await json(await signIn(operator, "owner-pass"))).body.redirectTo).toBe("/platform");
});
});
+3 -3
View File
@@ -4,7 +4,7 @@ import { BranchPricing } from "@/lib/server/models/Product";
import * as prices from "@/app/api/branch-prices/route"; import * as prices from "@/app/api/branch-prices/route";
import * as products from "@/app/api/products/route"; import * as products from "@/app/api/products/route";
import * as cash from "@/app/api/transactions/cash/route"; import * as cash from "@/app/api/transactions/cash/route";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) => const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } })); prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
@@ -44,7 +44,7 @@ describe("branch prices", () => {
const t = await makeTenant(); const t = await makeTenant();
await setPrice(t.owner, t.branchA, t.water20, 180); await setPrice(t.owner, t.branchA, t.water20, 180);
await setPrice(t.owner, t.branchA, t.water20, 200); await setPrice(t.owner, t.branchA, t.water20, 200);
expect(await BranchPricing.countDocuments({})).toBe(0); expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
await setPrice(t.owner, t.branchA, t.water5, 55); await setPrice(t.owner, t.branchA, t.water5, 55);
const reset = await prices.DELETE( const reset = await prices.DELETE(
@@ -85,6 +85,6 @@ describe("branch prices", () => {
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404); expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404); expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404);
expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404); expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
expect(await BranchPricing.countDocuments({})).toBe(0); expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
}); });
}); });
+2 -2
View File
@@ -4,7 +4,7 @@ import { StockTransfer } from "@/lib/server/models/StockTransfer";
import { StockLock } from "@/lib/server/models/StockLock"; import { StockLock } from "@/lib/server/models/StockLock";
import * as adjustments from "@/app/api/stock/adjustments/route"; import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route"; import * as transfers from "@/app/api/stock/transfers/route";
import { makeTenant, req, uuid, type Tenant } from "./helpers"; import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
async function openingStock(t: Tenant, quantity: number) { async function openingStock(t: Tenant, quantity: number) {
await adjustments.POST( await adjustments.POST(
@@ -35,7 +35,7 @@ describe("concurrent stock changes", () => {
expect(statuses.filter((s) => s === 409)).toHaveLength(3); expect(statuses.filter((s) => s === 409)).toHaveLength(3);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2); expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8); expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
expect(await StockTransfer.countDocuments({})).toBe(2); expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
}); });
it("holds across repeated races, not just once", async () => { it("holds across repeated races, not just once", async () => {
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { costOfGoodsSold } from "@/lib/server/costing";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase";
import { startOfMonthNairobi } from "@/lib/server/businessDay";
import * as cash from "@/app/api/transactions/cash/route";
import * as purchases from "@/app/api/purchases/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import * as profit from "@/app/api/reports/profit/route";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
const ALL_TIME = { from: new Date(0), to: new Date(Date.now() + 86_400_000) };
const buy = (t: Tenant, branchId: string, quantity: number, unitCost: number, productId = t.water20) =>
purchases.POST(req("POST", "/x", { as: t.owner, body: { branchId, supplierName: "Supplier", productId, quantity, unitCost } }));
const sell = (cashier: Actor, quantity: number, productId: string) =>
cash.POST(req("POST", "/x", { as: cashier, body: { sales: [{ clientUuid: uuid(), lines: [{ productId, quantity }] }] } }));
const adjust = (t: Tenant, mode: "COUNT" | "DELTA", quantity: number) =>
adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode, quantity } })
);
/** Move everything recorded so far to `when` (tests need history in an earlier period). */
async function backdate(t: Tenant, when: Date) {
// The raw collection: Mongoose treats createdAt as immutable.
const filter = { businessId: new mongoose.Types.ObjectId(t.businessId) };
await StockMovement.collection.updateMany(filter, { $set: { createdAt: when } });
await Transaction.collection.updateMany(filter, { $set: { createdAt: when } });
await Purchase.collection.updateMany(filter, { $set: { createdAt: when } });
}
describe("cost of goods sold: FIFO per branch", () => {
it("costs the oldest stock first, not an average", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await buy(t, t.branchA, 10, 20);
await sell(t.cashierA, 15, t.water20);
const c = await costOfGoodsSold(t.businessId, ALL_TIME);
expect(c).toMatchObject({ costOfGoodsSold: 200, unitsCosted: 15, unitsWithoutCost: 0 }); // 10x10 + 5x20 (the average would say 225)
});
it("costs each branch at what that branch paid", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await buy(t, t.branchB, 10, 30);
await sell(t.cashierB, 2, t.water20);
await sell(t.cashierA, 1, t.water20);
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(70);
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchA })).costOfGoodsSold).toBe(10);
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(60);
});
it("carries the sending branch's cost with a transfer", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 5 },
})
);
await buy(t, t.branchB, 5, 30);
await sell(t.cashierB, 6, t.water20);
// B's oldest stock is the 5 transferred at 10, then 1 of its own at 30.
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(80);
});
it("costs a sale made before its stock was recorded by the stock that arrives next", async () => {
const t = await makeTenant();
await sell(t.cashierA, 3, t.water20); // offline sale, nothing on the books yet
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, unitsWithoutCost: 3 });
await buy(t, t.branchA, 10, 12);
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 36, unitsWithoutCost: 0 });
});
it("reports written-off stock as a loss, not as cost of goods sold, and deducts it in net profit", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await adjust(t, "COUNT", 8);
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, stockLosses: 20 });
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({ stockLosses: 20, grossProfit: 0, netProfit: -20 });
});
it("values opening stock at the product's purchase price", async () => {
const t = await makeTenant();
await adjust(t, "DELTA", 10); // opening stock, before any purchase was recorded
await buy(t, t.branchA, 10, 15);
await sell(t.cashierA, 12, t.water20);
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(180);
});
it("counts only the period's sales, costed against what was left from earlier periods", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await sell(t.cashierA, 6, t.water20);
const lastMonth = new Date(startOfMonthNairobi().getTime() - 5 * 86_400_000);
await backdate(t, lastMonth);
await buy(t, t.branchA, 10, 20);
await sell(t.cashierA, 6, t.water20); // 4 left from last month at 10, then 2 at 20
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({ costMethod: "FIFO per branch", unitsSold: 6, costOfGoodsSold: 80, unitsWithoutCost: 0 });
});
});
+11 -11
View File
@@ -19,7 +19,7 @@ import * as profit from "@/app/api/reports/profit/route";
import * as cash from "@/app/api/transactions/cash/route"; import * as cash from "@/app/api/transactions/cash/route";
import * as adjustments from "@/app/api/stock/adjustments/route"; import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route"; import * as transfers from "@/app/api/stock/transfers/route";
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers"; import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) { async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus }); await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
@@ -39,7 +39,7 @@ describe("F-01 trial branch allowance", () => {
it("counts only active branches, and a paid Starter plan allows one", async () => { it("counts only active branches, and a paid Starter plan allows one", async () => {
const t = await makeTenant(); const t = await makeTenant();
await setPlan(t, "STARTER", "TRIALING"); await setPlan(t, "STARTER", "TRIALING");
await Branch.updateOne({ _id: t.branchB }, { isActive: false }); await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201); expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
await setPlan(t, "STARTER", "ACTIVE"); await setPlan(t, "STARTER", "ACTIVE");
@@ -70,7 +70,7 @@ describe("F-06 branch deactivation", () => {
expect(withStaff.status).toBe(409); expect(withStaff.status).toBe(409);
expect((await withStaff.json()).error).toMatch(/active staff/); expect((await withStaff.json()).error).toMatch(/active staff/);
await User.updateOne({ _id: t.cashierB.id }, { isActive: false }); await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
expect((await deactivate()).status).toBe(200); expect((await deactivate()).status).toBe(200);
// Hidden from pickers, kept on the Branches page. // Hidden from pickers, kept on the Branches page.
@@ -82,7 +82,7 @@ describe("F-06 branch deactivation", () => {
it("will not reactivate a branch past the plan's limit", async () => { it("will not reactivate a branch past the plan's limit", async () => {
const t = await makeTenant(); const t = await makeTenant();
await Branch.updateOne({ _id: t.branchB }, { isActive: false }); await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
await setPlan(t, "STARTER", "ACTIVE"); await setPlan(t, "STARTER", "ACTIVE");
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB })); const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
expect(res.status).toBe(403); expect(res.status).toBe(403);
@@ -100,12 +100,12 @@ describe("F-03 report period validation", () => {
}); });
describe("F-07 cost of goods sold", () => { describe("F-07 cost of goods sold", () => {
it("costs only the units sold, at the average buying price, and reports purchases separately", async () => { it("costs only the units sold, first-in-first-out, and reports purchases separately", async () => {
const t = await makeTenant(); const t = await makeTenant();
const buy = (quantity: number, unitCost: number) => const buy = (quantity: number, unitCost: number) =>
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } })); purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
await buy(20, 10); await buy(20, 10);
await buy(10, 13); // average (200 + 130) / 30 = 11 await buy(10, 13);
await cash.POST( await cash.POST(
req("POST", "/x", { req("POST", "/x", {
as: t.cashierA, as: t.cashierA,
@@ -116,9 +116,9 @@ describe("F-07 cost of goods sold", () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
revenue: 660, // 3 x 200 + 1 x 60 revenue: 660, // 3 x 200 + 1 x 60
unitsSold: 4, unitsSold: 4,
costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased costOfGoodsSold: 30, // 3 x 10 from the oldest purchase; the 5L was never purchased
unitsWithoutCost: 1, unitsWithoutCost: 1,
grossProfit: 627, grossProfit: 630,
stockPurchases: 330, stockPurchases: 330,
cashSurplus: 330, cashSurplus: 330,
}); });
@@ -152,7 +152,7 @@ describe("F-04 / F-05 / CAT-12 catalogue", () => {
await adjustments.POST( await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } }) 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 }); await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA }))); 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 }))); const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
@@ -228,13 +228,13 @@ describe("STA-04 staff invites", () => {
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404); 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); 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) }); await db(() => 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); 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 () => { it("does not let a manager re-invite another branch's staff", async () => {
const t = await makeTenant(); const t = await makeTenant();
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) }); await db(() => 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 })); const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
+6
View File
@@ -7,6 +7,7 @@ import { User, UserRole } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { signAccessToken } from "@/lib/server/jwt"; import { signAccessToken } from "@/lib/server/jwt";
import { ACCESS_COOKIE } from "@/lib/server/auth"; import { ACCESS_COOKIE } from "@/lib/server/auth";
import { crossTenant } from "@/lib/server/tenantGuard";
let phoneSeq = 0; let phoneSeq = 0;
function nextPhone(): string { function nextPhone(): string {
@@ -131,3 +132,8 @@ export async function json<T = any>(res: Response): Promise<{ status: number; bo
export function uuid(): string { export function uuid(): string {
return randomUUID(); return randomUUID();
} }
/** Tests may inspect and arrange any business's data directly. */
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
return crossTenant("test inspects the database directly", fn);
}
+2 -2
View File
@@ -6,7 +6,7 @@ import * as login from "@/app/api/auth/login/route";
import * as me from "@/app/api/auth/me/route"; import * as me from "@/app/api/auth/me/route";
import * as platformBusinesses from "@/app/api/platform/businesses/route"; import * as platformBusinesses from "@/app/api/platform/businesses/route";
import { ACCESS_COOKIE } from "@/lib/server/auth"; import { ACCESS_COOKIE } from "@/lib/server/auth";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" }; const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
@@ -35,7 +35,7 @@ describe("email and phone are unique per business", () => {
const b = await makeTenant("Beta"); const b = await makeTenant("Beta");
expect((await hire(a, a.owner)).status).toBe(201); expect((await hire(a, a.owner)).status).toBe(201);
expect((await hire(b, b.owner)).status).toBe(201); expect((await hire(b, b.owner)).status).toBe(201);
expect(await User.countDocuments({ email: PERSON.email })).toBe(2); expect(await db(() => User.countDocuments({ email: PERSON.email }))).toBe(2);
}); });
it("still refuses a duplicate inside the same business", async () => { it("still refuses a duplicate inside the same business", async () => {
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { readdirSync } from "fs";
import path from "path";
import { crossTenant, isTenantGuarded, TenantScopeError } from "@/lib/server/tenantGuard";
import { User } from "@/lib/server/models/User";
import { Transaction } from "@/lib/server/models/Transaction";
import { Expense } from "@/lib/server/models/Expense";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { stockOf } from "@/lib/server/stock";
import * as cash from "@/app/api/transactions/cash/route";
import * as transactions from "@/app/api/transactions/route";
import * as expenses from "@/app/api/expenses/route";
import * as purchases from "@/app/api/purchases/route";
import * as stock from "@/app/api/stock/route";
import * as movements from "@/app/api/stock/movements/route";
import * as transfers from "@/app/api/stock/transfers/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as summary from "@/app/api/dashboard/summary/route";
import * as reconciliation from "@/app/api/dashboard/reconciliation/route";
import * as profit from "@/app/api/reports/profit/route";
import * as users from "@/app/api/users/route";
import * as userById from "@/app/api/users/[id]/route";
import * as reinvite from "@/app/api/users/[id]/invite/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as productById from "@/app/api/products/[id]/route";
import * as branchPrices from "@/app/api/branch-prices/route";
import * as dispatch from "@/app/api/inventory/dispatch/route";
import * as eveningCount from "@/app/api/inventory/evening-count/route";
import { db, json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
// Models that hold one business's data but are deliberately unguarded (see tenantGuard.ts).
const UNGUARDED = new Set(["Business", "UserSession", "MpesaCallbackLog", "StockLock"]);
describe("the tenant guard", () => {
it("is on every model that stores a businessId", async () => {
// Load every model file so none can be skipped by accident.
const dir = path.resolve(__dirname, "../src/lib/server/models");
for (const file of readdirSync(dir)) await import(path.join(dir, file));
const scoped = Object.values(mongoose.models).filter((m) => m.schema.path("businessId") && !UNGUARDED.has(m.modelName));
expect(scoped.length).toBeGreaterThanOrEqual(10);
const unguarded = scoped.filter((m) => !isTenantGuarded(m.schema)).map((m) => m.modelName);
expect(unguarded).toEqual([]);
});
it("refuses queries that do not name exactly one business", async () => {
const t = await makeTenant();
const attempts: [string, () => PromiseLike<unknown>][] = [
["find", () => Transaction.find({ branchId: t.branchA })],
["findById", () => User.findById(t.cashierA.id)],
["exists", () => StockMovement.exists({ type: "SALE" })],
["updateMany", () => Expense.updateMany({}, { amount: 0 })],
["deleteMany", () => Transaction.deleteMany({ paymentMethod: "CASH" })],
["countDocuments", () => User.countDocuments({})],
["$in over businesses", () => User.find({ businessId: { $in: [t.businessId, new mongoose.Types.ObjectId()] } })],
["$exists", () => User.find({ businessId: { $exists: true } })],
["aggregate without $match", () => Transaction.aggregate([{ $group: { _id: "$businessId" } }])],
["aggregate matching later", () => Transaction.aggregate([{ $sort: { createdAt: -1 } }, { $match: { businessId: t.businessId } }])],
["unscoped populate", () => Expense.find({ businessId: t.businessId }).populate("branchId")],
];
await Expense.create({ businessId: t.businessId, branchId: t.branchA, category: "Rent", amount: 1, description: "x", recordedBy: t.owner.id });
for (const [label, run] of attempts) {
await expect(Promise.resolve().then(run), label).rejects.toBeInstanceOf(TenantScopeError);
}
});
it("allows scoped queries, and cross-business ones only with a stated reason", async () => {
const t = await makeTenant();
expect(await User.countDocuments({ businessId: t.businessId })).toBe(4);
expect(await User.countDocuments({ $and: [{ businessId: t.businessId }, { role: "DELIVERY" }] })).toBe(2);
expect(await crossTenant("test", () => User.countDocuments({}))).toBe(4);
expect(() => crossTenant(" ", () => User.countDocuments({}))).toThrow(/reason/);
// The exemption ends with the callback.
await expect(Promise.resolve().then(() => User.countDocuments({}))).rejects.toBeInstanceOf(TenantScopeError);
});
});
/** Give tenant B one of everything, so a leak into tenant A's view would show. */
async function seedVictim(b: Tenant) {
await adjustments.POST(
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "DELTA", quantity: 50 } })
);
await cash.POST(
req("POST", "/x", { as: b.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 3 }] }] } })
);
await expenses.POST(req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, category: "Rent", amount: 7777, description: "B rent" } }));
await purchases.POST(
req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, supplierName: "B supplier", productId: b.water20, quantity: 5, unitCost: 99 } })
);
await transfers.POST(
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: b.branchB, productId: b.water20, quantity: 2 } })
);
}
describe("route sweep: another business's ids get nothing", () => {
it("every read route returns none of business B's data to business A, whatever ids A sends", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await seedVictim(b);
const reads: [string, Response][] = [];
const get = async (label: string, handler: (r: any) => Promise<Response>, url: string) =>
reads.push([label, await handler(req("GET", url, { as: a.owner }))]);
const q = "?branchId=" + b.branchA;
await get("transactions", transactions.GET, "/api/transactions" + q);
await get("expenses", expenses.GET, "/api/expenses" + q);
await get("purchases", purchases.GET, "/api/purchases" + q);
await get("stock", stock.GET, "/api/stock" + q);
await get("movements", movements.GET, "/api/stock/movements" + q);
await get("transfers", transfers.GET, "/api/stock/transfers" + q);
await get("summary", summary.GET, "/api/dashboard/summary" + q);
await get("reconciliation", reconciliation.GET, "/api/dashboard/reconciliation" + q);
await get("profit", profit.GET, "/api/reports/profit" + q);
await get("users", users.GET, "/api/users" + q);
await get("branch prices", branchPrices.GET, "/api/branch-prices" + q);
const bIds = [b.businessId, b.branchA, b.branchB, b.water20, b.owner.id, b.cashierA.id];
for (const [label, res] of reads) {
const text = await res.text();
for (const id of bIds) expect(text.includes(id), `${label} leaked ${id}`).toBe(false);
expect(text.includes("7777") || text.includes("B supplier"), `${label} leaked B's figures`).toBe(false);
}
// Totals A sees are A's own (zero), not B's sale.
const totals = JSON.parse(await (await summary.GET(req("GET", "/api/dashboard/summary" + q, { as: a.owner }))).text());
expect(totals.totals.cashTotal).toBe(0);
});
it("every write route refuses business B's ids and leaves B's data untouched", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await seedVictim(b);
const before = {
stockA: await stockOf(b.businessId, b.branchA, b.water20),
cashier: await db(() => User.findById(b.cashierA.id).lean()),
sales: await Transaction.countDocuments({ businessId: b.businessId }),
};
const as = a.owner;
const writes: [string, Response][] = [
["deactivate B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { isActive: false } }), params({ id: b.cashierA.id }))],
["move B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { branchId: a.branchA } }), params({ id: b.cashierA.id }))],
["re-invite B's cashier", await reinvite.POST(req("POST", "/x", { as }), params({ id: b.cashierA.id }))],
["rename B's branch", await branchById.PATCH(req("PATCH", "/x", { as, body: { branchName: "Pwned" } }), params({ id: b.branchA }))],
["reprice B's product", await productById.PATCH(req("PATCH", "/x", { as, body: { unitPrice: 1 } }), params({ id: b.water20 }))],
["branch price on B", await branchPrices.PUT(req("PUT", "/x", { as, body: { branchId: b.branchA, productId: b.water20, unitPrice: 1 } }))],
["adjust B's stock", await adjustments.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "COUNT", quantity: 0 } }))],
["transfer out of B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: a.branchA, productId: b.water20, quantity: 1 } }))],
["transfer into B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: a.branchA, toBranchId: b.branchA, productId: a.water20, quantity: 1 } }))],
["expense on B", await expenses.POST(req("POST", "/x", { as, body: { branchId: b.branchA, category: "Rent", amount: 1, description: "x" } }))],
["purchase into B", await purchases.POST(req("POST", "/x", { as, body: { branchId: b.branchA, supplierName: "x", productId: b.water20, quantity: 1, unitCost: 1 } }))],
["dispatch at B", await dispatch.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, morningDispatched: 1 } }))],
["count at B", await eveningCount.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, eveningPhysicalCount: 1 } }))],
["sale of B's product", await cash.POST(req("POST", "/x", { as: a.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 1 }] }] } }))],
];
for (const [label, res] of writes) {
if (label.startsWith("sale")) {
expect((await json(res)).body.results[0].status, label).toBe("rejected");
} else {
expect(res.status, label).toBeGreaterThanOrEqual(400);
}
}
expect(await stockOf(b.businessId, b.branchA, b.water20)).toBe(before.stockA);
expect(await db(() => User.findById(b.cashierA.id).lean())).toMatchObject({
isActive: before.cashier!.isActive,
branchId: before.cashier!.branchId,
});
expect(await Transaction.countDocuments({ businessId: b.businessId })).toBe(before.sales);
expect(await Expense.countDocuments({ businessId: b.businessId })).toBe(1);
});
});
+7 -7
View File
@@ -4,7 +4,7 @@ import { Product, BranchPricing } from "@/lib/server/models/Product";
import { stockOf } from "@/lib/server/stock"; import { stockOf } from "@/lib/server/stock";
import * as cash from "@/app/api/transactions/cash/route"; import * as cash from "@/app/api/transactions/cash/route";
import type { SaleResult } from "@/lib/syncProtocol"; import type { SaleResult } from "@/lib/syncProtocol";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) { function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
return { return {
@@ -31,7 +31,7 @@ describe("offline sale sync", () => {
const { status, body } = await post(t.cashierA, [s]); const { status, body } = await post(t.cashierA, [s]);
expect(status).toBe(200); expect(status).toBe(200);
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]); expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2); expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(2);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2); expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3); expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
}); });
@@ -42,7 +42,7 @@ describe("offline sale sync", () => {
await post(t.cashierA, [s]); await post(t.cashierA, [s]);
const replay = await post(t.cashierA, [s]); const replay = await post(t.cashierA, [s]);
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" }); expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1); expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(1);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1); expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
}); });
@@ -78,7 +78,7 @@ describe("offline sale sync", () => {
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]); const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
const { body } = await post(t.cashierA, [otherCashiersSale]); const { body } = await post(t.cashierA, [otherCashiersSale]);
expect(body.results[0].status).toBe("rejected"); expect(body.results[0].status).toBe("rejected");
expect(await Transaction.countDocuments({})).toBe(0); expect(await db(() => Transaction.countDocuments({}))).toBe(0);
}); });
it("keeps a sale at the branch where it happened after the cashier is moved", async () => { it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
@@ -86,7 +86,7 @@ describe("offline sale sync", () => {
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
const moved = { ...t.cashierA, branchId: t.branchB }; const moved = { ...t.cashierA, branchId: t.branchB };
await post(moved, [s]); await post(moved, [s]);
const txn = await Transaction.findOne({ saleId: s.clientUuid }); const txn = await db(() => Transaction.findOne({ saleId: s.clientUuid }));
expect(txn!.branchId.toString()).toBe(t.branchA); expect(txn!.branchId.toString()).toBe(t.branchA);
}); });
@@ -97,13 +97,13 @@ describe("offline sale sync", () => {
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA }); const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]); const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]); expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
expect(await Transaction.countDocuments({})).toBe(0); expect(await db(() => Transaction.countDocuments({}))).toBe(0);
}); });
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => { it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
const t = await makeTenant(); const t = await makeTenant();
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 }); await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
await Product.updateOne({ _id: t.water5 }, { isActive: false }); await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
const s = sale(t, t.cashierA, [ const s = sale(t, t.cashierA, [
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device { productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
{ productId: t.water5, quantity: 1, unitPrice: 60 }, { productId: t.water5, quantity: 1, unitPrice: 60 },