Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe2421cc4 | |||
| 0f609d57bb | |||
| 38facd3d9d |
+17
-3
@@ -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.
|
||||
|
||||
@@ -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,10 +167,15 @@ export default function ProductsPage() {
|
||||
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
||||
<h1 className="font-display text-xl font-bold">Products & pricing</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href="/admin/prices" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||
Branch prices
|
||||
</Link>
|
||||
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||
← Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
|
||||
@@ -184,7 +189,12 @@ export default function ProductsPage() {
|
||||
<p className="text-xs text-depth-500">
|
||||
Sizes shown here appear as options on every branch'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">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface IBranchPricing extends Document {
|
||||
branchId: Types.ObjectId;
|
||||
productId: Types.ObjectId;
|
||||
unitPrice: number;
|
||||
updatedBy: Types.ObjectId | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ const branchPricingSchema = new Schema<IBranchPricing>(
|
||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||
unitPrice: { type: Number, required: true, min: 0 },
|
||||
updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||
},
|
||||
{ timestamps: { createdAt: false, updatedAt: true } }
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TierDef {
|
||||
priceKsh: number; // monthly price in KSh
|
||||
maxBranches: number | null; // null = unlimited
|
||||
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
|
||||
branchPricing: boolean; // may set a different price per branch
|
||||
features: string[];
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
||||
priceKsh: 3500,
|
||||
maxBranches: 1,
|
||||
maxUsers: 6, // owner + up to 5 staff/manager
|
||||
branchPricing: false,
|
||||
features: [
|
||||
"1 branch",
|
||||
"Up to 5 staff accounts",
|
||||
@@ -37,6 +39,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
||||
priceKsh: 7500,
|
||||
maxBranches: 5,
|
||||
maxUsers: 30,
|
||||
branchPricing: true,
|
||||
features: [
|
||||
"Up to 5 branches",
|
||||
"Up to 30 staff accounts",
|
||||
@@ -51,6 +54,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
||||
priceKsh: 15000,
|
||||
maxBranches: null,
|
||||
maxUsers: null,
|
||||
branchPricing: true,
|
||||
features: [
|
||||
"Unlimited branches",
|
||||
"Unlimited staff accounts",
|
||||
@@ -72,6 +76,15 @@ export const TRIAL_DAYS = 14;
|
||||
// applies to adding branches; existing branches are never removed.
|
||||
export const TRIAL_MAX_BRANCHES = 2;
|
||||
|
||||
/**
|
||||
* Whether the business may set branch-specific prices right now: a plan
|
||||
* feature (Growth and up), also available during the free trial so it can
|
||||
* be tried with the trial's two branches.
|
||||
*/
|
||||
export function canUseBranchPricing(tier: TierId, status: SubscriptionStatus): boolean {
|
||||
return TIERS[tier].branchPricing || status === "TRIALING";
|
||||
}
|
||||
|
||||
/** Branch limit for a business right now (null = unlimited). */
|
||||
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
|
||||
const max = TIERS[tier].maxBranches;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user