Docs.

Pricing & policy settings — reference for launch tuning

business/pricing-settings.md

Audience: a financial-research agent recommending launch values for every parameter on the office Pricing & policy page (/office/pricing).

This doc is derived directly from the code and verified against the test suite:

  • Engine: src/lib/office/pricing.ts
  • Verified against: src/lib/office/pricing.test.ts
  • Settings shape + defaults: src/lib/office/settings.ts
  • Seeded values: supabase/migrations/0001_baseline.sql, 0005_office_and_accounts.sql, 0007_settings_kv.sql
  • UI labels + inline help: src/components/office/PricingForm.tsx
  • Older narrative doc (superseded/extended by this one): docs/business/pricing.md

Units convention (read this first). Every money amount in storage and in the engine is USD cents (integer). $220.00/day is stored as 22000. The office form shows USD dollars and percents for human entry, then converts: dollars × 100 → cents, and percent ÷ 100 → fraction (actions.ts pctToFrac/Math.round(x*100)). COP never enters the math — it is a display-only conversion (see cop_per_usd).


1. Overview — how a quote is built, end to end

A rental quote is assembled in computeQuote (pricing.ts). In prose:

For each cart line:
  effective day rate (NET of IVA, USD cents)
      = manual per-item override   (items.rate_day_usd_cents, if set)
      OR derived from replacement value via the cost-recovery engine
        using that item's equipment-class parameters
        (see §2; null if no replacement value → line is "unpriced")

  line cost over N days
      = stack full weeks at the week rate, then add the remainder days,
        capping the remainder at one week's price:
        weeks   = floor(N / 7)
        remDays = N mod 7
        weekRate = explicit rate_week  OR  round(dayRate × week_multiplier)
        lineCost = weeks·weekRate + min(remDays·dayRate, weekRate)
        lineTotal = lineCost × qty

Subtotal (NET)      = Σ lineTotal over all lines
Replacement value   = Σ (item replacement value × qty)

