docs/PRICING_LOGIC.mdPricing 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_paymenton the quote row uses the unrounded internal midpoint (viamonthlyPayment(mid)inlib/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 (conservative0.20, balanced0.125, aggressive0.08) — picking a mode just patches the spread; manual values below it still win. - Minimum job floor — dollar amount. Leave blank or set to
0to disable the floor for that client (the default still applies viaDEFAULT_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:
min_job_flooris the hard backstop. Whatever else the inputs say, the midpoint never drops below this floor.price_per_sqftfor 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.range_spreadcontrols how loose the band is. A0.125spread 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.