Instant Quote · Admin

Docs

Project documentation rendered straight from /docs.

docs/PRICING_LOGIC.md

Pricing Logic

Last updated: May 19, 2026 at 4:17 PM ET

The single source of truth is lib/estimate.js. Every place an estimate is shown — the public form, the public API, the admin preview — calls calculateEstimate(input, rules). This doc explains exactly what that function does and how to tune it.

Related docs: README, ARCHITECTURE, ADMIN_GUIDE.

"Not sure" handling (Form V1)

The public form lets homeowners pick "Not sure" on every dimension that affects pricing. The pricing engine never sees a null or undefined for these fields — each "Not sure" pick maps to a defined, safe default:

Field "Not sure" maps to Effect
stories unknown (multiplier 1.0) Same math as a single-story home; integer column gets 1
roof_complexity unknown (multiplier 1.0) Same math as Simple
roof_age unknown (per-client adder, default $1,000) Already a real adder; admin can tune
selected_material client's default_material (fallback asphalt_arch) A real material price is used; the user's "unknown" choice never reaches the math

The resolution happens at the API layer in app/api/quotes/route.js; calculateEstimate(...) itself is unchanged. See FORM_FIELDS for the full field schema.

Inputs

calculateEstimate(input, pricingRules) takes:

Field Type Notes
roofAreaSqFt number Preferred. From EagleView, or what the user typed.
homeSqft number Legacy / fallback. Used only when roofAreaSqFt is missing.
stories "1" | "2" | "3" Number of stories on the home.
material asphalt_3tab | asphalt_arch | metal | tile | cedar_shake Roof material.
complexity simple | moderate | complex Roof complexity.
roofAge lt_10 | 10_20 | gt_20 | unknown Current roof age bucket.
pitch low | standard | steep Optional — the public form doesn't ask yet. Defaults to standard.

The function resolves the client's pricing rules over the defaults and falls back to defaults for any missing leaf.

Where roof square footage comes from