Deposit (refundable, NOT IVA'd, separate from the rental fee):
  gearDeposit  = replacementValue > 0
                   ? max(deposit_percent × replacementValue,
                         deposit_minimum_usd_cents)
                   : 0
  spaceDeposit = Σ (per-line flat depositUsdCents × qty)   // studio spaces only
  deposit      = gearDeposit + spaceDeposit

Total (NET)  = max(0, subtotal − discount + waiver)        // desk adjustments
IVA          = round(total × iva_rate)                      // VAT on the TOTAL
Gross        = total + IVA                                  // what the client pays, ex deposit

Key facts that fall out of the code:

  • The day rate is net of IVA. IVA (19%) is applied once, to the quote total, never to the per-day rate (pricing.ts lines 224–225).
  • The deposit is not taxed and not part of the total — it is a separate refundable hold returned after inspection.
  • A manual per-item override always wins over the derived rate (effectiveDayRateUsdCents).
  • Rounding: derived day rates round to day_rate_round_usd_cents; IVA and the COP conversion use Math.round. The final-total clamp is max(0, …).

The late-fee model (policy lever, not yet wired)

The page exposes a late-fee policy: a no-charge grace window (minutes) after the agreed return, then late_per_day_factor × day rate per late day beyond it. The intended computation (per the UI help in PricingForm.tsx) is:

if minutesLate ≤ late_grace_minutes:   no charge
else:                                  lateDays × late_per_day_factor × dayRate

Important — not enforced in code today. late_grace_minutes and late_per_day_factor are stored, editable, and surfaced in history, but there is no runtime consumer of them anywhere in src/ (verified by grep). They are documented policy + future levers. The financial agent should still recommend launch values, but understand they currently drive nothing automatically. The same is true of weekend_multiplier and half_day_multiplier (see §3).


2. The day-rate engine

Source: deriveDayRateUsdCents (pricing.ts lines 60–83). The day rate is derived from each item's replacement value (RC, in USD cents) via a straight-line cost-recovery model, parameterized by the item's equipment class.

The formula (exact)

depreciation = (RC − RC · residualPct) / lifeYears
annualCost   = depreciation + RC · (maintenancePct + insurancePct + overheadPct)
billableDays = 365 · utilization
denom        = billableDays · (1 − marginPct)

raw          = annualCost / denom                        (USD cents/day, pre-round)
NetDayRate   = round_to( max(dayRateFloorUsdCents, raw),  dayRateRoundUsdCents )

with two guards:

  • if denom ≤ 0 (i.e. marginPct ≥ 1): return dayRateFloorUsdCents directly.
  • rounding: if dayRateRoundUsdCents > 0, round(raw / r) · r (nearest increment — Math.round, so it can round down); else Math.round(raw).

Returns null only when there is no replacement value — that item is flagged "unpriced" in the UI and contributes $0 to the subtotal.

What each class parameter does (the lever)

ParamUnitRole in the formulaEffect of raising it
lifeYearsyearsdivisor of depreciationlowers the rate (cost spread over more years)
residualPctfraction 0–1resale value kept; reduces the depreciable base RC − RC·residuallowers the rate (less value to recover)
utilizationfraction 0–1billableDays = 365 · util — days/yr you expect to actually rent itlowers the rate, strongly (more days to amortize across)
maintenancePctfraction/yrannual opex, × RCraises the rate
insurancePctfraction/yrannual opex, × RCraises the rate
overheadPctfraction/yrannual opex, × RCraises the rate
marginPctfraction 0–1profit markup via ÷ (1 − margin)raises the rate

utilization is the dominant lever (it sits alone in the denominator and is small, ~5%). maintenance + insurance + overhead are summed and applied together as a single annual opex fraction of RC — the engine never uses them separately, so only their sum matters to the rate.

Worked examples (all at default classes, floor $15, round $5)

Computed with the exact code; cents shown to 2dp before rounding.

(a) High-value cinema camera body — Sony FX6, RC = $7,000 → camera_body (life 3.5y, residual 20%, util 5%, m 7.5% + i 2.5% + o 12% = 22%, margin 21%)

depreciation = (700000 − 700000·0.20)/3.5   = 160000.00 ¢/yr  ($1,600.00)
opex         = 700000 · 0.22                  = 154000.00 ¢/yr  ($1,540.00)
annualCost   = 314000.00 ¢/yr                                  ($3,140.00)
billableDays = 365 · 0.05 = 18.25
denom        = 18.25 · (1 − 0.21) = 14.4175
raw          = 314000.00 / 14.4175 = 21779.09 ¢  ($217.79/day)
NetDayRate   = round(21779.09 / 500)·500 = 22000 ¢ = $220.00/day
  +IVA (19%) → $261.80/day      (Cinemarket market reference ≈ $264 ✓)

(b) Mid lens — RC = $3,000 → lens (life 6y, residual 33%, util 5%, m 4% + i 2% + o 10% = 16%, margin 20%)

depreciation = (300000 − 300000·0.33)/6      = 33500.00 ¢/yr  ($335.00)
opex         = 300000 · 0.16                  = 48000.00 ¢/yr  ($480.00)
annualCost   = 81500.00 ¢/yr                                  ($815.00)
denom        = 18.25 · (1 − 0.20) = 14.60
raw          = 81500.00 / 14.60 = 5582.19 ¢   ($55.82/day)
NetDayRate   = round(5582.19/500)·500 = 5500 ¢ = $55.00/day   (rounds DOWN)
  +IVA (19%) → $65.45/day

(Matches the FX30-style "$815 annual / 18.25 days / 0.8 margin ≈ $56" example in the form's inline help, modulo rounding.)

(c) Cheap accessory — battery, RC = $120 → accessory (life 3y, residual 10%, util 5%, m 5% + i 1.5% + o 13% = 19.5%, margin 20%)

depreciation = (12000 − 12000·0.10)/3        = 3600.00 ¢/yr   ($36.00)
opex         = 12000 · 0.195                  = 2340.00 ¢/yr   ($23.40)
annualCost   = 5940.00 ¢/yr                                   ($59.40)
denom        = 18.25 · 0.80 = 14.60
raw          = 5940.00 / 14.60 = 406.85 ¢     ($4.07/day)
NetDayRate   = max(1500, 406.85) = 1500 ¢ = $15.00/day        (FLOOR binds)
  +IVA (19%) → $17.85/day

This is the floor's whole purpose: cheap items derive sub-floor rates and get lifted to the $15 minimum line charge (covers checkout labor).

Cross-check against pricing.test.ts (verified — all pass)

The test file defines a CLEAN class (lifeYears 1, residual 0, util 1, all opex 0, margin 0) so that annualCost = RC, billableDays = 365, denom = 365, hence raw = RC / 365:

  • deriveDayRateUsdCents(3_650_000, "battery", CLEAN)3,650,000/365 = 10,000, rounds to 10000 ✓ (test line 80). I reproduced 10000.
  • deriveDayRateUsdCents(100_000, …)100000/365 ≈ 274 < 1500 floor → 1500 ✓ (line 102). Reproduced 1500.
  • marginPct = 1denom = 0 → floor 1500 ✓ (line 96). Reproduced 1500.
  • dayRateRoundUsdCents = 0, RC = 3_651_825/365 = 10005 exactly → 10005 ✓ (line 108). Reproduced 10005.

computeQuote worked example (test line 142): two lines over 2 dayscamera_body (rateDay 10000, RC 200000) + a space (rateDay 20000, flat deposit 30000) → subtotal 2·10000 + 2·20000 = 60000; deposit max(1.0·200000,50000)=200000 gear + 30000 space = 230000; IVA round(60000·0.19)=11400; gross 71400. ✓


3. Each setting, one subsection

Current values below are the shipped defaults after migrations replay in order (0001 → 0005 → 0007). The stored key is shown; the form label is in parentheses.

cop_per_usdpricing.cop_per_usd_snapshot (currency · "cop per usd")

  • What: the USD→COP exchange rate used to display prices and bill in pesos. It is not in any pricing formula — all stored prices are USD cents; this only converts at display time (money.ts formatCopFromUsdCents: cop = round((usdCents/100) · copPerUsd)).
  • Current default: 4000 (0001 column default; carried forward).
  • Touchpoint: display only. (Note: the day-rate market calibration — why the FX6 lands at $220 net — was anchored to 3,600 COP/USD; that is a historical reference point, not the live display rate.)
  • Before/after: a $500 deposit shows as 500 × 4000 = 2,000,000 COP at 4,000; at 4,200 it shows 2,100,000 COP. The USD/cents figure is unchanged — only the peso display moves. A live "fx lookup" button can pull a fresh rate into the field; the user still must Save.

week_multiplierpricing.week_multiplier (rate multipliers · "week ×")

  • What: compresses 7 days into one discounted week price. This is the only multiplier actually wired into the engine (lineCostForDays).
  • Current default: 3.0 (a 7-day rental costs 3× the day rate, not 7×).
  • Touchpoint: weekRate = rate_week (explicit) ?? round(dayRate · week_multiplier); weeks stack and the sub-week remainder is capped at one week's price.
  • Before/after: daily $100, 10-day rental: weekRate = $300; weeks = 1, remDays = 3; total = 1·$300 + min(3·$100, $300) = $300 + $300 = $600. Raise the multiplier to 4 → weekRate $400, total $400 + min($300,$400) = $700. Lower to 2.5 → $250 + min($300,$250) = $500.

weekend_multiplierpricing.weekend_multiplier (rate multipliers · "weekend ×")

  • What: intended as a Sat+Sun-as-a-pair rate.
  • Current default: 1.0 (no surcharge, no discount).
  • Touchpoint: none — dormant no-op. No code reads it. The engine bills plain calendar days; a Fri–Sun rental is just three billed days at the day rate. At 1.0 it would have no effect even if wired.

half_day_multiplierpricing.half_day_multiplier (rate multipliers · "half day ×")

  • What: intended as the rate for a same-day return (a few hours).
  • Current default: 0.6 (60% of a full day).
  • Touchpoint: none — dormant. No code reads it. The minimum billed span is 1 day (rentalDaysBetween floors at 1).

deposit_percentpricing.deposit_percent (deposit · "percent")

  • What: the fraction of total replacement value held as a refundable security deposit at pickup.
  • Current default: 1.0 = 100%. (0001 seeds 0.20, but 0005 inserts a newer revision overriding it to 1.00 — "no Colombian gear-insurance market, so a full-replacement hold is the protection"; 0007 carries the newest row, 1.00, forward. The fresh-empty-DB fallback seed in 0007 writes 0.20, but a normal in-order replay yields 1.00.)
  • Touchpoint: gearDeposit = max(deposit_percent · Σreplacement, deposit_minimum_usd_cents), only when the cart has replacement-valued gear.
  • Before/after: a $7,000 camera at 100% holds $7,000; at 20% holds $1,400. At 100% the percentage always dominates the $500 floor, so the floor is dormant until the percentage is lowered.

deposit_minimum_usdpricing.deposit_minimum_usd_cents (deposit · "minimum usd")

  • What: floor under the gear deposit, so cheap-but-fragile items still hold a meaningful amount. Stored in cents; form shows dollars.
  • Current default: $500 (50000 cents).
  • Touchpoint: the max(…, deposit_minimum_usd_cents) in gearDeposit. Applies only when the cart contains replacement-valued gear — a space-only reservation is not saddled with the gear floor.
  • Before/after: a $200 mic at 20% would compute $40, lifted to the $500 floor. At 100% deposit_percent, the floor only bites items under $500 replacement value (e.g. a $120 battery still holds $500).

late_grace_minutespricing.late_grace_minutes (late fees · "grace minutes")

  • What: no-charge window after the agreed return time. Integer minutes.
  • Current default: 60 minutes.
  • Touchpoint: policy only — no runtime consumer (see §1). Intended: returns within 60 min are free.
  • Before/after: with factor 1.0, a daily-$100 rental returned 90 min late is intended to be free; 25 hours late → 1 late day × $100.

late_per_day_factorpricing.late_per_day_factor (late fees · "per day ×")

  • What: multiplier on the day rate for each late day beyond the grace window.
  • Current default: 1.0 (late days bill at the normal day rate).
  • Touchpoint: policy only — no runtime consumer. Intended: lateDays × factor × dayRate.
  • Before/after: factor 1.0 → a 2-days-late $100 rental adds $200; factor 1.5 (punitive) → adds $300.

iva_ratepricing.iva_rate (iva and rounding · "iva %")

  • What: Colombian VAT (IVA) applied to the quote total. Stored as a fraction; form shows percent.
  • Current default: 0.19 = 19%.
  • Touchpoint: iva = round(total · iva_rate); gross = total + iva. Never applied to the per-day rate or the deposit.
  • Before/after: a $500 net rental → IVA round(500·0.19)=$95 → gross $595. (Test line 221: net $9,000 → IVA round(9000·0.19)=1710.) 19% is DIAN-mandated; do not lower without legal advice.

day_rate_floor_usdpricing.day_rate_floor_usd_cents (iva and rounding · "day rate floor usd")

  • What: minimum derived day rate / minimum line charge — covers checkout labor on low-value gear. Stored in cents; form shows dollars.
  • Current default: $15 (1500 cents).
  • Touchpoint: max(dayRateFloorUsdCents, raw) inside the engine, and it is the value returned when marginPct ≥ 1 collapses the denominator.
  • Before/after: the $120 battery in §2(c) derives $4.07/day → lifted to $15. Raise the floor to $25 and that same battery rents at $25/day; set it too high and you price out accessories that should still be cheaply rentable.

day_rate_round_usdpricing.day_rate_round_usd_cents (iva and rounding · "rate rounding usd")

  • What: rounds engine-derived rates to a clean increment (tidier price lists). Does not affect manual overrides. Stored in cents; form shows dollars.
  • Current default: $5 (500 cents).
  • Touchpoint: round(raw / r) · rnearest increment (can round down). If set to 0, the engine uses Math.round to the nearest cent instead.
  • Before/after: FX6 raw $217.79 → $220 (rounds up). Mid lens raw $55.82 → $55 (rounds down). Set round = $10 → FX6 $217.79 → $220; lens $55.82 → $60.

4. Per-class parameter table (current defaults)

From DEFAULT_CLASSES in settings.ts and the seeded JSON in 0001/0007 (identical). m+i+o is the summed annual opex fraction the engine actually uses. All classes ship at 5% utilization.

classlifeYearsresidual%util%maint%ins%over%m+i+o%margin%
camera_body3.52057.52.51222.021
lens6335421016.020
lighting41556.51.81119.321
support828531.4812.418
electronics4205521118.020
accessory310551.51319.520

There is also a code-level DEFAULT_CLASS fallback (life 4, residual 15%, util 5%, maint 5%, ins 2%, over 12%, margin 20%) used only if a stored class is missing a field.

What each class represents (category→class map in pricing.ts):

  • camera_body — camera bodies. Short life (3.5y, fast obsolescence), high opex; the catalog's most valuable, fastest-depreciating items.
  • lens — camera lenses. Long life (6y), high residual (33% — glass holds value), low opex → low rate relative to value.
  • lighting — lights, modifiers, light accessories, triggers. Mid life, low residual (consumable-ish), highest insurance-adjacent maintenance.
  • support — tripods, stands, grip, stabilizers, cases, some storage. Longest life (8y), lowest opex and margin → cheapest to rent per dollar.
  • electronics — monitors, audio recorders/monitors, mics, timecode, digital storage, computers, phones. Mid life, fast obsolescence.
  • accessory — batteries, filters, lens/body accessories, adapters. Short life, low residual, highest overhead share; default fallback class for any unmapped category.

5. Projections / sensitivity

Derived day rate (net of IVA) for a camera_body at $10,000 replacement, one lever at a time, all else at default (round $5, floor $15). Baseline raw $311.13 → $310/day net (+IVA $368.90). Computed with the exact engine.

Utilization (the dominant lever; default 5%):

utilizationraw $/dayrounded $/day net
3%518.55520
5% (default)311.13310
10%155.56155
15%103.71105
20%77.7880
25%62.2360

Rate is inversely proportional to utilization: doubling util roughly halves the rate. This is the calibration knob — 5% (~18 billable days/yr) is what lands the FX6 at the Cinemarket market reference.

Margin (÷ (1 − margin); default 21%):

marginraw $/dayrounded net
11%276.17275
21% (default)311.13310
31%356.22355

±10 pts of margin moves the rate roughly ±$40 (~±13%) here.

lifeYears (depreciation divisor; default 3.5):

lifeYearsraw $/dayrounded net
1.5522.51525
3.5 (default)311.13310
5.5253.48255

Shorter life → faster recovery → higher rate; the effect tapers because opex (the non-depreciation term) is unaffected by life.

residualPct (default 20%):

residualraw $/dayrounded net
0%350.76350
20% (default)311.13310
40%271.50270

Higher residual → smaller depreciable base → lower rate. residual = 0 assumes the item is worthless at end of life (max depreciation).

Takeaway: utilization dwarfs the others. A launch-time mistake on utilization (e.g. assuming 15% when reality is 5%) cuts derived rates by ~3×. margin, lifeYears, and residual are second-order trims.


6. For the financial agent — recommend launch values

Every tunable parameter, its current shipped value, plausible range, and the trade-off. Recommend a launch value for each. Note the unit column.

Global scalars

Setting (stored key)UnitCurrentPlausible rangeTrade-off / lever
cop_per_usd_snapshotCOP/USD40003,800–4,400 (live FX)Display only. Too-stale a rate misquotes pesos catalog-wide; refresh to the live Banrep rate at launch. Day-rate math is unaffected.
week_multiplier×day3.02.5–5.0Wired. Effective per-day discount on 7-day rentals (3.0 ⇒ ~43% off/day). Higher = less discount = more revenue but less competitive on weeklies.
weekend_multiplier×(Sat+Sun)1.01.0–2.0Dormant (no engine consumer). Keep 1.0 at launch; revisit only if weekend pricing is wired.
half_day_multiplier×day0.60.4–0.8Dormant. Keep 0.6; min billed span is 1 day today.
deposit_percentfraction 0–11.0 (100%)0.5–1.0Whole loss/damage protection (no insurance market). 100% fully covers replacement but is a large client hold; lowering shifts loss risk to the studio.
deposit_minimum_usd_centsUSD (form: $)$500$200–$1,000Floor on the gear hold. Dormant while percent = 100% except for items under $500 RC. Protects against total loss on cheap-but-fragile gear.
late_grace_minutesminutes6015–120Policy only (not enforced). Bigger = friendlier but more abuse; #1 dispute source in rental — document it.
late_per_day_factor×day1.01.0–2.0Policy only. 1.0 = late days bill as normal days; >1 = punitive deterrent.
iva_ratefraction0.19 (19%)fixed by DIANMandated VAT on the total. Do not change without legal advice.
day_rate_floor_usd_centsUSD (form: $)$15$10–$30Minimum line charge (checkout labor). Too high prices out accessories; too low loses money on cheap rentals.
day_rate_round_usd_centsUSD (form: $)$5$1–$10 (0 = exact cents)Cosmetic tidiness of derived rates. Larger increments = rounder prices but coarser; rounds to nearest (can round down).

Per-class engine parameters (set per class — 6 classes × 7 params)

ParamUnitCurrent range across classesDirectionTrade-off / lever
lifeYearsyears3 (accessory) – 8 (support)↑ lowers rateExpected useful/serviceable life. Shorter for fast-obsoleting electronics/bodies; longer for mechanical support gear.
residualPctfraction 0–10.10 (accessory) – 0.33 (lens)↑ lowers rateFraction of value retained at end of life. Glass (lens) holds value; consumables don't.
utilizationfraction 0–10.05 all classes↑ lowers rate, stronglyExpected billable days/yr ÷ 365. The master calibration knob. 5% ≈ 18 days/yr; raise only if you genuinely rent more. Wrong here collapses or inflates the whole catalog.
maintenancePctfraction/yr0.03 (support) – 0.075 (camera_body)↑ raises rateAnnual upkeep as % of RC.
insurancePctfraction/yr0.014 (support) – 0.025 (camera_body)↑ raises rateAnnual risk loading as % of RC (note: there is no separate insurance product; this is a cost loading).
overheadPctfraction/yr0.08 (support) – 0.13 (accessory)↑ raises rateAllocated business overhead as % of RC.
marginPctfraction 0–10.18 (support) – 0.21 (camera_body/lighting)↑ raises rateProfit markup via ÷(1−margin).

Only the sum maintenancePct + insurancePct + overheadPct affects the rate — the engine never separates them. If the agent wants a cleaner model, recommend the three values and note the combined opex% per class (col "m+i+o%" in §4): camera_body 22.0, lens 16.0, lighting 19.3, support 12.4, electronics 18.0, accessory 19.5.

Calibration note for the agent

The current defaults were tuned so a $7,000 Sony FX6 lands at $220/day net (~$262 with IVA), matching a published Colombian market rate (~$264) at 5% utilization and 3,600 COP/USD calibration FX. Cost recovery scales ~linearly with replacement value, so cheaper bodies price intentionally below the regressive market (which charges a higher % on cheap gear). To lift a specific cheap item toward market without distorting the curve, use a per-item override or lower that class's utilization. The FX6 anchor is the reference point to preserve when recommending changes.