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
+2
View File
@@ -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
View File
@@ -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;