Developer DocsPricing Engine

Pricing Engine

The pricing engine is the heart of SignQuote. It lives in a single isolated module, src/pricing/engine.ts, and exposes one function:

export function computePricing(config: SignConfig, settings: Settings): PricingResult

It is pure, synchronous, and fully deterministic — no Date, no randomness, no I/O. The same config (Table A — the job) and settings (Table B — the shop’s constants) always yield the same PricingResult. That property is what lets the test suite pin the seeded quotes to exact dollar figures (AC-8), and it is why every formula and constant below can be reproduced by hand.

computePricing branches on config.productCategory:

return config.productCategory === 'channel_letter'
  ? priceChannelLetters(config, settings)
  : priceDimensionalLetters(config, settings)
⚠️

The most important idea on this page: per-inch is the price; cost-plus is a check. For channel letters the engine runs two paths. Path B (per-inch) produces fabPerInch — the number the customer sees. Path A (cost-plus) runs only in the background to compute a costSubtotal “floor”; its sole job is to drive the margin badge and the “cost-plus reference” figure. Changing a material cost moves the badge, never the quoted price. This also enforces the role boundary (AC-10): the salesperson sees prices and a badge, never costs.

Channel letters

Inputs come from the ChannelLetterConfig: letterHeight (H), letterCount (n), illuminationType, mountType, returnDepth, faceMaterial, ledProductId, plus the install and ancillary flags.

1. Derived geometry (§4.2.1)

The demo has no DXF, so per-letter geometry is estimated from height H using shape factors back-derived from the taxonomy’s worked Example 1 (an 18” letter → 48” perimeter, 1.6 sqft bounding box, 3.2 ft stroke run):

const perimeterFt    = (2.67 * H) / 12
const faceAreaSqft   = (0.711 * H * H) / 144
const strokeLengthFt = (2.13 * H) / 12
const letterWidthIn  = 0.711 * H          // for raceway-length estimate
const estStrokeWidthIn = H / 5            // typical block letter [A9]
FactorValueMeaning
Perimeter2.67 × H / 12feeds coil + trim-cap material
Face area0.711 × H² / 144feeds face + backing material
Stroke length2.13 × H / 12feeds LED module count
Letter width0.711 × Hfeeds raceway length
Est. stroke widthH / 5feeds the LED-rows bucket

Documented limitation (not a bug). Real letters vary roughly 3× in material by shape (“I” vs “M”/“W”). These factors model an average letter. Because geometry feeds only the background cost-plus path, the displayed per-inch price is completely unaffected — see Assumptions A8/A9.

2. LED & power-supply derivation (§4.2.2)

The selected LED product (or the first product as a fallback) provides densityPerFt, costPerModule, and wattsPerModule. The seeded default sloan_prism12 has densityPerFt = 1.5, wattsPerModule = 1, costPerModule = 2.

let modPerFt = led.densityPerFt
if (config.returnDepth <= 3) modPerFt = Math.max(modPerFt, 4.0)  // shallow-can rule
 
const rows =
  estStrokeWidthIn <= 4 ? 1 :
  estStrokeWidthIn <= 6 ? 2 :
  estStrokeWidthIn <= 8 ? 3 : 4
 
const modsPerLetter = Math.ceil(strokeLengthFt * modPerFt) * rows
const jobModules    = modsPerLetter * n
const totalWatts    = jobModules * led.wattsPerModule
 
const usableWatts = c.ps_max_watts * c.ps_derate   // 60 × 0.80 = 48
const psCount = usableWatts > 0 ? Math.ceil(totalWatts / usableWatts) : 0

