Compare commits

...

7 Commits

Author SHA1 Message Date
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 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
26 changed files with 1228 additions and 110 deletions
+4 -2
View File
@@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d
DELIVERY_MAX_DEVICES=2 DELIVERY_MAX_DEVICES=2
# ---- Platform operator ---- # ---- Platform operator ----
# Comma-separated emails with cross-tenant access to /platform. Create these # Comma-separated emails with cross-tenant access to /platform. The account
# accounts with `npm run seed`; public signup refuses these addresses. # 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= PLATFORM_ADMIN_EMAILS=
# ---- Invite links ---- # ---- Invite links ----
+44 -8
View File
@@ -62,7 +62,7 @@ the decisions taken along the way, and what is still open.
| Decision | Why | | 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. | | 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. | | 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 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. | | 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. sales from the old version are sent on the next sync.
## Testing ## 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, enforcement, tenant isolation for sales, stock and transfers, suspension,
the sync protocol (including a 120-sale backlog replayed twice), and the the sync protocol (including a 120-sale backlog replayed twice), and the
business day. business day.
@@ -115,16 +115,52 @@ and checks every balance and total it predicts.
2. ZERRO is trialling, so it can now add Branch B without a plan change. 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. 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.
## Still open ## 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. - **Isolation by convention.** Tenant filters are still written per route.
The tests cover the sales, stock, transfer and access paths, not every The tests cover the sales, stock, transfer and access paths, not every
route. 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 - **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 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. 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", "migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts" "backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts",
"migrate:identity": "tsx scripts/migrate-per-business-identity.ts"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
+49
View File
@@ -0,0 +1,49 @@
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";
/**
* 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();
}
main().catch((err) => {
console.error("migration failed:", err.message);
process.exit(1);
});
+16 -1
View File
@@ -13,6 +13,7 @@ import { connectDb } from "../src/lib/server/db";
import { User } from "../src/lib/server/models/User"; import { User } from "../src/lib/server/models/User";
import { Product } from "../src/lib/server/models/Product"; import { Product } from "../src/lib/server/models/Product";
import { Business } from "../src/lib/server/models/Business"; import { Business } from "../src/lib/server/models/Business";
import { isPlatformAdminEmail } from "../src/lib/server/platform";
/** /**
* Run once against a fresh database: `npm run seed`. * Run once against a fresh database: `npm run seed`.
@@ -39,10 +40,23 @@ async function seed() {
} }
let business = await Business.findOne({ name: demoBusinessName }); 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) { if (existing) {
console.log(`Super admin (${email}) already exists, skipping`); 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) { if (!business) {
// Backfill a business for a legacy single-tenant admin if needed. // Backfill a business for a legacy single-tenant admin if needed.
business = await Business.create({ name: demoBusinessName, ownerId: existing._id }); business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
@@ -61,6 +75,7 @@ async function seed() {
phoneNumber: "254700000000", phoneNumber: "254700000000",
email, email,
passwordHash, passwordHash,
platformAdmin,
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
}); });
business = await Business.create({ name: demoBusinessName, ownerId: owner._id }); business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
+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> <p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Products &amp; pricing</h1> <h1 className="font-display text-xl font-bold">Products &amp; pricing</h1>
</div> </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"> <Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
← Dashboard ← Dashboard
</Link> </Link>
</div> </div>
</div>
</header> </header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5"> <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"> <p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions. it from new sales without touching past transactions.
{!canEdit && " Only the business owner can change products and prices."} {!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> </p>
{products.some((p) => !p.isActive) && ( {products.some((p) => !p.isActive) && (
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600"> <label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
+59 -17
View File
@@ -7,7 +7,8 @@ import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt"; import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth"; import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform"; import { isPlatformAdmin } from "@/lib/server/platform";
import { objectId } from "@/lib/server/validation";
import { getEnv } from "@/lib/server/env"; import { getEnv } from "@/lib/server/env";
import { tenantAccessError } from "@/lib/server/subscription"; import { tenantAccessError } from "@/lib/server/subscription";
@@ -21,6 +22,9 @@ const loginSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
deviceId: z.string().uuid(), deviceId: z.string().uuid(),
deviceLabel: z.string().max(120).optional(), 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) { async function enforceDeviceBinding(userId: string, deviceId: string) {
@@ -44,32 +48,70 @@ export async function POST(req: NextRequest) {
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
} }
const { email, password, deviceId, deviceLabel } = parsed.data; const { email, password, deviceId, deviceLabel, businessId } = parsed.data;
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash"); // An email is unique within a business, so it can belong to accounts in
if (!user || !user.isActive) { // 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 User.find(filter).select("+passwordHash");
if (candidates.length === 0) {
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 }); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
} }
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) { const now = Date.now();
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000); 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 }); return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
} }
const validPassword = await user.comparePassword(password); const matched = [];
if (!validPassword) { for (const u of unlocked) {
user.failedLoginAttempts += 1; if (await u.comparePassword(password)) matched.push(u);
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) { }
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
user.failedLoginAttempts = 0; 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 }); return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
} }
user.failedLoginAttempts = 0; // A correct password unlocks only the accounts it belongs to; a person
user.lockedUntil = null; // with a different password in another business is not affected.
await user.save(); 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); const tenantError = await tenantAccessError(user.businessId);
if (tenantError) { if (tenantError) {
@@ -123,7 +165,7 @@ export async function POST(req: NextRequest) {
email: user.email, email: user.email,
}, },
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard", redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
isPlatformAdmin: isPlatformAdminEmail(user.email), isPlatformAdmin: isPlatformAdmin(user),
}); });
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions()); res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
+2 -2
View File
@@ -4,7 +4,7 @@ import { User } from "@/lib/server/models/User";
import { Branch } from "@/lib/server/models/Branch"; import { Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business"; import { Business } from "@/lib/server/models/Business";
import { getAuth } from "@/lib/server/auth"; import { getAuth } from "@/lib/server/auth";
import { isPlatformAdminEmail } from "@/lib/server/platform"; import { isPlatformAdmin } from "@/lib/server/platform";
import { isMpesaEnabled } from "@/lib/server/env"; import { isMpesaEnabled } from "@/lib/server/env";
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers"; import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
@@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
branchName: branch?.branchName ?? null, branchName: branch?.branchName ?? null,
phoneNumber: user.phoneNumber, phoneNumber: user.phoneNumber,
email: user.email, email: user.email,
isPlatformAdmin: isPlatformAdminEmail(user.email), isPlatformAdmin: isPlatformAdmin(user),
}, },
subscription: { subscription: {
tier, tier,
+6 -12
View File
@@ -53,22 +53,16 @@ export async function POST(req: NextRequest) {
const lowerEmail = email.toLowerCase(); const lowerEmail = email.toLowerCase();
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS) // Operator addresses are reserved, so nobody can open a business that
// and signup does not verify email ownership, so an operator address // looks like the operator's. (Access itself also needs the platformAdmin
// must never be claimable through public signup. Operator accounts are // flag, which signup never sets.)
// created with `npm run seed` instead.
if (isPlatformAdminEmail(lowerEmail)) { if (isPlatformAdminEmail(lowerEmail)) {
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 }); return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
} }
const emailTaken = await User.findOne({ email: lowerEmail }).lean(); // No platform-wide duplicate check: a new business has no users yet, and
if (emailTaken) { // the same person may already work for another business. (Checking would
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 }); // also tell anyone whether an email or phone is registered elsewhere.)
}
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
if (phoneTaken) {
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
+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(
{ 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 });
}
+6 -5
View File
@@ -71,14 +71,15 @@ export async function POST(req: NextRequest) {
} catch (e) { } catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 400 }); 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 lowerEmail = manager.email.toLowerCase();
const emailTaken = await User.findOne({ email: lowerEmail }).lean(); const emailTaken = await User.exists({ businessId, email: lowerEmail });
if (emailTaken) { 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) { 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 });
} }
} }
@@ -107,7 +108,7 @@ export async function POST(req: NextRequest) {
} catch (err: any) { } catch (err: any) {
await Branch.deleteOne({ _id: branch._id }).catch(() => {}); await Branch.deleteOne({ _id: branch._id }).catch(() => {});
if (err?.code === 11000) { if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 }); return NextResponse.json({ error: "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 }); return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
} }
+3 -1
View File
@@ -24,7 +24,9 @@ export async function GET(req: NextRequest) {
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role); req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true }; const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean(); 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 overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) { for (const p of products) {
+15 -6
View File
@@ -6,7 +6,7 @@ import { Product } from "@/lib/server/models/Product";
import { StockMovement } from "@/lib/server/models/StockMovement"; import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, MANAGERS } from "@/lib/server/auth"; import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation"; 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"; 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 (!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 (!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 refId = clientRefId(auth.businessId, data.clientUuid);
const lockedBranch = branchId;
try {
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId }); const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
if (!already) { 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; const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) { if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment"; const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({ await recordMovement({
businessId: auth.businessId, businessId: auth.businessId,
branchId, branchId: lockedBranch,
productId: data.productId, productId: data.productId,
type: "ADJUSTMENT", type: "ADJUSTMENT",
quantity: delta, quantity: delta,
@@ -77,7 +82,11 @@ export async function POST(req: NextRequest) {
}); });
} }
} }
return stockOf(auth.businessId, lockedBranch, data.productId);
const quantity = await stockOf(auth.businessId, branchId, data.productId); });
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 }); 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;
}
} }
+22 -6
View File
@@ -6,7 +6,7 @@ import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer"; import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -104,8 +104,20 @@ export async function POST(req: NextRequest) {
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 }); if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// Transfers are made online by a manager, so unlike offline sales they // Transfers are made online by a manager, so unlike offline sales they
// can be refused when the sending branch does not have the stock. // can be refused when the sending branch does not have the stock. The
const available = await stockOf(auth.businessId, fromBranchId, data.productId); // 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) { if (available < data.quantity) {
return NextResponse.json( return NextResponse.json(
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available }, { error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
@@ -117,7 +129,7 @@ export async function POST(req: NextRequest) {
try { try {
transfer = await StockTransfer.create({ transfer = await StockTransfer.create({
businessId: auth.businessId, businessId: auth.businessId,
fromBranchId, fromBranchId: sendingBranch,
toBranchId: data.toBranchId, toBranchId: data.toBranchId,
productId: data.productId, productId: data.productId,
quantity: data.quantity, quantity: data.quantity,
@@ -126,11 +138,15 @@ export async function POST(req: NextRequest) {
recordedBy: auth.sub, recordedBy: auth.sub,
}); });
} catch (err: unknown) { } 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; if ((err as { code?: number })?.code !== 11000) throw err;
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!; transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
} }
await applyTransfer(transfer); await applyTransfer(transfer);
return NextResponse.json({ transfer }, { status: 201 }); return NextResponse.json({ transfer }, { status: 201 });
});
} catch (err) {
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
} }
+1 -1
View File
@@ -115,7 +115,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 }); return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
} catch (err: any) { } catch (err: any) {
if (err?.code === 11000) { if (err?.code === 11000) {
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 }); return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 });
} }
throw err; throw err;
} }
+52 -2
View File
@@ -12,6 +12,18 @@ interface ActiveDevice {
lastUsedAt: string; 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -21,6 +33,8 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null); const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
// 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); const [invited, setInvited] = useState(false);
useEffect(() => { useEffect(() => {
@@ -29,6 +43,10 @@ export default function LoginPage() {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
await signIn();
}
async function signIn(businessId?: string) {
setError(null); setError(null);
setDeviceConflict(null); setDeviceConflict(null);
setLoading(true); setLoading(true);
@@ -41,11 +59,15 @@ export default function LoginPage() {
password, password,
deviceId: getDeviceId(), deviceId: getDeviceId(),
deviceLabel: getDeviceLabel(), deviceLabel: getDeviceLabel(),
businessId,
}), }),
}); });
router.replace(result.redirectTo); router.replace(result.redirectTo);
return;
} catch (err) { } 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[]) ?? []); setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
setError(err.message); setError(err.message);
} else if (err instanceof ApiError) { } else if (err instanceof ApiError) {
@@ -75,7 +97,35 @@ export default function LoginPage() {
Run every branch with confidence — sales, stock, M-Pesa and cash. Run every branch with confidence — sales, stock, M-Pesa and cash.
</p> </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"> <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> <p className="text-sm font-medium text-alert-600">{error}</p>
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
+2
View File
@@ -41,6 +41,7 @@ export interface IBranchPricing extends Document {
branchId: Types.ObjectId; branchId: Types.ObjectId;
productId: Types.ObjectId; productId: Types.ObjectId;
unitPrice: number; unitPrice: number;
updatedBy: Types.ObjectId | null;
updatedAt: Date; updatedAt: Date;
} }
@@ -50,6 +51,7 @@ const branchPricingSchema = new Schema<IBranchPricing>(
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
unitPrice: { type: Number, required: true, min: 0 }, unitPrice: { type: Number, required: true, min: 0 },
updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
}, },
{ timestamps: { createdAt: false, updatedAt: true } } { timestamps: { createdAt: false, updatedAt: true } }
); );
+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);
+18 -2
View File
@@ -10,8 +10,16 @@ export interface IUser extends Document {
name: string; name: string;
role: UserRole; role: UserRole;
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner) 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 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 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; passwordHash: string;
isActive: boolean; isActive: boolean;
failedLoginAttempts: number; failedLoginAttempts: number;
@@ -33,8 +41,9 @@ const userSchema = new Schema<IUser>(
name: { type: String, required: true, trim: true }, name: { type: String, required: true, trim: true },
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" }, role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null }, branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
phoneNumber: { type: String, required: true, unique: true, index: true }, phoneNumber: { type: String, required: true },
email: { type: String, required: true, unique: true, lowercase: true, trim: true }, email: { type: String, required: true, lowercase: true, trim: true },
platformAdmin: { type: Boolean, default: false },
passwordHash: { type: String, required: true, select: false }, passwordHash: { type: String, required: true, select: false },
isActive: { type: Boolean, default: true }, isActive: { type: Boolean, default: true },
failedLoginAttempts: { type: Number, default: 0 }, failedLoginAttempts: { type: Number, default: 0 },
@@ -45,6 +54,13 @@ const userSchema = new Schema<IUser>(
{ timestamps: true } { 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 // Enforce role/branch invariants at the model layer — belt-and-braces
// alongside API-level validation. Every role authenticates the same way // alongside API-level validation. Every role authenticates the same way
// now (email + password on the single /login page), so there is no // now (email + password on the single /login page), so there is no
+14 -5
View File
@@ -26,21 +26,30 @@ import type { NextRequest } from "next/server";
import { getAuth } from "@/lib/server/auth"; import { getAuth } from "@/lib/server/auth";
import { User } from "@/lib/server/models/User"; 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. * Resolve and authorize the current request as a platform operator.
* Returns the authenticated User's id/email on success, or null if the * 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. * 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( export async function requirePlatformAdmin(
req: NextRequest req: NextRequest
): Promise<{ userId: string; email: string } | null> { ): Promise<{ userId: string; email: string } | null> {
const auth = getAuth(req); const auth = getAuth(req);
if (!auth) return null; if (!auth) return null;
const user = await User.findById(auth.sub).select("email isActive").lean(); const user = await User.findById(auth.sub).select("email isActive platformAdmin").lean();
if (!user || !user.isActive) return null; if (!user || !user.isActive) return null;
if (!isPlatformAdminEmail(user.email)) return null; if (!isPlatformAdmin(user)) return null;
return { userId: auth.sub, email: user.email }; return { userId: auth.sub, email: user.email };
} }
+56
View File
@@ -3,6 +3,7 @@ import { StockMovement, type MovementType } from "@/lib/server/models/StockMovem
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Product } from "@/lib/server/models/Product"; import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase"; import { Purchase } from "@/lib/server/models/Purchase";
import { StockLock } from "@/lib/server/models/StockLock";
import type { ITransaction } from "@/lib/server/models/Transaction"; import type { ITransaction } from "@/lib/server/models/Transaction";
type Id = Types.ObjectId | string; type Id = Types.ObjectId | string;
@@ -173,3 +174,58 @@ export async function backfillPurchaseMovements(businessId?: Id): Promise<number
} }
return added; 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 });
}
}
+13
View File
@@ -13,6 +13,7 @@ export interface TierDef {
priceKsh: number; // monthly price in KSh priceKsh: number; // monthly price in KSh
maxBranches: number | null; // null = unlimited maxBranches: number | null; // null = unlimited
maxUsers: number | null; // total users in the business (owner + managers + staff); 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[]; features: string[];
} }
@@ -23,6 +24,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 3500, priceKsh: 3500,
maxBranches: 1, maxBranches: 1,
maxUsers: 6, // owner + up to 5 staff/manager maxUsers: 6, // owner + up to 5 staff/manager
branchPricing: false,
features: [ features: [
"1 branch", "1 branch",
"Up to 5 staff accounts", "Up to 5 staff accounts",
@@ -37,6 +39,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 7500, priceKsh: 7500,
maxBranches: 5, maxBranches: 5,
maxUsers: 30, maxUsers: 30,
branchPricing: true,
features: [ features: [
"Up to 5 branches", "Up to 5 branches",
"Up to 30 staff accounts", "Up to 30 staff accounts",
@@ -51,6 +54,7 @@ export const TIERS: Record<TierId, TierDef> = {
priceKsh: 15000, priceKsh: 15000,
maxBranches: null, maxBranches: null,
maxUsers: null, maxUsers: null,
branchPricing: true,
features: [ features: [
"Unlimited branches", "Unlimited branches",
"Unlimited staff accounts", "Unlimited staff accounts",
@@ -72,6 +76,15 @@ export const TRIAL_DAYS = 14;
// applies to adding branches; existing branches are never removed. // applies to adding branches; existing branches are never removed.
export const TRIAL_MAX_BRANCHES = 2; 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). */ /** Branch limit for a business right now (null = unlimited). */
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null { export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
const max = TIERS[tier].maxBranches; const max = TIERS[tier].maxBranches;
+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 } 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 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 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 } 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 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]);
});
});
+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 } 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 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;
}
});
});