From 0f609d57bb29713849881f7536b31a286fdf7792 Mon Sep 17 00:00:00 2001 From: denisireri Date: Mon, 12 Oct 2026 10:45:37 +0300 Subject: [PATCH] Add a screen to set prices per branch Branch price overrides were already applied at the till, in cash sales and in M-Pesa, but nothing could set them. - Products -> Branch prices (/admin/prices): pick a branch and set its price for any product; setting it back to the default removes the override, and "Use default" resets it. Branch managers see their own branch's prices read-only. - /api/branch-prices: GET (owner: any branch; manager: own), PUT and DELETE (owner only). Branch and product must belong to the business; prices must be above zero; who changed a price is recorded. - Branch pricing is a plan feature, as the Growth plan already advertised: Growth and Enterprise, plus any business on its free trial. A paid Starter business keeps any prices set during its trial and can reset them, but cannot set new ones. - The catalogue view on the Products page always shows default prices; the till's view shows what the branch charges. Co-Authored-By: Claude Opus 5.5 --- docs/PILOT_READINESS.md | 20 ++- src/app/admin/prices/page.tsx | 278 +++++++++++++++++++++++++++++ src/app/admin/products/page.tsx | 12 +- src/app/api/branch-prices/route.ts | 131 ++++++++++++++ src/app/api/products/route.ts | 4 +- src/lib/server/models/Product.ts | 2 + src/lib/tiers.ts | 13 ++ tests/branchPrices.test.ts | 90 ++++++++++ 8 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 src/app/admin/prices/page.tsx create mode 100644 src/app/api/branch-prices/route.ts create mode 100644 tests/branchPrices.test.ts diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md index 17b167d..f86b090 100644 --- a/docs/PILOT_READINESS.md +++ b/docs/PILOT_READINESS.md @@ -76,7 +76,7 @@ the decisions taken along the way, and what is still open. sales from the old version are sent on the next sync. ## Testing -- `npm test` runs 50 API tests against an in-memory MongoDB, covering role +- `npm test` runs 56 API tests against an in-memory MongoDB, covering role enforcement, tenant isolation for sales, stock and transfers, suspension, the sync protocol (including a 120-sale backlog replayed twice), and the business day. @@ -115,9 +115,23 @@ and checks every balance and total it predicts. 2. ZERRO is trialling, so it can now add Branch B without a plan change. 3. Create the manager and cashier logins with invite links from Staff. +## Branch prices + +Owners set a different price per branch on **Products -> Branch prices** +(`/admin/prices`, API `/api/branch-prices`). Setting a price back to the +default removes the override. Branch managers see their own branch's +prices read-only. + +- It is a Growth/Enterprise feature (as the plan list already advertised), + also available during the free trial. After a trial on Starter, existing + branch prices keep applying and can be reset, but not changed. +- The till, cash sales and M-Pesa all read the branch price. A phone picks + up a change the next time it loads products; a sale it records offline in + the meantime is kept at the server's price and flagged as a price + mismatch. +- The Products page always shows default prices. + ## 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. diff --git a/src/app/admin/prices/page.tsx b/src/app/admin/prices/page.tsx new file mode 100644 index 0000000..86dd411 --- /dev/null +++ b/src/app/admin/prices/page.tsx @@ -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(null); + const [branches, setBranches] = useState([]); + const [branchId, setBranchId] = useState(""); + const [data, setData] = useState(null); + const [drafts, setDrafts] = useState>({}); + const [savingId, setSavingId] = useState(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("/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 ( +
+
+
+
+
+ +
+
+ ); + } + + const canEdit = isOwner && !!data?.allowed; + const overrides = data?.prices.filter((p) => p.branchPrice !== null).length ?? 0; + + return ( +
+
+
+
+

{me.businessName ?? "Vessel"}

+

Branch prices

+
+ + โ† Products + +
+
+ +
+ {branches.length === 0 ? ( +
+ +
+ ) : ( +
+
+ {isOwner ? ( +
+ + +
+ ) : ( +

{data?.branchName ?? "Your branch"}

+ )} +

+ 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."} +

+ {isOwner && data && !data.allowed && ( +

+ Branch prices are part of the Growth plan. Existing branch prices still apply and can be reset to + the default. +

+ )} + {data &&

{overrides} product(s) priced differently here

} +
+ + {!data ? ( + + ) : data.prices.length === 0 ? ( + + ) : ( +
    + {data.prices.map((row) => { + const custom = row.branchPrice !== null; + const draft = drafts[row.productId] ?? ""; + const changed = parseFloat(draft) !== (row.branchPrice ?? row.defaultPrice); + return ( +
  • +
    +

    + {row.label}{" "} + ยท {formatSize(row.sizeLiters, row.unit)} + {!row.isActive && (deactivated)} +

    +

    + Default KES {row.defaultPrice.toLocaleString()} + {custom && ( + + This branch: KES {row.branchPrice!.toLocaleString()} + + )} +

    +
    + {canEdit ? ( +
    + KES + 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" + /> + + {custom && ( + + )} +
    + ) : ( +
    + + KES {(row.branchPrice ?? row.defaultPrice).toLocaleString()} + + {isOwner && custom && ( + + )} +
    + )} +
  • + ); + })} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx index a15eba8..5922a2c 100644 --- a/src/app/admin/products/page.tsx +++ b/src/app/admin/products/page.tsx @@ -167,9 +167,14 @@ export default function ProductsPage() {

Vessel

Products & pricing

+
+ + Branch prices + โ† Dashboard +
@@ -184,7 +189,12 @@ export default function ProductsPage() {

Sizes shown here appear as options on every branch's terminal. Deactivating a size hides it from new sales without touching past transactions. - {!canEdit && " Only the business owner can change products and prices."} + {!canEdit && " Only the business owner can change products and prices."} Prices here are the + defaults; a branch can charge its own price under{" "} + + Branch prices + + .

{products.some((p) => !p.isActive) && (