Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
/**
 * 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
  );
}