Add a screen to set prices per branch

Branch price overrides were already applied at the till, in cash sales
and in M-Pesa, but nothing could set them.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:45:37 +03:00
parent 38facd3d9d
commit 0f609d57bb
8 changed files with 545 additions and 5 deletions
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthUser, Branch } from "@/lib/types";
import { formatSize } from "@/lib/units";
import { useToast } from "@/components/ui/Toast";
import { SkeletonList } from "@/components/ui/Skeleton";
import EmptyState from "@/components/ui/EmptyState";
interface PriceRow {
productId: string;
label: string;
unit: string;
sizeLiters: number;
isActive: boolean;
defaultPrice: number;
branchPrice: number | null;
updatedAt: string | null;
}
interface PricesResponse {
branchId: string;
branchName: string;
allowed: boolean;
prices: PriceRow[];
}
/**
* Branch prices: what each branch charges for each product. Owners edit;
* branch managers see their own branch's prices.
*/
export default function BranchPricesPage() {
const router = useRouter();
const { show } = useToast();
const [me, setMe] = useState<AuthUser | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [branchId, setBranchId] = useState("");
const [data, setData] = useState<PricesResponse | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingId, setSavingId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const isOwner = me?.role === "SUPER_ADMIN";
useEffect(() => {
(async () => {
try {
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
if (result.user.role === "DELIVERY") {
router.replace("/terminal");
return;
}
setMe(result.user);
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
setBranches(br.branches);
const first = result.user.role === "SUPER_ADMIN" ? br.branches[0]?._id : result.user.branchId;
if (first) setBranchId(first);
} catch {
router.replace("/login");
return;
}
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!branchId) return;
loadPrices(branchId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [branchId]);
async function loadPrices(id: string) {
try {
const res = await apiFetch<PricesResponse>("/api/branch-prices?branchId=" + id);
setData(res);
setDrafts(Object.fromEntries(res.prices.map((p) => [p.productId, String(p.branchPrice ?? p.defaultPrice)])));
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not load prices", "error");
}
}
async function save(row: PriceRow) {
const price = parseFloat(drafts[row.productId]);
if (!Number.isFinite(price) || price <= 0) {
show("Enter a price above zero", "error");
return;
}
setSavingId(row.productId);
try {
await apiFetch("/api/branch-prices", {
method: "PUT",
body: JSON.stringify({ branchId, productId: row.productId, unitPrice: price }),
});
show(
price === row.defaultPrice
? `${row.label} back to the default price`
: `${row.label} now KES ${price.toLocaleString()} at ${data?.branchName}`,
"success"
);
await loadPrices(branchId);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not save the price", "error");
}
setSavingId(null);
}
async function reset(row: PriceRow) {
setSavingId(row.productId);
try {
await apiFetch(`/api/branch-prices?branchId=${branchId}&productId=${row.productId}`, { method: "DELETE" });
show(`${row.label} back to the default price`, "success");
await loadPrices(branchId);
} catch (err) {
show(err instanceof ApiError ? err.message : "Could not reset the price", "error");
}
setSavingId(null);
}
if (loading || !me) {
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8">
<div className="mx-auto h-14 max-w-3xl" />
</header>
<div className="mx-auto -mt-4 max-w-3xl px-5">
<SkeletonList rows={4} />
</div>
</main>
);
}
const canEdit = isOwner && !!data?.allowed;
const overrides = data?.prices.filter((p) => p.branchPrice !== null).length ?? 0;
return (
<main className="min-h-screen bg-sand-50 pb-12">
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
<h1 className="font-display text-xl font-bold">Branch prices</h1>
</div>
<Link
href="/admin/products"
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
>
← Products
</Link>
</div>
</header>
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
{branches.length === 0 ? (
<div className="rounded-xl2 bg-white shadow-soft">
<EmptyState icon="🏢" title="No branches yet" description="Create a branch first, then set its prices here." />
</div>
) : (
<div className="rounded-xl2 bg-white shadow-soft">
<div className="space-y-3 border-b border-depth-100 px-4 py-3">
{isOwner ? (
<div>
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price-branch">
Branch
</label>
<select
id="price-branch"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3 sm:w-72"
>
{branches.map((b) => (
<option key={b._id} value={b._id}>
{b.branchName} — {b.locationCity}
</option>
))}
</select>
</div>
) : (
<h2 className="font-display font-semibold text-depth-900">{data?.branchName ?? "Your branch"}</h2>
)}
<p className="text-xs text-depth-500">
Each product sells at its default price unless this branch has its own price. Cashiers at the branch
see the new price the next time their till loads products.
{!isOwner && " Only the business owner can change prices."}
</p>
{isOwner && data && !data.allowed && (
<p className="rounded-lg bg-cash-500/10 px-3 py-2 text-sm text-cash-700">
Branch prices are part of the Growth plan. Existing branch prices still apply and can be reset to
the default.
</p>
)}
{data && <p className="text-xs font-medium text-depth-600">{overrides} product(s) priced differently here</p>}
</div>
{!data ? (
<SkeletonList rows={3} />
) : data.prices.length === 0 ? (
<EmptyState icon="🧴" title="No products yet" description="Add products first on the Products page." />
) : (
<ul className="divide-y divide-depth-100">
{data.prices.map((row) => {
const custom = row.branchPrice !== null;
const draft = drafts[row.productId] ?? "";
const changed = parseFloat(draft) !== (row.branchPrice ?? row.defaultPrice);
return (
<li key={row.productId} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="font-medium text-depth-900">
{row.label}{" "}
<span className="text-depth-500">· {formatSize(row.sizeLiters, row.unit)}</span>
{!row.isActive && <span className="ml-2 text-xs text-depth-500">(deactivated)</span>}
</p>
<p className="text-xs text-depth-500">
Default KES {row.defaultPrice.toLocaleString()}
{custom && (
<span className="ml-2 rounded-full bg-flow-500/15 px-2 py-0.5 font-medium text-flow-700">
This branch: KES {row.branchPrice!.toLocaleString()}
</span>
)}
</p>
</div>
{canEdit ? (
<div className="flex items-center gap-2">
<span className="text-sm text-depth-500">KES</span>
<input
type="number"
min="0.01"
step="0.01"
inputMode="decimal"
value={draft}
onChange={(e) => setDrafts((d) => ({ ...d, [row.productId]: e.target.value }))}
aria-label={`Price of ${row.label} at this branch`}
className="w-24 rounded-lg border border-depth-200 px-2 py-1.5 text-sm"
/>
<button
onClick={() => save(row)}
disabled={!changed || savingId === row.productId}
className="rounded-lg bg-flow-500 px-3 py-1.5 text-sm font-semibold text-depth-950 disabled:opacity-40"
>
Save
</button>
{custom && (
<button
onClick={() => reset(row)}
disabled={savingId === row.productId}
className="text-xs text-depth-500 underline"
>
Use default
</button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-depth-900">
KES {(row.branchPrice ?? row.defaultPrice).toLocaleString()}
</span>
{isOwner && custom && (
<button onClick={() => reset(row)} className="text-xs text-depth-500 underline">
Use default
</button>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
)}
</div>
</main>
);
}
+11 -1
View File
@@ -167,9 +167,14 @@ export default function ProductsPage() {
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
<h1 className="font-display text-xl font-bold">Products &amp; pricing</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link href="/admin/prices" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
Branch prices
</Link>
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
← Dashboard
</Link>
</div>
</div>
</header>
@@ -184,7 +189,12 @@ export default function ProductsPage() {
<p className="text-xs text-depth-500">
Sizes shown here appear as options on every branch&apos;s terminal. Deactivating a size hides
it from new sales without touching past transactions.
{!canEdit && " Only the business owner can change products and prices."}
{!canEdit && " Only the business owner can change products and prices."} Prices here are the
defaults; a branch can charge its own price under{" "}
<Link href="/admin/prices" className="underline underline-offset-2">
Branch prices
</Link>
.
</p>
{products.some((p) => !p.isActive) && (
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
+131
View File
@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { connectDb } from "@/lib/server/db";
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { Business } from "@/lib/server/models/Business";
import { Product, BranchPricing } from "@/lib/server/models/Product";
import { authorize, badRequest, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { canUseBranchPricing, type SubscriptionStatus, type TierId } from "@/lib/tiers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Branch-specific prices. A branch charges its own price for a product when
* one is set here, otherwise the product's default price. The till, the
* cash-sale route and M-Pesa all read prices the same way, so a change here
* applies to the next sale at that branch (a phone that is offline picks it
* up when it next loads products; a sale it records meanwhile is kept at the
* server's price and flagged as a price mismatch).
*/
async function pricingAllowed(businessId: string): Promise<boolean> {
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
return canUseBranchPricing((biz?.tier as TierId) ?? "STARTER", (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING");
}
// GET /api/branch-prices?branchId= — every product with its default and
// branch price. Owners pick any branch; managers see their own.
export async function GET(req: NextRequest) {
await connectDb();
const gate = authorize(req, MANAGERS);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const requested = req.nextUrl.searchParams.get("branchId");
const branchId = auth.role === "SUPER_ADMIN" ? requested : auth.branchId;
if (!branchId) return badRequest("branchId is required");
if (!objectId.safeParse(branchId).success) return badRequest("Invalid branchId");
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
const [products, overrides, allowed] = await Promise.all([
Product.find({ businessId: auth.businessId }).sort({ sortOrder: 1 }).lean(),
BranchPricing.find({ businessId: auth.businessId, branchId }).lean(),
pricingAllowed(auth.businessId),
]);
const byProduct = new Map(overrides.map((o) => [o.productId.toString(), o]));
const prices = products
// Deactivated products only matter here if they still carry a branch price.
.filter((p) => p.isActive || byProduct.has(p._id.toString()))
.map((p) => {
const o = byProduct.get(p._id.toString());
return {
productId: p._id.toString(),
label: p.label,
unit: p.unit,
sizeLiters: p.sizeLiters,
isActive: p.isActive,
defaultPrice: p.unitPrice,
branchPrice: o?.unitPrice ?? null,
updatedAt: o?.updatedAt ?? null,
};
});
return NextResponse.json({ branchId, branchName: branch.branchName, allowed, prices });
}
const setSchema = z.object({
branchId: objectId,
productId: objectId,
unitPrice: z.number().positive("Price must be more than zero").max(10_000_000),
});
// PUT /api/branch-prices — owner sets one product's price at one branch.
// Setting it to the default price removes the override.
export async function PUT(req: NextRequest) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const parsed = setSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return badRequest(parsed.error.issues[0]?.message ?? "Invalid request");
const { branchId, productId, unitPrice } = parsed.data;
if (!(await pricingAllowed(auth.businessId))) {
return NextResponse.json(
{ error: "Branch prices are part of the Growth plan. Upgrade to set a different price per branch." },
{ status: 403 }
);
}
const [branch, product] = await Promise.all([
Branch.exists({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }),
Product.findOne({ _id: productId, businessId: auth.businessId }).select("unitPrice").lean(),
]);
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
if (unitPrice === product.unitPrice) {
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
return NextResponse.json({ branchId, productId, branchPrice: null, defaultPrice: product.unitPrice });
}
const row = await BranchPricing.findOneAndUpdate(
{ branchId, productId },
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).lean();
return NextResponse.json({ branchId, productId, branchPrice: row!.unitPrice, defaultPrice: product.unitPrice });
}
// DELETE /api/branch-prices?branchId=&productId= — back to the default price.
// Always allowed, even after the plan no longer includes branch prices.
export async function DELETE(req: NextRequest) {
await connectDb();
const gate = authorize(req, ["SUPER_ADMIN"]);
if (!gate.ok) return gate.response;
const auth = gate.auth;
const branchId = req.nextUrl.searchParams.get("branchId");
const productId = req.nextUrl.searchParams.get("productId");
if (!objectId.safeParse(branchId).success || !objectId.safeParse(productId).success) {
return badRequest("branchId and productId are required");
}
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
return NextResponse.json({ ok: true });
}
+3 -1
View File
@@ -24,7 +24,9 @@ export async function GET(req: NextRequest) {
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
if (auth.branchId) {
// The catalogue view (includeInactive) shows default prices; the till's
// view shows what this branch actually charges.
if (auth.branchId && !includeInactive) {
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
for (const p of products) {