Compare commits

..

11 Commits

Author SHA1 Message Date
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 bae21ee62c Merge pull request 'Make email and phone unique per business, not platform-wide' (#5) from fix/per-business-identity into main
Reviewed-on: #5
2026-10-12 08:41:07 +00:00
denis_ireri 5e0e44c647 Merge pull request 'Stop simultaneous transfers from overdrawing a branch' (#4) from fix/transfer-race into main
Reviewed-on: #4
2026-10-12 08:40:42 +00: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
denis_ireri 9e3e59a804 Make email and phone unique per business, not platform-wide
One person could not work for two businesses, and signup and staff
creation told anyone whether an email or phone was registered anywhere
on the platform.

- User email and phone are now unique per business: indexes on
  (businessId, email) and (businessId, phoneNumber). Duplicate checks in
  signup and branch-manager creation are scoped to the business, so they
  no longer reveal other businesses' users.
- Login finds the email in every business and checks the password
  against each account. One match signs straight in; several return 300
  with the businesses to choose from, and the login page shows a picker.
  A wrong password returns the same 401 as before and reveals nothing;
  failures count against each candidate account, and a correct password
  only resets the accounts it opens.
- Platform-operator access now also needs a platformAdmin flag on the
  account. With per-business emails, an email match alone would have let
  any owner create a staff account with the operator's email and open the
  cross-tenant console. The flag is only set by the seed and the
  migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes.
- npm run migrate:identity flags today's operators (while each listed
  email still matches one account) and syncs the User indexes, dropping
  the old unique email_1 and phoneNumber_1. Checked against a database in
  the old shape; safe to re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:31:45 +03:00
denis_ireri ee5bb5beb8 Stop simultaneous transfers from overdrawing a branch
A transfer read the sending branch's stock and then wrote its movements
as two separate steps. Requests arriving together all saw the same
balance and all passed the check: five simultaneous transfers of 4 from
a stock of 10 all succeeded, sending 20.

The check and the write now run under withStockLock: a short lease on
one product's stock at one branch, taken with a single atomic upsert
(it only succeeds when no unexpired lease exists) and released when the
request finishes. A second request waits briefly, then sees the updated
balance; after 5 seconds it gets 409 "try again". A lease expires after
10 seconds, so a crashed request cannot block the stock for good.

Physical counts take the same lock, so a count is never computed from a
balance a transfer is changing. A double-tapped transfer that races
itself still moves stock once. Sales do not take the lock: they are
recorded even when stock runs out, by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:17 +03:00
denis_ireri 1fe2421cc4 Merge pull request 'Add a screen to set prices per branch' (#3) from feat/branch-prices into main
Reviewed-on: #3
2026-10-12 07:46:56 +00:00
denis_ireri 0f609d57bb Add a screen to set prices per branch
Branch price overrides were already applied at the till, in cash sales
and in M-Pesa, but nothing could set them.

- Products -> Branch prices (/admin/prices): pick a branch and set its
  price for any product; setting it back to the default removes the
  override, and "Use default" resets it. Branch managers see their own
  branch's prices read-only.
- /api/branch-prices: GET (owner: any branch; manager: own), PUT and
  DELETE (owner only). Branch and product must belong to the business;
  prices must be above zero; who changed a price is recorded.
- Branch pricing is a plan feature, as the Growth plan already
  advertised: Growth and Enterprise, plus any business on its free
  trial. A paid Starter business keeps any prices set during its trial
  and can reset them, but cannot set new ones.
- The catalogue view on the Products page always shows default prices;
  the till's view shows what the branch charges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:45:37 +03:00
denis_ireri 38facd3d9d Merge pull request 'Address the 11 October test report findings' (#2) from fix/test-report-followups into main
Reviewed-on: #2
2026-10-12 07:37:15 +00:00
52 changed files with 1663 additions and 175 deletions
+4 -2
View File
@@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2
# ---- Platform operator ----
# Comma-separated emails with cross-tenant access to /platform. Create these
# accounts with `npm run seed`; public signup refuses these addresses.
# Comma-separated emails with cross-tenant access to /platform. The account
# must also carry the platformAdmin flag, set only by `npm run seed` or
# `npm run migrate:identity`. Removing an email here revokes access.
# Public signup refuses these addresses.
PLATFORM_ADMIN_EMAILS=
# ---- Invite links ----
+68 -11
View File
@@ -62,7 +62,7 @@ the decisions taken along the way, and what is still open.
| Decision | Why |
|---|---|
| Offline sales are always accepted, even if they take stock below zero. | The sale already happened. Refusing it would lose real cash. Negative stock is shown on the Stock page and fixed with a physical count. |
| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. |
| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. The check and the move run under a short per-branch, per-product lock (`withStockLock` in `src/lib/server/stock.ts`), so simultaneous transfers cannot together overdraw a branch; physical counts take the same lock. |
| The server price is authoritative; a different price shown on the device is kept (`clientUnitPrice`) and flagged (`priceMismatch`). | Trusting the device's price would let a cashier under-charge. Overwriting it silently would hide a cash discrepancy. |
| A queued sale stays with the branch where it was recorded, even if the cashier has since moved. | That is where the stock left and the cash was taken. |
| A product deactivated while a phone was offline is still recorded at its price. | Same reason as the first row: the sale happened. |
@@ -76,7 +76,7 @@ the decisions taken along the way, and what is still open.
sales from the old version are sent on the next sync.
## Testing
- `npm test` runs 50 API tests against an in-memory MongoDB, covering role
- `npm test` runs 62 API tests against an in-memory MongoDB, covering role
enforcement, tenant isolation for sales, stock and transfers, suspension,
the sync protocol (including a 120-sale backlog replayed twice), and the
business day.
@@ -115,16 +115,73 @@ and checks every balance and total it predicts.
2. ZERRO is trialling, so it can now add Branch B without a plan change.
3. Create the manager and cashier logins with invite links from Staff.
## Branch prices
Owners set a different price per branch on **Products -> Branch prices**
(`/admin/prices`, API `/api/branch-prices`). Setting a price back to the
default removes the override. Branch managers see their own branch's
prices read-only.
- It is a Growth/Enterprise feature (as the plan list already advertised),
also available during the free trial. After a trial on Starter, existing
branch prices keep applying and can be reset, but not changed.
- The till, cash sales and M-Pesa all read the branch price. A phone picks
up a change the next time it loads products; a sale it records offline in
the meantime is kept at the server's price and flagged as a price
mismatch.
- The Products page always shows default prices.
## People in more than one business
Email and phone are unique **within a business**, not across the platform,
so one person can work for two businesses. Each account has its own
password and role.
- Login looks the email up in every business. If the password matches one
account, it signs straight in; if it matches several, the login page asks
which business. Someone with a wrong password learns nothing about which
businesses an email belongs to, and a wrong guess never resets the
failed-login count of another business's account.
- Signup and staff creation only check for duplicates inside the business,
so they no longer reveal whether an email or phone is registered
elsewhere.
- Platform-operator access now needs the `platformAdmin` flag on the
account as well as the email in `PLATFORM_ADMIN_EMAILS`. Without the
flag, any owner could create a staff account with the operator's email
in their own business. The flag is only set by `npm run seed` and
`npm run migrate:identity`, never through the app.
**Deploying this change:** run `npm run migrate:identity` straight after
the deploy. It flags the existing operator accounts and swaps the old
platform-wide unique indexes for per-business ones. Until it runs,
operators cannot open the Platform console and a second business cannot
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.
## Still open
- **Setting branch prices.** Branch price overrides are applied everywhere,
but there is no screen or API to set them yet.
- **Global email and phone uniqueness.** One person cannot work for two
businesses, and signup reveals whether an address is already registered.
- **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every
route.
- **Concurrent transfers.** Two transfers submitted at the same moment
could together send more than the branch holds.
- **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.
+2 -1
View File
@@ -11,7 +11,8 @@
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts"
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts",
"migrate:identity": "tsx scripts/migrate-per-business-identity.ts"
},
"dependencies": {
"axios": "^1.7.7",
+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 { Business } from "../src/lib/server/models/Business";
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
import { crossTenant } from "../src/lib/server/tenantGuard";
async function migrate() {
await connectDb();
@@ -75,7 +76,7 @@ async function migrate() {
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);
process.exit(1);
});
+50
View File
@@ -0,0 +1,50 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
import mongoose from "mongoose";
import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User";
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
* per-business uniqueness:
*
* 1. Marks today's platform operators. Until now an operator was anyone
* whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so
* that was one account each. From now on emails are only unique per
* business, so access also needs the platformAdmin flag on the account.
* Run this BEFORE dropping the old index, while each listed email
* still matches exactly one account.
* 2. Syncs the User indexes: drops the old unique `email_1` and
* `phoneNumber_1`, creates unique (businessId, email) and
* (businessId, phoneNumber), and a plain email lookup index.
*
* npm run migrate:identity
*/
async function main() {
await connectDb();
for (const email of platformAdminEmails()) {
const accounts = await User.find({ email }).select("_id").lean();
if (accounts.length === 1) {
await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } });
console.log(`Platform operator: ${email}`);
} else if (accounts.length === 0) {
console.warn(`No account for ${email} - create it with npm run seed.`);
} else {
console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`);
}
}
const dropped = await User.syncIndexes();
console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`);
await mongoose.disconnect();
}
crossTenant("identity migration spans every business", main).catch((err) => {
console.error("migration failed:", err.message);
process.exit(1);
});
+18 -2
View File
@@ -13,6 +13,8 @@ import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business";
import { isPlatformAdminEmail } from "../src/lib/server/platform";
import { crossTenant } from "../src/lib/server/tenantGuard";
/**
* Run once against a fresh database: `npm run seed`.
@@ -39,10 +41,23 @@ async function seed() {
}
let business = await Business.findOne({ name: demoBusinessName });
const existing = await User.findOne({ email });
// Emails are unique per business, so look in the seeded business (or, for
// a legacy single-tenant database, for its one super admin).
const existing = business
? await User.findOne({ email, businessId: business._id })
: await User.findOne({ email, role: "SUPER_ADMIN" });
// The seed is the only way (besides the identity migration) to make an
// operator: the flag is honoured only while the email is also listed in
// PLATFORM_ADMIN_EMAILS.
const platformAdmin = isPlatformAdminEmail(email);
if (existing) {
console.log(`Super admin (${email}) already exists, skipping`);
if (platformAdmin && !existing.platformAdmin) {
existing.platformAdmin = true;
await existing.save();
console.log(`Marked ${email} as a platform operator`);
}
if (!business) {
// Backfill a business for a legacy single-tenant admin if needed.
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
@@ -61,6 +76,7 @@ async function seed() {
phoneNumber: "254700000000",
email,
passwordHash,
platformAdmin,
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
});
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
@@ -92,7 +108,7 @@ async function seed() {
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);
process.exit(1);
});
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch } from "@/lib/types";
import { formatSize } from "@/lib/units";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
interface PriceRow {
productId: string;
label: string;
unit: string;
sizeLiters: number;
isActive: boolean;
defaultPrice: number;
branchPrice: number | null;
updatedAt: string | null;
}
interface PricesResponse {
branchId: string;
branchName: string;
allowed: boolean;
prices: PriceRow[];
}
/**
* Branch prices: what each branch charges for each product. Owners edit;
* branch managers see their own branch's prices.
*/
export default function BranchPricesPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [branchId, setBranchId] = useState("");
const [data, setData] = useState<PricesResponse | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingId, setSavingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const isOwner = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(br.branches);
const first = result.user.role === "SUPER_ADMIN" ? br.branches[0]?._id : result.user.branchId;
if (first) setBranchId(first);
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!branchId) return;
loadPrices(branchId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [branchId]);
async function loadPrices(id: string) {
try {
const res = await apiFetch<PricesResponse>("/api/branch-prices?branchId=" + id);
setData(res);
setDrafts(Object.fromEntries(res.prices.map((p) => [p.productId, String(p.branchPrice ?? p.defaultPrice)])));
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load prices", "error");
}
}
async function save(row: PriceRow) {
const price = parseFloat(drafts[row.productId]);
if (!Number.isFinite(price) || price <= 0) {
show("Enter a price above zero", "error");
return;
}
setSavingId(row.productId);
try {
await apiFetch("/api/branch-prices", {
method: "PUT",
body: JSON.stringify({ branchId, productId: row.productId, unitPrice: price }),
});
show(
price === row.defaultPrice
? `${row.label} back to the default price`
: `${row.label} now KES ${price.toLocaleString()} at ${data?.branchName}`,
"success"
);
await loadPrices(branchId);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not save the price", "error");
}
setSavingId(null);
}
async function reset(row: PriceRow) {
setSavingId(row.productId);
try {
await apiFetch(`/api/branch-prices?branchId=${branchId}&productId=${row.productId}`, { method: "DELETE" });
show(`${row.label} back to the default price`, "success");
await loadPrices(branchId);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not reset the price", "error");
}
setSavingId(null);
}
if (loading || !me) {
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8">
<div className="mx-auto h-14 max-w-3xl" />
</header>
<div className="mx-auto -mt-4 max-w-3xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
const canEdit = isOwner && !!data?.allowed;
const overrides = data?.prices.filter((p) => p.branchPrice !== null).length ?? 0;
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
<h1 className="font-display text-xl font-bold">Branch prices</h1>
</div>
<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
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
{branches.length === 0 ? (
<div className="rounded-xl2 bg-white shadow-soft">
<EmptyState icon="🏢" title="No branches yet" description="Create a branch first, then set its prices here." />
</div>
) : (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="space-y-3 border-b border-depth-100 px-4 py-3">
{isOwner ? (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price-branch">
Branch
</label>
<select
id="price-branch"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3 sm:w-72"
>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName} — {b.locationCity}
</option>
))}
</select>
</div>
) : (
<h2 className="font-display font-semibold text-depth-900">{data?.branchName ?? "Your branch"}</h2>
)}
<p className="text-xs text-depth-500">
Each product sells at its default price unless this branch has its own price. Cashiers at the branch
see the new price the next time their till loads products.
{!isOwner && " Only the business owner can change prices."}
</p>
{isOwner && data && !data.allowed && (
<p className="rounded-lg bg-cash-500/10 px-3 py-2 text-sm text-cash-700">
Branch prices are part of the Growth plan. Existing branch prices still apply and can be reset to
the default.
</p>
)}
{data && <p className="text-xs font-medium text-depth-600">{overrides} product(s) priced differently here</p>}
</div>
{!data ? (
<SkeletonList rows={3} />
) : data.prices.length === 0 ? (
<EmptyState icon="🧴" title="No products yet" description="Add products first on the Products page." />
) : (
<ul className="divide-y divide-depth-100">
{data.prices.map((row) => {
const custom = row.branchPrice !== null;
const draft = drafts[row.productId] ?? "";
const changed = parseFloat(draft) !== (row.branchPrice ?? row.defaultPrice);
return (
<li key={row.productId} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="font-medium text-depth-900">
{row.label}{" "}
<span className="text-depth-500">· {formatSize(row.sizeLiters, row.unit)}</span>
{!row.isActive && <span className="ml-2 text-xs text-depth-500">(deactivated)</span>}
</p>
<p className="text-xs text-depth-500">
Default KES {row.defaultPrice.toLocaleString()}
{custom && (
<span className="ml-2 rounded-full bg-flow-500/15 px-2 py-0.5 font-medium text-flow-700">
This branch: KES {row.branchPrice!.toLocaleString()}
</span>
)}
</p>
</div>
{canEdit ? (
<div className="flex items-center gap-2">
<span className="text-sm text-depth-500">KES</span>
<input
type="number"
min="0.01"
step="0.01"
inputMode="decimal"
value={draft}
onChange={(e) => setDrafts((d) => ({ ...d, [row.productId]: e.target.value }))}
aria-label={`Price of ${row.label} at this branch`}
className="w-24 rounded-lg border border-depth-200 px-2 py-1.5 text-sm"
/>
<button
onClick={() => save(row)}
disabled={!changed || savingId === row.productId}
className="rounded-lg bg-flow-500 px-3 py-1.5 text-sm font-semibold text-depth-950 disabled:opacity-40"
>
Save
</button>
{custom && (
<button
onClick={() => reset(row)}
disabled={savingId === row.productId}
className="text-xs text-depth-500 underline"
>
Use default
</button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-depth-900">
KES {(row.branchPrice ?? row.defaultPrice).toLocaleString()}
</span>
{isOwner && custom && (
<button onClick={() => reset(row)} className="text-xs text-depth-500 underline">
Use default
</button>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
)}
</div>
</main>
);
}
+11 -1
View File
@@ -167,10 +167,15 @@ export default function ProductsPage() {
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Products &amp; pricing</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/admin/prices" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Branch prices
</Link>
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
← Dashboard
</Link>
</div>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
@@ -184,7 +189,12 @@ export default function ProductsPage() {
<p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions.
{!canEdit && " Only the business owner can change products and prices."}
{!canEdit && " Only the business owner can change products and prices."} Prices here are the
defaults; a branch can charge its own price under{" "}
<Link href="/admin/prices" className="underline underline-offset-2">
Branch prices
</Link>
.
</p>
{products.some((p) => !p.isActive) && (
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
+63 -18
View File
@@ -7,7 +7,9 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { isPlatformAdmin } from "@/lib/server/platform";
import { objectId } from "@/lib/server/validation";
import { crossTenant } from "@/lib/server/tenantGuard";
import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription";
@@ -21,6 +23,9 @@ const loginSchema = z.object({
password: z.string().min(1),
deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(),
// Which business to sign in to, when the same email and password match
// accounts in more than one (the first attempt returns the choices).
businessId: objectId.optional(),
});
async function enforceDeviceBinding(userId: string, deviceId: string) {
@@ -44,32 +49,72 @@ export async function POST(req: NextRequest) {
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
}
const { email, password, deviceId, deviceLabel } = parsed.data;
const { email, password, deviceId, deviceLabel, businessId } = parsed.data;
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
if (!user || !user.isActive) {
// An email is unique within a business, so it can belong to accounts in
// several businesses, each with its own password.
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
if (businessId) filter.businessId = businessId;
const candidates = await crossTenant("login looks an email up in every business", () =>
User.find(filter).select("+passwordHash").exec()
);
if (candidates.length === 0) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
const now = Date.now();
const unlocked = candidates.filter((u) => !u.lockedUntil || u.lockedUntil.getTime() <= now);
if (unlocked.length === 0) {
const soonest = Math.min(...candidates.map((u) => u.lockedUntil!.getTime()));
const minutesLeft = Math.ceil((soonest - now) / 60000);
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
}
const validPassword = await user.comparePassword(password);
if (!validPassword) {
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
user.failedLoginAttempts = 0;
const matched = [];
for (const u of unlocked) {
if (await u.comparePassword(password)) matched.push(u);
}
if (matched.length === 0) {
// Count the failure against every account this email could have meant.
for (const u of unlocked) {
u.failedLoginAttempts += 1;
if (u.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
u.lockedUntil = new Date(now + LOCKOUT_MS);
u.failedLoginAttempts = 0;
}
await u.save();
}
await user.save();
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
await user.save();
// A correct password unlocks only the accounts it belongs to; a person
// with a different password in another business is not affected.
for (const u of matched) {
u.failedLoginAttempts = 0;
u.lockedUntil = null;
await u.save();
}
if (matched.length > 1) {
// Only someone who knows the password learns which businesses it opens.
const businesses = await Business.find({ _id: { $in: matched.map((u) => u.businessId) } })
.select("name")
.lean();
const nameById = new Map(businesses.map((b) => [b._id.toString(), b.name]));
return NextResponse.json(
{
error: "This email signs in to more than one business. Choose one.",
chooseBusiness: matched.map((u) => ({
businessId: u.businessId.toString(),
businessName: nameById.get(u.businessId.toString()) ?? "Business",
role: u.role,
})),
},
{ status: 300 }
);
}
const user = matched[0];
const tenantError = await tenantAccessError(user.businessId);
if (tenantError) {
@@ -107,7 +152,7 @@ export async function POST(req: NextRequest) {
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 res = NextResponse.json({
@@ -123,7 +168,7 @@ export async function POST(req: NextRequest) {
email: user.email,
},
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
isPlatformAdmin: isPlatformAdminEmail(user.email),
isPlatformAdmin: isPlatformAdmin(user),
});
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
+4 -4
View File
@@ -4,7 +4,7 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform";
import { isPlatformAdmin } from "@/lib/server/platform";
import { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
@@ -16,11 +16,11 @@ export async function GET(req: NextRequest) {
const auth = getAuth(req);
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) {
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 tier = (business?.tier as TierId) ?? "STARTER";
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber,
email: user.email,
isPlatformAdmin: isPlatformAdminEmail(user.email),
isPlatformAdmin: isPlatformAdmin(user),
},
subscription: {
tier,
+4 -1
View File
@@ -4,6 +4,7 @@ import { User } from "@/lib/server/models/User";
import { UserSession } from "@/lib/server/models/UserSession";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { tenantAccessError } from "@/lib/server/subscription";
import { crossTenant } from "@/lib/server/tenantGuard";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
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 });
}
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) {
await UserSession.deleteOne({ _id: session._id });
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
+7 -13
View File
@@ -53,22 +53,16 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
// and signup does not verify email ownership, so an operator address
// must never be claimable through public signup. Operator accounts are
// created with `npm run seed` instead.
// Operator addresses are reserved, so nobody can open a business that
// looks like the operator's. (Access itself also needs the platformAdmin
// flag, which signup never sets.)
if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
}
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
// No platform-wide duplicate check: a new business has no users yet, and
// the same person may already work for another business. (Checking would
// also tell anyone whether an email or phone is registered elsewhere.)
const passwordHash = await bcrypt.hash(password, 12);
@@ -100,7 +94,7 @@ export async function POST(req: NextRequest) {
await owner.save();
} catch (e) {
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 || "";
if (msg.includes("duplicate key")) {
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { authorize, badRequest, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { canUseBranchPricing, type SubscriptionStatus, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Branch-specific prices. A branch charges its own price for a product when
* one is set here, otherwise the product's default price. The till, the
* cash-sale route and M-Pesa all read prices the same way, so a change here
* applies to the next sale at that branch (a phone that is offline picks it
* up when it next loads products; a sale it records meanwhile is kept at the
* server's price and flagged as a price mismatch).
*/
async function pricingAllowed(businessId: string): Promise<boolean> {
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
return canUseBranchPricing((biz?.tier as TierId) ?? "STARTER", (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING");
}
// GET /api/branch-prices?branchId= — every product with its default and
// branch price. Owners pick any branch; managers see their own.
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const requested = req.nextUrl.searchParams.get("branchId");
const branchId = auth.role === "SUPER_ADMIN" ? requested : auth.branchId;
if (!branchId) return badRequest("branchId is required");
if (!objectId.safeParse(branchId).success) return badRequest("Invalid branchId");
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const [products, overrides, allowed] = await Promise.all([
Product.find({ businessId: auth.businessId }).sort({ sortOrder: 1 }).lean(),
BranchPricing.find({ businessId: auth.businessId, branchId }).lean(),
pricingAllowed(auth.businessId),
]);
const byProduct = new Map(overrides.map((o) => [o.productId.toString(), o]));
const prices = products
// Deactivated products only matter here if they still carry a branch price.
.filter((p) => p.isActive || byProduct.has(p._id.toString()))
.map((p) => {
const o = byProduct.get(p._id.toString());
return {
productId: p._id.toString(),
label: p.label,
unit: p.unit,
sizeLiters: p.sizeLiters,
isActive: p.isActive,
defaultPrice: p.unitPrice,
branchPrice: o?.unitPrice ?? null,
updatedAt: o?.updatedAt ?? null,
};
});
return NextResponse.json({ branchId, branchName: branch.branchName, allowed, prices });
}
const setSchema = z.object({
branchId: objectId,
productId: objectId,
unitPrice: z.number().positive("Price must be more than zero").max(10_000_000),
});
// PUT /api/branch-prices — owner sets one product's price at one branch.
// Setting it to the default price removes the override.
export async function PUT(req: NextRequest) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = setSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return badRequest(parsed.error.issues[0]?.message ?? "Invalid request");
const { branchId, productId, unitPrice } = parsed.data;
if (!(await pricingAllowed(auth.businessId))) {
return NextResponse.json(
{ error: "Branch prices are part of the Growth plan. Upgrade to set a different price per branch." },
{ status: 403 }
);
}
const [branch, product] = await Promise.all([
Branch.exists({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }),
Product.findOne({ _id: productId, businessId: auth.businessId }).select("unitPrice").lean(),
]);
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
if (unitPrice === product.unitPrice) {
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
return NextResponse.json({ branchId, productId, branchPrice: null, defaultPrice: product.unitPrice });
}
const row = await BranchPricing.findOneAndUpdate(
{ businessId: auth.businessId, branchId, productId },
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean();
return NextResponse.json({ branchId, productId, branchPrice: row!.unitPrice, defaultPrice: product.unitPrice });
}
// DELETE /api/branch-prices?branchId=&productId= — back to the default price.
// Always allowed, even after the plan no longer includes branch prices.
export async function DELETE(req: NextRequest) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = req.nextUrl.searchParams.get("branchId");
const productId = req.nextUrl.searchParams.get("productId");
if (!objectId.safeParse(branchId).success || !objectId.safeParse(productId).success) {
return badRequest("branchId and productId are required");
}
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
return NextResponse.json({ ok: true });
}
+7 -6
View File
@@ -71,14 +71,15 @@ export async function POST(req: NextRequest) {
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
}
// Unique within this business only (see the User model).
const lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
const emailTaken = await User.exists({ businessId, email: lowerEmail });
if (emailTaken) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
return NextResponse.json({ error: "Someone in your business already uses this email." }, { status: 409 });
}
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
const phoneTaken = await User.exists({ businessId, phoneNumber: managerPhone });
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
return NextResponse.json({ error: "Someone in your business already uses this phone number." }, { status: 409 });
}
}
@@ -105,9 +106,9 @@ export async function POST(req: NextRequest) {
{ status: 201 }
);
} catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
}
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
}
+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 { objectId } from "@/lib/server/validation";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
}
const expenses = await Expense.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 })
.limit(500)
.lean();
+1 -1
View File
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
const date = parsed.data.date ?? todayNairobi();
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 },
{ 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 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 },
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
+4 -1
View File
@@ -5,13 +5,16 @@ import { connectDb } from "@/lib/server/db";
import { User } from "@/lib/server/models/User";
import { Business } from "@/lib/server/models/Business";
import { hashInviteToken } from "@/lib/server/invites";
import { crossTenant } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
async function findInvitee(token: string) {
if (!token || token.length > 100) return null;
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
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;
return user;
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
import { isMpesaEnabled } from "@/lib/server/env";
import { Transaction } from "@/lib/server/models/Transaction";
import { recordSaleMovement } from "@/lib/server/stock";
import { crossTenant } from "@/lib/server/tenantGuard";
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
export const runtime = "nodejs";
@@ -69,7 +70,9 @@ export async function POST(req: NextRequest) {
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) {
await MpesaCallbackLog.create({
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
const code = Number(result.ResultCode);
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) {
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
fresh.mpesaDetails.resultCode = code;
+12 -8
View File
@@ -4,6 +4,7 @@ import { Business } from "@/lib/server/models/Business";
import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch";
import { requirePlatformAdmin } from "@/lib/server/platform";
import { crossTenant } from "@/lib/server/tenantGuard";
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
export const runtime = "nodejs";
@@ -23,23 +24,26 @@ export async function GET(req: NextRequest) {
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
// Usage counts per business in two grouped queries instead of N+1.
const [userCounts, branchCounts] = await Promise.all([
User.aggregate<{ _id: unknown; count: number }>([
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
const [userCounts, branchCounts] = await crossTenant("the platform console counts every business", () =>
Promise.all([
User.aggregate<{ _id: unknown; count: number }>([{ $group: { _id: "$businessId", count: { $sum: 1 } } }]).exec(),
Branch.aggregate<{ _id: unknown; count: number }>([
{ $match: { isActive: { $ne: false } } },
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
]),
]);
]).exec(),
])
);
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
// Owner contact lookup.
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")
.lean();
.lean()
.exec()
);
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
const rows = businesses.map((b) => {
+3 -1
View File
@@ -24,7 +24,9 @@ export async function GET(req: NextRequest) {
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
if (auth.branchId) {
// The catalogue view (includeInactive) shows default prices; the till's
// view shows what this branch actually charges.
if (auth.branchId && !includeInactive) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) {
+3 -2
View File
@@ -7,6 +7,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { objectId } from "@/lib/server/validation";
import { recordMovement } from "@/lib/server/stock";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -36,8 +37,8 @@ export async function GET(req: NextRequest) {
}
const purchases = await Purchase.find(filter)
.populate("branchId", "branchName")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.sort({ createdAt: -1 })
.limit(500)
.lean();
+16 -7
View File
@@ -6,7 +6,7 @@ import { Product } from "@/lib/server/models/Product";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { clientRefId, recordMovement, stockOf } from "@/lib/server/stock";
import { clientRefId, recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
export const runtime = "nodejs";
@@ -57,17 +57,22 @@ export async function POST(req: NextRequest) {
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// A retried submit with the same clientUuid is a no-op.
// A retried submit with the same clientUuid is a no-op. A count reads the
// balance to work out its correction, so it runs under the stock lock:
// a transfer cannot slip in between the read and the write.
const refId = clientRefId(auth.businessId, data.clientUuid);
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
const lockedBranch = branchId;
try {
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
if (!already) {
const before = await stockOf(auth.businessId, branchId, data.productId);
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({
businessId: auth.businessId,
branchId,
branchId: lockedBranch,
productId: data.productId,
type: "ADJUSTMENT",
quantity: delta,
@@ -77,7 +82,11 @@ export async function POST(req: NextRequest) {
});
}
}
const quantity = await stockOf(auth.businessId, branchId, data.productId);
return stockOf(auth.businessId, lockedBranch, data.productId);
});
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
} catch (err) {
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
}
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { connectDb } from "@/lib/server/db";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -23,9 +24,9 @@ export async function GET(req: NextRequest) {
const movements = await StockMovement.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean();
return NextResponse.json({ movements });
}
+27 -10
View File
@@ -5,8 +5,9 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { objectId } from "@/lib/server/validation";
import { recordMovement, stockOf } from "@/lib/server/stock";
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -27,10 +28,10 @@ export async function GET(req: NextRequest) {
const transfers = await StockTransfer.find(filter)
.sort({ createdAt: -1 })
.limit(100)
.populate("fromBranchId", "branchName")
.populate("toBranchId", "branchName")
.populate("productId", "label unit")
.populate("recordedBy", "name")
.populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
.populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
.populate(scopedPopulate("productId", "label unit", auth.businessId))
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
.lean();
return NextResponse.json({ transfers });
}
@@ -104,8 +105,20 @@ export async function POST(req: NextRequest) {
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// Transfers are made online by a manager, so unlike offline sales they
// can be refused when the sending branch does not have the stock.
const available = await stockOf(auth.businessId, fromBranchId, data.productId);
// can be refused when the sending branch does not have the stock. The
// balance check and the movements run under the sending branch's stock
// lock, so two simultaneous transfers cannot both spend the same units.
const sendingBranch = fromBranchId;
try {
return await withStockLock(auth.businessId, sendingBranch, data.productId, async () => {
// An identical submit may have completed while we waited for the lock.
const done = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
if (done) {
await applyTransfer(done);
return NextResponse.json({ transfer: done }, { status: 200 });
}
const available = await stockOf(auth.businessId, sendingBranch, data.productId);
if (available < data.quantity) {
return NextResponse.json(
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
@@ -117,7 +130,7 @@ export async function POST(req: NextRequest) {
try {
transfer = await StockTransfer.create({
businessId: auth.businessId,
fromBranchId,
fromBranchId: sendingBranch,
toBranchId: data.toBranchId,
productId: data.productId,
quantity: data.quantity,
@@ -126,11 +139,15 @@ export async function POST(req: NextRequest) {
recordedBy: auth.sub,
});
} catch (err: unknown) {
// Two identical submits raced and the other one created it.
// Defensive: the unique clientUuid index still guards double submits.
if ((err as { code?: number })?.code !== 11000) throw err;
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
}
await applyTransfer(transfer);
return NextResponse.json({ transfer }, { status: 201 });
});
} catch (err) {
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
}
+2 -4
View File
@@ -167,10 +167,8 @@ export async function POST(req: NextRequest) {
} catch (err: unknown) {
if ((err as { code?: number })?.code !== 11000) throw err;
// Already recorded by an earlier attempt: reuse it.
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` });
if (!existing || existing.businessId.toString() !== auth.businessId) {
return reject("This sale id is already in use.");
}
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
if (!existing) return reject("This sale id is already in use.");
txn = existing;
}
// 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 { Transaction } from "@/lib/server/models/Transaction";
import { authorize, branchParam } from "@/lib/server/auth";
import { scopedPopulate } from "@/lib/server/tenantGuard";
import { startOfTodayNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
@@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
const transactions = await Transaction.find(filter)
.sort({ createdAt: -1 })
.limit(limit)
.populate("branchId", "branchName")
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
.lean();
return NextResponse.json({ transactions });
+1 -1
View File
@@ -115,7 +115,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
} catch (err: any) {
if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 });
}
throw err;
}
+52 -2
View File
@@ -12,6 +12,18 @@ interface ActiveDevice {
lastUsedAt: string;
}
interface BusinessChoice {
businessId: string;
businessName: string;
role: string;
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: "Owner",
BRANCH_MANAGER: "Branch manager",
DELIVERY: "Cashier",
};
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
@@ -21,6 +33,8 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
// Set when this email and password open accounts in more than one business.
const [choices, setChoices] = useState<BusinessChoice[] | null>(null);
const [invited, setInvited] = useState(false);
useEffect(() => {
@@ -29,6 +43,10 @@ export default function LoginPage() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await signIn();
}
async function signIn(businessId?: string) {
setError(null);
setDeviceConflict(null);
setLoading(true);
@@ -41,11 +59,15 @@ export default function LoginPage() {
password,
deviceId: getDeviceId(),
deviceLabel: getDeviceLabel(),
businessId,
}),
});
router.replace(result.redirectTo);
return;
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
if (err instanceof ApiError && err.status === 300) {
setChoices((err.body?.chooseBusiness as BusinessChoice[]) ?? []);
} else if (err instanceof ApiError && err.status === 409) {
setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
setError(err.message);
} else if (err instanceof ApiError) {
@@ -75,7 +97,35 @@ export default function LoginPage() {
Run every branch with confidence — sales, stock, M-Pesa and cash.
</p>
{deviceConflict ? (
{choices && !deviceConflict ? (
<div className="mt-8">
<p className="text-sm font-medium text-depth-800">Which business do you want to sign in to?</p>
{error && choices.length === 0 && <p className="mt-2 text-sm text-alert-600">{error}</p>}
<ul className="mt-3 space-y-2">
{choices.map((c) => (
<li key={c.businessId}>
<button
onClick={() => signIn(c.businessId)}
disabled={loading}
className="tap-target flex w-full items-center justify-between rounded-xl2 border border-depth-200 bg-white px-4 text-left transition hover:border-flow-500 disabled:opacity-60"
>
<span className="font-medium text-depth-900">{c.businessName}</span>
<span className="text-xs text-depth-500">{ROLE_LABELS[c.role] ?? c.role}</span>
</button>
</li>
))}
</ul>
<button
onClick={() => {
setChoices(null);
setError(null);
}}
className="mt-4 text-sm text-depth-500 underline underline-offset-2"
>
Use a different email
</button>
</div>
) : deviceConflict ? (
<div className="mt-8 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-4">
<p className="text-sm font-medium text-alert-600">{error}</p>
<ul className="mt-3 space-y-2">
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IBranch extends Document {
@@ -30,5 +31,7 @@ const branchSchema = new Schema<IBranch>(
/** Filter for branches that are in use. */
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
branchSchema.plugin(tenantGuard);
export const Branch =
(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";
export interface IDailyInventory extends Document {
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
// One ledger row per branch+product+day.
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
dailyInventorySchema.plugin(tenantGuard);
export const DailyInventory =
(models.DailyInventory as Model<IDailyInventory>) ||
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";
// 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
// re-invocations where the module is evaluated more than once.
expenseSchema.plugin(tenantGuard);
export const Expense =
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
+7
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose";
export interface IProduct extends Document {
@@ -32,6 +33,8 @@ const productSchema = new Schema<IProduct>(
{ timestamps: true }
);
productSchema.plugin(tenantGuard);
export const Product =
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
@@ -41,6 +44,7 @@ export interface IBranchPricing extends Document {
branchId: Types.ObjectId;
productId: Types.ObjectId;
unitPrice: number;
updatedBy: Types.ObjectId | null;
updatedAt: Date;
}
@@ -50,11 +54,14 @@ const branchPricingSchema = new Schema<IBranchPricing>(
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
unitPrice: { type: Number, required: true, min: 0 },
updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
},
{ timestamps: { createdAt: false, updatedAt: true } }
);
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
branchPricingSchema.plugin(tenantGuard);
export const BranchPricing =
(models.BranchPricing as Model<IBranchPricing>) ||
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";
// 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({ productId: 1 });
purchaseSchema.plugin(tenantGuard);
export const Purchase =
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
+25
View File
@@ -0,0 +1,25 @@
import { Schema, model, models, Model } from "mongoose";
export interface IStockLock {
_id: string; // `${businessId}:${branchId}:${productId}`
owner: string; // random id of the request holding the lock
lockedUntil: Date; // lease expiry: a crashed request cannot hold the lock forever
}
/**
* A short lease on one product's stock at one branch, held while a request
* reads the current balance and writes a movement that depends on it (see
* withStockLock in src/lib/server/stock.ts). Rows are deleted on release;
* an expired row is simply taken over by the next request.
*/
const stockLockSchema = new Schema<IStockLock>(
{
_id: { type: String, required: true },
owner: { type: String, required: true },
lockedUntil: { type: Date, required: true },
},
{ versionKey: false }
);
export const StockLock =
(models.StockLock as Model<IStockLock>) || model<IStockLock>("StockLock", stockLockSchema);
+3
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose";
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, createdAt: -1 });
stockMovementSchema.plugin(tenantGuard);
export const StockMovement =
(models.StockMovement as Model<IStockMovement>) ||
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";
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, createdAt: -1 });
stockTransferSchema.plugin(tenantGuard);
export const StockTransfer =
(models.StockTransfer as Model<IStockTransfer>) ||
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";
export type PaymentMethod = "CASH" | "MPESA";
@@ -105,6 +106,8 @@ transactionSchema.index({ businessId: 1, createdAt: -1 });
transactionSchema.index({ paymentStatus: 1 });
transactionSchema.index({ businessId: 1, saleId: 1 });
transactionSchema.plugin(tenantGuard);
export const Transaction =
(models.Transaction as Model<ITransaction>) ||
model<ITransaction>("Transaction", transactionSchema);
+21 -2
View File
@@ -1,3 +1,4 @@
import { tenantGuard } from "@/lib/server/tenantGuard";
import { Schema, model, models, Model, Types, Document } from "mongoose";
import bcrypt from "bcryptjs";
@@ -10,8 +11,16 @@ export interface IUser extends Document {
name: string;
role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner)
// Email and phone are unique WITHIN a business, not across the platform,
// so one person can work for two businesses (each account has its own
// password and role). Login asks which business when the same email and
// password match accounts in more than one.
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
email: string; // login identifier for every role, via the single /login page
// Platform operator (cross-tenant console). Only set by `npm run seed` or
// the identity migration, never through the app, and only honoured while
// the email is also listed in PLATFORM_ADMIN_EMAILS.
platformAdmin: boolean;
passwordHash: string;
isActive: boolean;
failedLoginAttempts: number;
@@ -33,8 +42,9 @@ const userSchema = new Schema<IUser>(
name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
phoneNumber: { type: String, required: true },
email: { type: String, required: true, lowercase: true, trim: true },
platformAdmin: { type: Boolean, default: false },
passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 },
@@ -45,6 +55,13 @@ const userSchema = new Schema<IUser>(
{ timestamps: true }
);
userSchema.index({ businessId: 1, email: 1 }, { unique: true });
userSchema.index({ businessId: 1, phoneNumber: 1 }, { unique: true });
// Login looks an email up across businesses. Named so it never clashes with
// the old platform-wide unique `email_1` index, which the identity
// migration (npm run migrate:identity) drops.
userSchema.index({ email: 1 }, { name: "email_lookup" });
// Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no
@@ -64,5 +81,7 @@ userSchema.methods.comparePassword = async function (password: string): Promise<
return bcrypt.compare(password, this.passwordHash);
};
userSchema.plugin(tenantGuard);
export const User =
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
+16 -5
View File
@@ -26,21 +26,32 @@ import type { NextRequest } from "next/server";
import { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User";
/**
* Whether this account is a platform operator. Two conditions, because
* emails are only unique within a business: any owner can create a staff
* account with any email in their own business, so an email match alone
* must never grant cross-tenant access. The account must also carry the
* platformAdmin flag, which the app never sets (seed/migration only).
* Removing an email from PLATFORM_ADMIN_EMAILS revokes access at once.
*/
export function isPlatformAdmin(user: { email?: string | null; platformAdmin?: boolean | null }): boolean {
return user.platformAdmin === true && isPlatformAdminEmail(user.email);
}
/**
* Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the
* caller is not a platform admin. The DB must already be connected.
*
* The JWT has no email, so we re-read the User and match its email
* against PLATFORM_ADMIN_EMAILS.
*/
export async function requirePlatformAdmin(
req: NextRequest
): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req);
if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive").lean();
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId })
.select("email isActive platformAdmin")
.lean();
if (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null;
if (!isPlatformAdmin(user)) return null;
return { userId: auth.sub, email: user.email };
}
+63 -3
View File
@@ -3,6 +3,8 @@ import { StockMovement, type MovementType } from "@/lib/server/models/StockMovem
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import { StockLock } from "@/lib/server/models/StockLock";
import { crossTenant } from "@/lib/server/tenantGuard";
import type { ITransaction } from "@/lib/server/models/Transaction";
type Id = Types.ObjectId | string;
@@ -25,7 +27,7 @@ export interface MovementInput {
*/
export async function recordMovement(m: MovementInput): Promise<void> {
await StockMovement.updateOne(
{ type: m.type, refId: m.refId },
{ type: m.type, refId: m.refId, businessId: m.businessId },
{
$setOnInsert: {
businessId: m.businessId,
@@ -154,11 +156,14 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
const filter: Record<string, unknown> = { productId: { $ne: null } };
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
const purchases = businessId
? await Purchase.find(filter).lean()
: await crossTenant("the backfill runs over every business", () => Purchase.find(filter).lean().exec());
let added = 0;
for await (const p of Purchase.find(filter).lean().cursor()) {
for (const p of purchases) {
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({
businessId: p.businessId,
branchId: p.branchId,
@@ -173,3 +178,58 @@ export async function backfillPurchaseMovements(businessId?: Id): Promise<number
}
return added;
}
const LOCK_LEASE_MS = 10_000;
const LOCK_WAIT_MS = 5_000;
/** Another request kept the stock busy for longer than we are willing to wait. */
export class StockBusyError extends Error {
constructor() {
super("This stock is being updated by someone else. Please try again.");
}
}
/**
* Run `fn` while holding the lock on one product's stock at one branch.
*
* Anything that reads the balance and then writes a movement based on it
* (a transfer refusing to overdraw, a physical count computing its
* correction) must run inside this. Without it, two transfers that each
* see 10 in stock can both send 6, because the read and the write are
* separate operations. With it, the second waits for the first and then
* sees 4.
*
* The lock is a lease row: acquired with one atomic upsert that only
* succeeds if no unexpired lease exists (otherwise the insert hits the
* unique _id and fails), released by deleting it. Sales do not take the
* lock - they are recorded even when stock runs out (see PILOT_READINESS).
*/
export async function withStockLock<T>(businessId: Id, branchId: Id, productId: Id, fn: () => Promise<T>): Promise<T> {
const key = `${businessId}:${branchId}:${productId}`;
const owner = crypto.randomUUID();
const deadline = Date.now() + LOCK_WAIT_MS;
let delay = 10;
for (;;) {
const now = Date.now();
try {
await StockLock.updateOne(
{ _id: key, lockedUntil: { $lt: new Date(now) } },
{ $set: { owner, lockedUntil: new Date(now + LOCK_LEASE_MS) } },
{ upsert: true }
);
break;
} catch (err: unknown) {
if ((err as { code?: number })?.code !== 11000) throw err;
}
if (Date.now() > deadline) throw new StockBusyError();
await new Promise((r) => setTimeout(r, delay + Math.random() * delay));
delay = Math.min(delay * 2, 200);
}
try {
return await fn();
} finally {
await StockLock.deleteOne({ _id: key, owner });
}
}
+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 } };
}
+13
View File
@@ -13,6 +13,7 @@ export interface TierDef {
priceKsh: number; // monthly price in KSh
maxBranches: number | null; // null = unlimited
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
branchPricing: boolean; // may set a different price per branch
features: string[];
}
@@ -23,6 +24,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 3500,
maxBranches: 1,
maxUsers: 6, // owner + up to 5 staff/manager
branchPricing: false,
features: [
"1 branch",
"Up to 5 staff accounts",
@@ -37,6 +39,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 7500,
maxBranches: 5,
maxUsers: 30,
branchPricing: true,
features: [
"Up to 5 branches",
"Up to 30 staff accounts",
@@ -51,6 +54,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 15000,
maxBranches: null,
maxUsers: null,
branchPricing: true,
features: [
"Unlimited branches",
"Unlimited staff accounts",
@@ -72,6 +76,15 @@ export const TRIAL_DAYS = 14;
// applies to adding branches; existing branches are never removed.
export const TRIAL_MAX_BRANCHES = 2;
/**
* Whether the business may set branch-specific prices right now: a plan
* feature (Growth and up), also available during the free trial so it can
* be tried with the trial's two branches.
*/
export function canUseBranchPricing(tier: TierId, status: SubscriptionStatus): boolean {
return TIERS[tier].branchPricing || status === "TRIALING";
}
/** Branch limit for a business right now (null = unlimited). */
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
const max = TIERS[tier].maxBranches;
+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 stkpush from "@/app/api/mpesa/stkpush/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", () => {
it("blocks cashiers from owner-level money endpoints with 403", async () => {
@@ -78,7 +78,7 @@ describe("user deactivation", () => {
params({ id: t.cashierA.id })
);
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);
});
@@ -105,7 +105,7 @@ describe("tenant status", () => {
it("refuses login for a suspended business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
const res = await login.POST(
@@ -119,7 +119,7 @@ describe("tenant status", () => {
it("still allows login for an active business", async () => {
const t = await makeTenant();
const passwordHash = await bcrypt.hash("correct-horse", 4);
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
const res = await login.POST(
req("POST", "/api/auth/login", {
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { Business } from "@/lib/server/models/Business";
import { BranchPricing } from "@/lib/server/models/Product";
import * as prices from "@/app/api/branch-prices/route";
import * as products from "@/app/api/products/route";
import * as cash from "@/app/api/transactions/cash/route";
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
const tillPrice = async (as: Actor, productId: string) => {
const { body } = await json(await products.GET(req("GET", "/api/products", { as })));
return body.products.find((p: any) => p._id === productId).unitPrice;
};
describe("branch prices", () => {
it("charges the branch's own price at that branch only, through the till and the sale", async () => {
const t = await makeTenant();
expect((await setPrice(t.owner, t.branchA, t.water20, 180)).status).toBe(200);
expect(await tillPrice(t.cashierA, t.water20)).toBe(180);
expect(await tillPrice(t.cashierB, t.water20)).toBe(200);
const sale = await json(
await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 2 }] }] } }))
);
expect(sale.body.results[0]).toMatchObject({ status: "saved", amountTotal: 360 });
});
it("lists default and branch prices, and the catalogue view keeps showing defaults", async () => {
const t = await makeTenant();
await setPrice(t.owner, t.branchA, t.water20, 180);
const { body } = await json(await prices.GET(req("GET", "/api/branch-prices?branchId=" + t.branchA, { as: t.owner })));
const row = body.prices.find((p: any) => p.productId === t.water20);
expect(row).toMatchObject({ defaultPrice: 200, branchPrice: 180 });
expect(body.allowed).toBe(true);
const catalogue = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
expect(catalogue.body.products.find((p: any) => p._id === t.water20).unitPrice).toBe(200);
});
it("removes the override when set back to the default, or reset", async () => {
const t = await makeTenant();
await setPrice(t.owner, t.branchA, t.water20, 180);
await setPrice(t.owner, t.branchA, t.water20, 200);
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
await setPrice(t.owner, t.branchA, t.water5, 55);
const reset = await prices.DELETE(
req("DELETE", `/api/branch-prices?branchId=${t.branchA}&productId=${t.water5}`, { as: t.owner })
);
expect(reset.status).toBe(200);
expect(await tillPrice(t.cashierA, t.water5)).toBe(60);
});
it("lets a manager see only their own branch's prices, and not change them", async () => {
const t = await makeTenant();
const own = await json(await prices.GET(req("GET", "/api/branch-prices?branchId=" + t.branchB, { as: t.managerA })));
expect(own.body.branchId).toBe(t.branchA); // pinned to their branch
expect((await setPrice(t.managerA, t.branchA, t.water20, 150)).status).toBe(403);
expect((await prices.GET(req("GET", "/api/branch-prices", { as: t.cashierA }))).status).toBe(403);
});
it("follows the plan: Growth and trials may set prices, a paid Starter may only reset them", async () => {
const t: Tenant = await makeTenant();
await Business.updateOne({ _id: t.businessId }, { tier: "STARTER", subscriptionStatus: "TRIALING" });
expect((await setPrice(t.owner, t.branchA, t.water20, 180)).status).toBe(200);
await Business.updateOne({ _id: t.businessId }, { tier: "STARTER", subscriptionStatus: "ACTIVE" });
expect((await setPrice(t.owner, t.branchA, t.water20, 170)).status).toBe(403);
// Prices set during the trial keep applying until reset.
expect(await tillPrice(t.cashierA, t.water20)).toBe(180);
const reset = await prices.DELETE(
req("DELETE", `/api/branch-prices?branchId=${t.branchA}&productId=${t.water20}`, { as: t.owner })
);
expect(reset.status).toBe(200);
expect(await tillPrice(t.cashierA, t.water20)).toBe(200);
});
it("rejects zero prices and another tenant's branch or product", async () => {
const t = await makeTenant("Alpha");
const other = await makeTenant("Beta");
expect((await setPrice(t.owner, t.branchA, t.water20, 0)).status).toBe(400);
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 prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { stockOf } from "@/lib/server/stock";
import { StockTransfer } from "@/lib/server/models/StockTransfer";
import { StockLock } from "@/lib/server/models/StockLock";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
async function openingStock(t: Tenant, quantity: number) {
await adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "DELTA", quantity },
})
);
}
const transferOut = (t: Tenant, quantity: number) =>
transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity },
})
);
describe("concurrent stock changes", () => {
it("never lets simultaneous transfers send more than the branch holds", async () => {
const t = await makeTenant();
await openingStock(t, 10);
// Five managers press "Record transfer" for 4 units at the same moment.
const statuses = (await Promise.all(Array.from({ length: 5 }, () => transferOut(t, 4)))).map((r) => r.status);
expect(statuses.filter((s) => s === 201)).toHaveLength(2);
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.branchB, t.water20)).toBe(8);
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
});
it("holds across repeated races, not just once", async () => {
for (let round = 0; round < 5; round++) {
const t = await makeTenant("Round" + round);
await openingStock(t, 9);
const statuses = (await Promise.all(Array.from({ length: 4 }, () => transferOut(t, 3)))).map((r) => r.status);
expect(statuses.filter((s) => s === 201)).toHaveLength(3);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
}
});
it("applies a double-tapped transfer once even when both taps race", async () => {
const t = await makeTenant();
await openingStock(t, 10);
const body = { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 6 };
const statuses = (
await Promise.all([
transfers.POST(req("POST", "/x", { as: t.owner, body })),
transfers.POST(req("POST", "/x", { as: t.owner, body })),
])
).map((r) => r.status);
expect(statuses.sort()).toEqual([200, 201]);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(4);
});
it("takes over a lock left behind by a crashed request once it expires", async () => {
const t = await makeTenant();
await openingStock(t, 10);
await StockLock.create({
_id: `${t.businessId}:${t.branchA}:${t.water20}`,
owner: "crashed-request",
lockedUntil: new Date(Date.now() - 1000),
});
expect((await transferOut(t, 3)).status).toBe(201);
expect(await StockLock.countDocuments({})).toBe(0);
});
it("serialises a physical count with a transfer, so the count is never applied to a stale balance", async () => {
const t = await makeTenant();
await openingStock(t, 10);
// A count of 10 and a transfer of 6 race. Whichever runs second must
// see the first: either the count resets 4 back to 10 after the
// transfer, or the transfer moves 6 out of the counted 10.
const [count, transfer] = await Promise.all([
adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "COUNT", quantity: 10 },
})
),
transferOut(t, 6),
]);
expect([count.status, transfer.status]).toEqual([201, 201]);
const a = await stockOf(t.businessId, t.branchA, t.water20);
expect([4, 10]).toContain(a);
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(6);
});
it("does not block transfers of different products or branches on each other", async () => {
const t = await makeTenant();
await openingStock(t, 10);
await adjustments.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 10 },
})
);
const results = await Promise.all([
transferOut(t, 5),
transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water5, quantity: 5 },
})
),
]);
expect(results.map((r) => r.status)).toEqual([201, 201]);
});
});
+7 -7
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 adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
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 () => {
const t = await makeTenant();
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);
await setPlan(t, "STARTER", "ACTIVE");
@@ -70,7 +70,7 @@ describe("F-06 branch deactivation", () => {
expect(withStaff.status).toBe(409);
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);
// 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 () => {
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");
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
expect(res.status).toBe(403);
@@ -152,7 +152,7 @@ describe("F-04 / F-05 / CAT-12 catalogue", () => {
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
);
await Product.updateOne({ _id: t.water5 }, { isActive: false });
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 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: 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);
});
it("does not let a manager re-invite another branch's staff", async () => {
const t = await makeTenant();
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
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 }));
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 { signAccessToken } from "@/lib/server/jwt";
import { ACCESS_COOKIE } from "@/lib/server/auth";
import { crossTenant } from "@/lib/server/tenantGuard";
let phoneSeq = 0;
function nextPhone(): string {
@@ -131,3 +132,8 @@ export async function json<T = any>(res: Response): Promise<{ status: number; bo
export function uuid(): string {
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);
}
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import bcrypt from "bcryptjs";
import { User } from "@/lib/server/models/User";
import * as users from "@/app/api/users/route";
import * as login from "@/app/api/auth/login/route";
import * as me from "@/app/api/auth/me/route";
import * as platformBusinesses from "@/app/api/platform/businesses/route";
import { ACCESS_COOKIE } from "@/lib/server/auth";
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
async function hire(t: Tenant, as: Actor, person = PERSON) {
return users.POST(
req("POST", "/api/users", { as, body: { ...person, role: "DELIVERY", branchId: t.branchA, password: "password-123" } })
);
}
const signIn = (email: string, password: string, businessId?: string) =>
login.POST(req("POST", "/api/auth/login", { body: { email, password, deviceId: uuid(), businessId } }));
async function setPassword(email: string, businessId: string, password: string) {
await User.updateOne({ email, businessId }, { passwordHash: await bcrypt.hash(password, 4) });
}
function accessCookie(res: Response): string {
const cookie = res.headers.get("set-cookie") ?? "";
return cookie.match(new RegExp(`${ACCESS_COOKIE}=([^;]+)`))![1];
}
describe("email and phone are unique per business", () => {
it("lets one person work for two businesses with the same email and phone", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
expect((await hire(a, a.owner)).status).toBe(201);
expect((await hire(b, b.owner)).status).toBe(201);
expect(await db(() => User.countDocuments({ email: PERSON.email }))).toBe(2);
});
it("still refuses a duplicate inside the same business", async () => {
const a = await makeTenant("Alpha");
await hire(a, a.owner);
const sameEmail = await hire(a, a.owner, { ...PERSON, phoneNumber: "0733000222" });
const samePhone = await hire(a, a.owner, { ...PERSON, email: "other@aquaflow.test" });
expect([sameEmail.status, samePhone.status]).toEqual([409, 409]);
});
});
describe("login when an email exists in several businesses", () => {
it("signs straight in when only one account's password matches", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "alpha-secret");
await setPassword(PERSON.email, b.businessId, "beta-secret");
const res = await json(await signIn(PERSON.email, "beta-secret"));
expect(res.status).toBe(200);
expect(res.body.user.businessId).toBe(b.businessId);
});
it("asks which business when the same password opens both, then signs in to the one chosen", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "same-secret");
await setPassword(PERSON.email, b.businessId, "same-secret");
const ask = await json(await signIn(PERSON.email, "same-secret"));
expect(ask.status).toBe(300);
expect(ask.body.chooseBusiness.map((c: any) => c.businessName).sort()).toEqual(["Alpha", "Beta"]);
const chosen = await json(await signIn(PERSON.email, "same-secret", a.businessId));
expect(chosen.status).toBe(200);
expect(chosen.body.user.businessId).toBe(a.businessId);
});
it("reveals nothing about other businesses to a wrong password", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
const res = await json(await signIn(PERSON.email, "wrong-password"));
expect(res.status).toBe(401);
expect(res.body.chooseBusiness).toBeUndefined();
expect(res.body.error).toBe("Incorrect email or password");
});
it("does not let a wrong guess at one business unlock or reset the other", async () => {
const a = await makeTenant("Alpha");
const b = await makeTenant("Beta");
await hire(a, a.owner);
await hire(b, b.owner);
await setPassword(PERSON.email, a.businessId, "alpha-secret");
await setPassword(PERSON.email, b.businessId, "beta-secret");
for (let i = 0; i < 2; i++) await signIn(PERSON.email, "wrong");
await signIn(PERSON.email, "alpha-secret"); // resets Alpha's counter only
const [alpha, beta] = await Promise.all([
User.findOne({ email: PERSON.email, businessId: a.businessId }),
User.findOne({ email: PERSON.email, businessId: b.businessId }),
]);
expect([alpha!.failedLoginAttempts, beta!.failedLoginAttempts]).toEqual([0, 2]);
});
});
describe("platform operator access", () => {
it("is not granted by an email match alone: another business cannot mint an operator", async () => {
const mallory = await makeTenant("Mallory Water");
await hire(mallory, mallory.owner, { name: "Fake operator", email: OPERATOR, phoneNumber: "0744000333" });
await setPassword(OPERATOR, mallory.businessId, "i-am-the-operator");
const res = await signIn(OPERATOR, "i-am-the-operator");
expect(res.status).toBe(200);
expect((await res.clone().json()).isPlatformAdmin).toBe(false);
const consoleRes = await platformBusinesses.GET(
req("GET", "/api/platform/businesses", { cookies: { [ACCESS_COOKIE]: accessCookie(res) } })
);
expect(consoleRes.status).toBe(403);
});
it("is granted to the flagged account whose email is listed", async () => {
const home = await makeTenant("Operator Co");
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
await setPassword(OPERATOR, home.businessId, "real-operator");
const res = await signIn(OPERATOR, "real-operator");
const cookies = { [ACCESS_COOKIE]: accessCookie(res) };
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(200);
expect((await json(await me.GET(req("GET", "/x", { cookies })))).body.user.isPlatformAdmin).toBe(true);
});
it("is revoked by removing the email from PLATFORM_ADMIN_EMAILS, even with the flag", async () => {
const home = await makeTenant("Operator Co");
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
await setPassword(OPERATOR, home.businessId, "real-operator");
const cookies = { [ACCESS_COOKIE]: accessCookie(await signIn(OPERATOR, "real-operator")) };
const listed = process.env.PLATFORM_ADMIN_EMAILS;
process.env.PLATFORM_ADMIN_EMAILS = "";
try {
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(403);
} finally {
process.env.PLATFORM_ADMIN_EMAILS = listed;
}
});
});
+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 * as cash from "@/app/api/transactions/cash/route";
import type { SaleResult } from "@/lib/syncProtocol";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
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 = {}) {
return {
@@ -31,7 +31,7 @@ describe("offline sale sync", () => {
const { status, body } = await post(t.cashierA, [s]);
expect(status).toBe(200);
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2);
expect(await 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.water5)).toBe(-3);
});
@@ -42,7 +42,7 @@ describe("offline sale sync", () => {
await post(t.cashierA, [s]);
const replay = await post(t.cashierA, [s]);
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1);
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).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 { body } = await post(t.cashierA, [otherCashiersSale]);
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 () => {
@@ -86,7 +86,7 @@ describe("offline sale sync", () => {
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
const moved = { ...t.cashierA, branchId: t.branchB };
await post(moved, [s]);
const txn = await Transaction.findOne({ saleId: s.clientUuid });
const txn = await db(() => Transaction.findOne({ saleId: s.clientUuid }));
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 { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
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 () => {
const t = await makeTenant();
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
await Product.updateOne({ _id: t.water5 }, { isActive: false });
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
const s = sale(t, t.cashierA, [
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
{ productId: t.water5, quantity: 1, unitPrice: 60 },