Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bae21ee62c | |||
| 5e0e44c647 | |||
| 9e3e59a804 | |||
| ee5bb5beb8 | |||
| 1fe2421cc4 | |||
| 0f609d57bb | |||
| 38facd3d9d |
+4
-2
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -167,9 +167,14 @@ 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 & pricing</h1>
|
<h1 className="font-display text-xl font-bold">Products & 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>
|
||||||
|
|
||||||
@@ -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's terminal. Deactivating a size hides
|
Sizes shown here appear as options on every branch'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">
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,27 +57,36 @@ 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 already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
|
const lockedBranch = branchId;
|
||||||
if (!already) {
|
try {
|
||||||
const before = await stockOf(auth.businessId, branchId, data.productId);
|
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
||||||
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
|
||||||
if (delta !== 0) {
|
if (!already) {
|
||||||
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
|
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
||||||
await recordMovement({
|
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
||||||
businessId: auth.businessId,
|
if (delta !== 0) {
|
||||||
branchId,
|
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
|
||||||
productId: data.productId,
|
await recordMovement({
|
||||||
type: "ADJUSTMENT",
|
businessId: auth.businessId,
|
||||||
quantity: delta,
|
branchId: lockedBranch,
|
||||||
refId,
|
productId: data.productId,
|
||||||
note: data.note ? `${label}. ${data.note}` : label,
|
type: "ADJUSTMENT",
|
||||||
recordedBy: auth.sub,
|
quantity: delta,
|
||||||
});
|
refId,
|
||||||
}
|
note: data.note ? `${label}. ${data.note}` : label,
|
||||||
|
recordedBy: auth.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quantity = await stockOf(auth.businessId, branchId, data.productId);
|
|
||||||
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,33 +104,49 @@ 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
|
||||||
if (available < data.quantity) {
|
// lock, so two simultaneous transfers cannot both spend the same units.
|
||||||
return NextResponse.json(
|
const sendingBranch = fromBranchId;
|
||||||
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let transfer: IStockTransfer;
|
|
||||||
try {
|
try {
|
||||||
transfer = await StockTransfer.create({
|
return await withStockLock(auth.businessId, sendingBranch, data.productId, async () => {
|
||||||
businessId: auth.businessId,
|
// An identical submit may have completed while we waited for the lock.
|
||||||
fromBranchId,
|
const done = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
|
||||||
toBranchId: data.toBranchId,
|
if (done) {
|
||||||
productId: data.productId,
|
await applyTransfer(done);
|
||||||
quantity: data.quantity,
|
return NextResponse.json({ transfer: done }, { status: 200 });
|
||||||
clientUuid: data.clientUuid,
|
}
|
||||||
note: data.note ?? null,
|
|
||||||
recordedBy: auth.sub,
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Two identical submits raced and the other one created it.
|
|
||||||
if ((err as { code?: number })?.code !== 11000) throw err;
|
|
||||||
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
|
|
||||||
}
|
|
||||||
await applyTransfer(transfer);
|
|
||||||
|
|
||||||
return NextResponse.json({ transfer }, { status: 201 });
|
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 },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer: IStockTransfer;
|
||||||
|
try {
|
||||||
|
transfer = await StockTransfer.create({
|
||||||
|
businessId: auth.businessId,
|
||||||
|
fromBranchId: sendingBranch,
|
||||||
|
toBranchId: data.toBranchId,
|
||||||
|
productId: data.productId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
clientUuid: data.clientUuid,
|
||||||
|
note: data.note ?? null,
|
||||||
|
recordedBy: auth.sub,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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 } }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user