Hello from MCP server
/**
* Tier Pricing Calculator
*
* Calculates prices for all tiers in a menu using the pricing formula.
* This module extracts pricing logic from MenuTemplateLegacy.vue so that
* prices can be calculated once in CheckHours.vue and stored in the job.
*
* Strategy:
* 1. Calculate base price ratios from database values (zero extra hours)
* 2. Use the new pricingFormula for the lowest tier (band-aid) with actual hours
* 3. Scale other tier prices using the base ratios
* 4. Calculate implied hourly rate for each tier (for pricing additional hours)
*/
import {
calculateWithImpliedRate,
createPricingVars,
getImpliedRate,
DEFAULT_STANDARD_DEDUCTION,
DEFAULT_ADDITIONAL_HOUR_DISCOUNT,
type PricingResult,
type PricingVars,
} from '@/lib/pricingFormula';
// Re-export pricing formula utilities for convenience
export {
calculateWithImpliedRate,
createPricingVars,
getImpliedRate,
DEFAULT_STANDARD_DEDUCTION,
DEFAULT_ADDITIONAL_HOUR_DISCOUNT,
type PricingResult,
type PricingVars,
};
export interface OrgVars {
hourlyFee: number;
serviceCallFee: number;
saDiscount: number;
salesTax: number;
}
export interface TierWithPrices {
id: string;
name: string;
title?: string;
offer: any;
menuCopy?: any[];
contentItems?: any[];
warranty?: string;
price: number;
discountPrice: number;
impliedHourlyRate: number; // Rate used for pricing additional hours
pricingDetails?: PricingResult; // Full breakdown of pricing calculation
}
export interface MenuDataWithPrices {
id: string;
name: string;
tiers: TierWithPrices[];
}
/**
* Get material cost from an offer's costsMaterial array
*/
function getMaterialCost(offer: any): number {
return (offer?.costsMaterial || []).reduce(
(total: number, item: any) => total + (Number(item?.quantity) || 0),
0
);
}
/**
* Get time cost from an offer's costsTime array
*/
function getTimeCost(offer: any): number {
return (offer?.costsTime || []).reduce(
(total: number, item: any) => total + (item?.hours || 0),
0
);
}
/**
* Calculate base tier price ratios using database hours (zero extra hours)
* This locks the tier price relationships based on original database values
*/
function calculateBaseTierRatios(tiers: any[], orgVars: OrgVars) {
const basePrices: { tierId: string; price: number }[] = [];
// Calculate all tier prices with ZERO extra hours using DATABASE hours
for (const tier of tiers) {
if (!tier.offer) continue;
const materialCost = getMaterialCost(tier.offer);
const timeCost = getTimeCost(tier.offer);
const multiplier = parseFloat(tier.offer.multiplier) || 1;
// Use new pricing formula for base price calculation
const pricingVars = createPricingVars({
material: materialCost,
time: timeCost,
hourlyFee: orgVars.hourlyFee,
serviceCallFee: orgVars.serviceCallFee,
salesTax: orgVars.salesTax,
multiplier: multiplier,
saDiscount: 1, // Don't apply discount for ratio calculation
additionalTime: 0,
additionalMaterial: 0,
});
const result = calculateWithImpliedRate(pricingVars);
basePrices.push({ tierId: tier.id, price: result.tierPrice });
}
// Find the lowest price (band-aid tier)
const lowestPrice = Math.min(...basePrices.map(p => p.price));
// Calculate ratios for each tier relative to the lowest
const ratios: Record<string, number> = {};
for (const { tierId, price } of basePrices) {
ratios[tierId] = lowestPrice > 0 ? price / lowestPrice : 1;
}
return { ratios, lowestPrice, basePrices };
}
/**
* Calculate tier prices using the new pricing formula with implied rates
*
* Strategy:
* 1. Calculate base price ratios from database values (zero extra hours)
* 2. Use the new pricingFormula for the lowest tier (band-aid) with actual hours
* 3. Scale other tier prices using the base ratios
* 4. Calculate implied hourly rate for each tier (for pricing additional hours)
*
* @param menuData - The menu data with tiers
* @param orgVars - Organization variables (hourlyFee, serviceCallFee, saDiscount, salesTax)
* @param baseHours - Base hours set by technician
* @param extraHours - Extra hours from slider adjustment (default 0)
* @param extraCostMultiplier - Extra cost multiplier (default 1, unused but kept for API compatibility)
* @returns Menu data with calculated prices on each tier
*/
export function calculateTierPrices(
menuData: any,
orgVars: OrgVars,
baseHours: number,
extraHours: number = 0,
extraCostMultiplier: number = 1
): MenuDataWithPrices | null {
if (!menuData || !menuData.tiers || menuData.tiers.length === 0) {
return null;
}
// Deep clone to avoid mutating original
const result = JSON.parse(JSON.stringify(menuData)) as MenuDataWithPrices;
// Calculate base price ratios using DATABASE hours (zero extra hours)
const { ratios } = calculateBaseTierRatios(result.tiers, orgVars);
// Find the lowest tier (tier with ratio = 1.0, typically band-aid)
const lowestTier = result.tiers.find(t => ratios[t.id] === 1) || result.tiers[result.tiers.length - 1];
if (!lowestTier?.offer) {
return result;
}
// Calculate the lowest tier price using the new formula with technician's hours
const lowestTierMaterial = getMaterialCost(lowestTier.offer);
const lowestTierMultiplier = parseFloat(lowestTier.offer.multiplier) || 1;
const lowestTierVars = createPricingVars({
material: lowestTierMaterial,
time: baseHours, // Use technician's chosen base hours
hourlyFee: orgVars.hourlyFee,
serviceCallFee: orgVars.serviceCallFee,
salesTax: orgVars.salesTax,
multiplier: lowestTierMultiplier,
saDiscount: 1, // Don't apply discount yet
additionalTime: extraHours,
additionalMaterial: 0,
});
const lowestTierResult = calculateWithImpliedRate(lowestTierVars);
const lowestTierPrice = lowestTierResult.priceAfterDeduction; // Price after standard deduction, before SA discount
// Apply ratios to all tiers and calculate implied rates
for (const tier of result.tiers) {
const tierRatio = ratios[tier.id] || 1;
// Scale price from lowest tier using the ratio
tier.price = lowestTierPrice * tierRatio;
// Discount price applies 3% discount
tier.discountPrice = tier.price * 0.97;
// Calculate pricing details for this tier to get implied rate
const tierMaterialCost = getMaterialCost(tier.offer);
const tierMultiplier = parseFloat(tier.offer?.multiplier) || 1;
const pricingVars = createPricingVars({
material: tierMaterialCost,
time: baseHours,
hourlyFee: orgVars.hourlyFee,
serviceCallFee: orgVars.serviceCallFee,
salesTax: orgVars.salesTax,
multiplier: tierMultiplier,
saDiscount: 1,
additionalTime: extraHours,
additionalMaterial: 0,
});
const pricingResult = calculateWithImpliedRate(pricingVars);
// Store the implied hourly rate for this tier
tier.impliedHourlyRate = pricingResult.impliedHourlyRate;
tier.pricingDetails = pricingResult;
// Add warranty based on tier name
const tierName = tier.name?.toLowerCase() || '';
if (tierName.includes('platinum')) {
tier.warranty = '2 year limited warranty';
} else if (tierName.includes('gold')) {
tier.warranty = '18 month limited warranty';
} else if (tierName.includes('silver')) {
tier.warranty = '1 year limited warranty';
} else if (tierName.includes('bronze')) {
tier.warranty = '6 month limited warranty';
} else if (tierName.includes('band')) {
tier.warranty = '30 day limited warranty';
}
// Extract title from contentItems if not already set
if (!tier.title && tier.contentItems) {
const titleItem = tier.contentItems.find((e: any) => e.refId?.includes('_title'));
if (titleItem) {
tier.title = titleItem.content;
}
}
}
return result;
}
/**
* Get the base hours from the lowest tier (band-aid) in the menu
*/
export function getBandAidHours(menuData: any): number {
if (!menuData?.tiers || menuData.tiers.length === 0) {
return 0;
}
const lowestTier = menuData.tiers[menuData.tiers.length - 1];
if (!lowestTier?.offer?.costsTime) {
return 0;
}
return lowestTier.offer.costsTime.reduce(
(sum: number, cost: any) => sum + (cost.hours || 0),
0
);
}