File

apps/recallassess/recallassess-api/src/common/pricing/banded-pricing.util.ts

Description

Cumulative banded pricing — stacks ordered bands (Starter → Enterprise). Slice widths match banded-pricing-packages-concept.md: 50, 50, 150, 250, then unlimited.

Index

Properties

Properties

rate
rate: number
Type : number
sortOrder
sortOrder: number
Type : number
export interface BandedPricingBand {
  sortOrder: number;
  rate: number;
}

/** Seat capacity per band slice (cumulative stack), aligned to concept doc. */
const BAND_SLICE_SIZES = [50, 50, 150, 250, Number.POSITIVE_INFINITY] as const;

export function bandSliceSize(bandIndex: number): number {
  return BAND_SLICE_SIZES[bandIndex] ?? Number.POSITIVE_INFINITY;
}

/**
 * Monthly subtotal for participant count using cumulative band rates.
 * Bands must be sorted by sortOrder ascending (Starter first).
 */
export function calculateBandedMonthlySubtotal(
  participantCount: number,
  bands: BandedPricingBand[],
): number {
  if (participantCount <= 0 || bands.length === 0) {
    return 0;
  }

  const sorted = [...bands].sort((a, b) => a.sortOrder - b.sortOrder);
  let remaining = participantCount;
  let total = 0;

  for (let i = 0; i < sorted.length && remaining > 0; i++) {
    const slice = bandSliceSize(i);
    const seatsInBand = slice === Number.POSITIVE_INFINITY ? remaining : Math.min(remaining, slice);
    total += seatsInBand * sorted[i].rate;
    remaining -= seatsInBand;
  }

  return total;
}

export interface BandedPricingLine {
  bandIndex: number;
  seats: number;
  rate: number;
  subtotal: number;
}

/** Band breakdown for invoices / previews. */
export function calculateBandedBreakdown(
  participantCount: number,
  bands: BandedPricingBand[],
): BandedPricingLine[] {
  if (participantCount <= 0 || bands.length === 0) {
    return [];
  }

  const sorted = [...bands].sort((a, b) => a.sortOrder - b.sortOrder);
  const lines: BandedPricingLine[] = [];
  let remaining = participantCount;

  for (let i = 0; i < sorted.length && remaining > 0; i++) {
    const slice = bandSliceSize(i);
    const seatsInBand = slice === Number.POSITIVE_INFINITY ? remaining : Math.min(remaining, slice);
    lines.push({
      bandIndex: i,
      seats: seatsInBand,
      rate: sorted[i].rate,
      subtotal: seatsInBand * sorted[i].rate,
    });
    remaining -= seatsInBand;
  }

  return lines;
}

/** Effective flat rate for display (total / seats). */
export function effectiveRatePerParticipant(monthlySubtotal: number, participantCount: number): number {
  if (participantCount <= 0) {
    return 0;
  }
  return monthlySubtotal / participantCount;
}

results matching ""

    No results matching ""