Two details worth stressing:

  • ps_derate is 0.80, not 0.9. This is a deliberate taxonomy decision (#1); see the inline note in src/data/defaults.ts (“do NOT ‘fix’ to 0.9”). Usable wattage per supply is ps_max_watts × ps_derate = 60 × 0.80 = 48 W.
  • The divisor is guarded. If an owner clears ps_max_watts or ps_derate to 0 on the Settings screen, psCount is forced to 0 rather than Infinity.

rows_by_stroke buckets: 1 (≤4”), 2 (>4–6”), 3 (>6–8”), 4 (>8”). At storefront heights the H/5 stroke estimate keeps most letters in the single-row bucket.

3. Path B — per-inch (the displayed price, §4.2.3)

This is the number the customer sees. Rate selection follows a strict precedence — halo beats raceway beats front-lit — then multiplies by total upright inches:

const rate =
  config.illuminationType === 'halo_reverse' ? c.price_per_inch_halo      // $22
  : config.mountType === 'raceway'           ? c.price_per_inch_raceway   // $21
  :                                            c.price_per_inch_frontlit  // $19
 
const fabPerInch = n * H * rate
ConditionConstantSeeded rate ($/upright inch)
illuminationType === 'halo_reverse'price_per_inch_halo$22
else if mountType === 'raceway'price_per_inch_raceway$21
otherwise (front-lit)price_per_inch_frontlit$19

4. Path A — cost-plus (background only, §4.2.3)

Path A reconstructs an estimated cost “floor”. It never appears in the quote or PDF — it exists to compute the margin badge and a reference price. The full decomposition is the ClCostBreakdown and is rendered only on the Settings preview panel.

Per-letter materials. The face-material rate switches to colored acrylic only for front-lit colored faces; otherwise it uses the white-acrylic rate:

const facePerSqft =
  config.illuminationType === 'front_lit' && config.faceMaterial === 'colored_acrylic'
    ? c.acrylic_colored_per_sqft   // $2.19
    : c.acrylic_white_per_sqft     // $1.88
 
const matCoil = perimeterFt * c.coil_cost_per_ft      // $2.00 / ft
const matFace = faceAreaSqft * facePerSqft
const matBack = faceAreaSqft * c.acm_backing_per_sqft // $1.31 / sqft
const matTrim = perimeterFt * c.trim_cap_per_ft       // $0.41 / ft
const matLed  = modsPerLetter * led.costPerModule
const matMisc = c.wire_studs_per_letter               // $5 flat [A11]
const perLetterMaterial = matCoil + matFace + matBack + matTrim + matLed + matMisc

Job materials. Power supplies, raceway (only for raceway mount), then a flat +7% waste allowance:

const matPs = psCount * c.power_supply_cost           // $42.95 each
// Raceway spans the letter run + 15% spacing allowance [A12]
const racewayLenFt = (letterWidthIn * n * 1.15) / 12
const matRaceway = config.mountType === 'raceway' ? racewayLenFt * c.raceway_cost_per_ft : 0 // $25/ft
 
const materialBase  = perLetterMaterial * n + matPs + matRaceway
const materialTotal = materialBase * (1 + c.material_indeterminates_pct)  // × 1.07

Labor — task-rate model. Hours scale with a height-based sizeFactor clamped to [0.5, 2], calibrated to reproduce both taxonomy labor anchors. Design is not in labor — it is a flat ancillary fee (decision D1), which avoids double-counting:

const sizeFactor    = clamp(H / 24, 0.5, 2.0)
const cncBendHours  = 0.25 * sizeFactor * n
const assemblyHours = 0.75 * sizeFactor * n
const cncBendCost   = cncBendHours * c.cnc_bend_rate   // $65 / hr
const assemblyCost  = assemblyHours * c.assembly_rate  // $45 / hr
const burden    = (cncBendCost + assemblyCost) * c.labor_burden_pct  // × 0.15
const laborCost = cncBendCost + assemblyCost + burden
 
const costSubtotal = materialTotal + laborCost          // the "floor"
const fabCostPlus  = costSubtotal * config.markupMultiplier  // reference price

Halo cost-plus is an approximation. Halo letters reuse the front-lit material model (there are no sourced halo-specific material prices). This affects only the badge — the displayed halo price always comes from price_per_inch_halo. See A14.

5. Margin badge

The badge is the reconciliation between the two paths. It compares the quoted per-inch price against the estimated cost floor:

const marginPct = fabPerInch > 0 ? (fabPerInch - costSubtotal) / fabPerInch : 0
const badge =
  marginPct < c.margin_red_below   ? 'red'   :  // < 0.30
  marginPct < c.margin_amber_below ? 'amber' :  // < 0.45
                                     'green'
BadgeConditionSeeded threshold
redmarginPct < margin_red_below< 0.30
ambermarginPct < margin_amber_below< 0.45
greenotherwise≥ 0.45

The thresholds are configurable constants. A red badge carries the warning text: “Below margin floor — this price approaches material + labor cost.” (Forced red is exercised by AC-5.)

6. Install heuristic (§4.2.3)

Install hours scale with letter count and are scaled by access and surface multipliers, then floored by a minimum fee, with a crane fee added when access is crane:

const installHours =
  (2 + 0.5 * n) * ACCESS_MULT[config.installHeightAccess] * SURFACE_MULT[config.wallSurface]
const install =
  Math.max(c.install_min_fee, installHours * c.install_rate) +
  (config.installHeightAccess === 'crane' ? c.crane_fee : 0)
installHeightAccessMultiplier
ground_ladder1.0
bucket1.25
lift1.5
crane2.0

Seeded fees: install_rate $100/hr, install_min_fee $175, crane_fee $2,500.

7. Ancillary fees & TOTAL

Permit and engineering lines appear only when their flags are set; design auto-applies to channel letters (decision D1):

const ancillary =
  (config.permitRequired ? c.permit_fee : 0) +        // $300
  (config.engineeringRequired ? c.pe_stamp_fee : 0) + // $600 (PE stamp)
  c.design_fee                                        // $150, CL only
const total = subtotal + ancillary

The TOTAL is composed as fabPerInch + install + ancillary (with the minimum-job adjustment described below applied to fabPerInch + install first).

Dimensional letters (§4.2.4)

Dimensional letters are resold, not fabricated, so there is no cost-plus path and no margin badge — the dealer markup is explicit, so there is no hidden floor to warn about. Inputs come from DimensionalLetterConfig: dimMaterial, dimFinish, dimMount, plus height/count.

Grid interpolation. Each material has a price grid with 6/12/18/24” anchors. Height is clamped to [6, 24] (and heightClamped is flagged), then lerpGrid linearly interpolates between the bracketing anchors:

const hClamped = clamp(config.letterHeight, 6, 24)
const heightClamped = hClamped !== config.letterHeight
const gridBasePerLetter = lerpGrid(grid.prices, hClamped)

Finish & mount multipliers, then markup:

const finishMult =
  config.dimFinish === 'painted_custom_color' ? c.finish_mult_painted_custom  // 1.125
  : config.dimFinish === 'polished'           ? c.finish_mult_polished        // 1.5
  :                                             1.0
const mountMult = config.dimMount === 'rail' ? c.mount_mult_rail : 1.0          // rail = 1.35
 
const basePerLetter = gridBasePerLetter * finishMult * mountMult
const wholesale = basePerLetter * config.letterCount
const supplied  = wholesale * config.markupMultiplier   // default dealer_markup_dim = 1.5
dimFinishMultiplierdimMountMultiplier
standard_included1.0flush_stud1.0
painted_custom_color1.125spacer_standoff1.0
polished1.5rail1.35

Install uses a lower hours base and adds the taxonomy’s “2× wholesale” cap before the minimum-fee floor:

const installHours =
  (1 + 0.25 * config.letterCount) * ACCESS_MULT[config.installHeightAccess] * SURFACE_MULT[config.wallSurface]
const uncapped = installHours * c.install_rate
const capped   = Math.min(2 * wholesale, uncapped)        // cap rule
const install  = Math.max(c.install_min_fee, capped) +
  (config.installHeightAccess === 'crane' ? c.crane_fee : 0)

There is no design fee for dimensional letters — patterns come with the resold letters (D1). TOTAL = supplied + install + ancillary, where ancillary is permit + engineering only.

Rounding & minimums (§4.2.5)

  • Precision. The engine computes in full float precision. The UI/PDF render line items to 2 decimals and the final TOTAL to whole dollars (A18).
  • Minimum job charge. minimum_job_price is seeded 0 (disabled) — decision D2, to preserve the Example 2 anchor. When an owner sets it above 0 and fab/supplied + install < minimum_job_price, the engine pushes the subtotal up to the minimum and adds a visible “Minimum job charge” line for the difference:
let subtotal = fabPerInch + install
if (c.minimum_job_price > 0 && subtotal < c.minimum_job_price) {
  lineItems.push({ key: 'minimum', label: 'Minimum job charge', amount: c.minimum_job_price - subtotal })
  subtotal = c.minimum_job_price
}

Worked example — seeded CAFE quote #1001

This is the AC-1 fixture: 4 front-lit letters, 18” tall, white 3/16” acrylic, sloan_prism12 LEDs, flush-stud mount, brick wall, bucket access, return depth 5, markup ×2, permit required.

Derived geometry

perimeterFt = 2.67×18/12 = 4.005 · faceAreaSqft = 0.711×18²/144 = 1.5998 · strokeLengthFt = 2.13×18/12 = 3.195 · estStrokeWidthIn = 18/5 = 3.6rows = 1.

LEDs & power

modPerFt = 1.5 (return depth 5 > 3, no shallow rule). modsPerLetter = ceil(3.195 × 1.5) × 1 = ceil(4.7925) = 5. jobModules = 5 × 4 = 20. totalWatts = 20 × 1 = 20 W. psCount = ceil(20 / (60×0.80)) = ceil(20/48) = 1. → 5 modules/letter, 20 modules, 20 W, 1 power supply.

Path B — displayed price

Front-lit, flush stud → rate $19. fabPerInch = 4 × 18 × 19 = $1,368.00.

Path A — cost floor (badge only)

Per-letter materials = coil 8.01 + face 3.0075 + backing 2.0957 + trim 1.6421 + LEDs 10 + misc 5 = $29.755. × 4 = 119.02; + PS 42.95 = 161.97; × 1.07 = $173.31 material total. Labor: sizeFactor = clamp(18/24) = 0.75; CNC/bend 0.75 h × $65 = 48.75; assembly 2.25 h × $45 = 101.25; burden 15% = 22.50 → $172.50. costSubtotal = 173.31 + 172.50 = $345.81. fabCostPlus = 345.81 × 2 = $691.62 (the cost-plus reference).

Margin badge

marginPct = (1368 − 345.81) / 1368 = 0.747 (74.7%)green (≥ 0.45).

Install & ancillary

installHours = (2 + 0.5×4) × 1.25 (bucket) × 1.0 (brick) = 5.0; max(175, 5.0×100) = $500.00. Permit $300.00; design (auto, CL) $150.00.

TOTAL

1368 + 500 + 300 + 150 = $2,318.

LineAmount
Fabrication — channel letters (per-inch)$1,368.00
Installation$500.00
Permit$300.00
Design$150.00
TOTAL$2,318
Margin badgegreen, 74.7%
Cost-plus reference (fabCostPlus)$691.62

See Acceptance & Testing for the full pinned fixtures (AC-1 through AC-7), including the dimensional LAW OFFICE quote and the live-constants (AC-4) and forced-red-badge (AC-5) cases.