If roofAreaSqFt is supplied (this is normal — either from EagleView's prefetch or from the user typing it), it's used directly.

Otherwise the legacy fallback formula applies:

roofSqft = (homeSqft / stories) × 1.15

The 1.15 is the PITCH_FACTOR constant. It accounts for the fact that a typical 5:12–7:12 pitched roof has ~15% more surface area than its footprint. The result is what's tracked as roof_area_source = "fallback" in the quotes table.

Defaults

From lib/estimate.js#DEFAULT_PRICING (current values):

price_per_sqft: {
  asphalt_3tab: 6.0,
  asphalt_arch: 7.5,
  metal:        13.0,
  tile:         18.0,
  cedar_shake:  13.0,
},
complexity_mult: {
  simple:   1.0,
  moderate: 1.15,
  complex:  1.35,
},
stories_mult: {
  "1": 1.0,
  "2": 1.1,
  "3": 1.25,
},
pitch_mult: {
  low:      0.95,
  standard: 1.0,
  steep:    1.2,
},
age_flat_adder: {
  lt_10:   0,
  "10_20": 500,
  gt_20:   2000,
  unknown: 1000,
},
range_spread:  0.125,   // 12.5% on each side of the midpoint
min_job_floor: 10000,   // safety floor — no quote below this

Every leaf is editable per-client in the admin. The merge in resolvePricing only fills in missing keys, so a client's explicitly saved value is always preserved.

Order of operations

The midpoint is calculated in this order, then the band is built from the midpoint:

1. Base price        = roof square footage × material price per sq ft
2. Adjusted price    = base price × complexity multiplier
                                  × story multiplier
                                  × pitch multiplier
3. Midpoint          = adjusted price + age adder
4. Apply floor       = If min_job_floor > 0 and midpoint < min_job_floor
                       → set midpoint = min_job_floor
5. Low estimate      = midpoint × (1 - range_spread)   (rounded to $100)
6. High estimate     = midpoint × (1 + range_spread)   (rounded to $100)
   Displayed mid     = midpoint                        (rounded to $100)

A few things worth calling out:

  • The floor is applied to the midpoint, not just the low side. This keeps the band symmetric. Lifting only the low side would give you low = floor, high = original_high, which would look wrong whenever the original midpoint was tiny.
  • The cached estimated_monthly_payment on the quote row uses the unrounded internal midpoint (via monthlyPayment(mid) in lib/financing.js).
  • Low / mid / high are each rounded to the nearest $100 in the response shown to the user.

Example calculation

Inputs: 2,400 sq ft roof, 2 stories, architectural asphalt, moderate complexity, 10–20 year old roof, standard pitch. Defaults from above.

base       = 2400 × 7.50           = $18,000
adjusted   = 18000 × 1.15 × 1.1 × 1.0 = $22,770
midpoint   = 22770 + 500            = $23,270
floor      = 10000 (not triggered, 23,270 ≥ 10,000)
low        = 23270 × (1 - 0.125)    = $20,361.25  → $20,400
high       = 23270 × (1 + 0.125)    = $26,178.75  → $26,200
mid (shown) = $23,300

Minimum job floor example

Inputs that produce a tiny midpoint, say a 500 sq ft 3-tab roof on a single-story simple gable, with the same defaults:

base       = 500 × 6.00             = $3,000
adjusted   = 3000 × 1.0 × 1.0 × 1.0 = $3,000
midpoint   = 3000 + 500             = $3,500
floor      = 10000 (triggered — 3,500 < 10,000)
midpoint   = $10,000   ← lifted to floor
low        = 10000 × (1 - 0.125)    = $8,750  → $8,800
high       = 10000 × (1 + 0.125)    = $11,250 → $11,300
mid (shown) = $10,000

Without the floor, this would have produced a $3.1k–$3.9k band — not a real number for a roof replacement. The floor keeps the tool from suggesting prices the contractor would never actually quote.

Where these settings are editable

All per-client overrides live in the admin under Edit client → Pricing rules:

  • Material price per sq ft — text inputs for each of the five materials.
  • Complexity multipliers — Simple / Moderate / Complex.
  • Story multipliers — 1 / 2 / 3+.
  • Pitch multipliers — Low / Standard / Steep.
  • Age flat adders — < 10 / 10–20 / 20+ / Unknown.
  • Range spread — accepts the raw fraction (e.g. 0.125). The admin also exposes "Estimate mode" presets (conservative 0.20, balanced 0.125, aggressive 0.08) — picking a mode just patches the spread; manual values below it still win.
  • Minimum job floor — dollar amount. Leave blank or set to 0 to disable the floor for that client (the default still applies via DEFAULT_PRICING.min_job_floor; see below).

Any field left blank in the admin clears that value in the DB. On the next read, resolvePricing fills the missing leaf back in from DEFAULT_PRICING. So "clear a field" effectively means "use the default".

min_job_floor and existing clients

The DB stores null to mean "no explicit floor set". resolvePricing treats null and <= 0 the same: fall back to DEFAULT_PRICING.min_job_floor. With the current default at $10,000, this means existing clients who never set a floor will pick up the $10,000 default at next load.

That's by design — it's the entire point of having a non-zero default. A client who explicitly wants no floor must set it to a small dollar amount (e.g. 1) or otherwise we treat their non-positive value as "unset". If you change the floor default in code, ship a note to the client list so nobody is surprised.

Avoiding unrealistic low estimates

Three knobs control how cheap a quote can read:

  1. min_job_floor is the hard backstop. Whatever else the inputs say, the midpoint never drops below this floor.
  2. price_per_sqft for the cheap-end materials (3-tab asphalt especially). If you raise the floor, also keep an eye on the per-sqft values — a $6/sqft 3-tab on a 1,200 sq ft 1-story simple gable should still produce a reasonable number on its own.
  3. range_spread controls how loose the band is. A 0.125 spread means the low is 87.5% of the midpoint. A larger spread can read as "we don't really know" if the midpoint isn't trustworthy yet.

When in doubt: start with min_job_floor higher, lower it once you've seen real quotes coming through. It's much easier for the contractor to negotiate a customer down from a too-high range than to explain why a $3,500 quote is actually impossible.