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.tslines 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 useMath.round. The final-total clamp ismax(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_minutesandlate_per_day_factorare stored, editable, and surfaced in history, but there is no runtime consumer of them anywhere insrc/(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 ofweekend_multiplierandhalf_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): returndayRateFloorUsdCentsdirectly. - rounding: if
dayRateRoundUsdCents > 0,round(raw / r) · r(nearest increment —Math.round, so it can round down); elseMath.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)
| Param | Unit | Role in the formula | Effect of raising it |
|---|---|---|---|
lifeYears | years | divisor of depreciation | lowers the rate (cost spread over more years) |
residualPct | fraction 0–1 | resale value kept; reduces the depreciable base RC − RC·residual | lowers the rate (less value to recover) |
utilization | fraction 0–1 | billableDays = 365 · util — days/yr you expect to actually rent it | lowers the rate, strongly (more days to amortize across) |
maintenancePct | fraction/yr | annual opex, × RC | raises the rate |
insurancePct | fraction/yr | annual opex, × RC | raises the rate |
overheadPct | fraction/yr | annual opex, × RC | raises the rate |
marginPct | fraction 0–1 | profit 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 < 1500floor → 1500 ✓ (line 102). Reproduced 1500.marginPct = 1→denom = 0→ floor 1500 ✓ (line 96). Reproduced 1500.dayRateRoundUsdCents = 0,RC = 3_651_825→/365 = 10005exactly → 10005 ✓ (line 108). Reproduced 10005.
computeQuote worked example (test line 142): two lines over 2 days —
camera_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_usd → pricing.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.tsformatCopFromUsdCents:cop = round((usdCents/100) · copPerUsd)). - Current default: 4000 (
0001column 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 COPat 4,000; at 4,200 it shows2,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_multiplier → pricing.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_multiplier → pricing.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_multiplier → pricing.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 (
rentalDaysBetweenfloors at 1).
deposit_percent → pricing.deposit_percent (deposit · "percent")
- What: the fraction of total replacement value held as a refundable security deposit at pickup.
- Current default: 1.0 = 100%. (
0001seeds 0.20, but0005inserts a newer revision overriding it to 1.00 — "no Colombian gear-insurance market, so a full-replacement hold is the protection";0007carries the newest row, 1.00, forward. The fresh-empty-DB fallback seed in0007writes 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_usd → pricing.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 (
50000cents). - Touchpoint: the
max(…, deposit_minimum_usd_cents)ingearDeposit. 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_minutes → pricing.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_factor → pricing.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_rate → pricing.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 → IVAround(9000·0.19)=1710.) 19% is DIAN-mandated; do not lower without legal advice.
day_rate_floor_usd → pricing.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 (
1500cents). - Touchpoint:
max(dayRateFloorUsdCents, raw)inside the engine, and it is the value returned whenmarginPct ≥ 1collapses 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_usd → pricing.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 (
500cents). - Touchpoint:
round(raw / r) · r— nearest increment (can round down). If set to 0, the engine usesMath.roundto 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.
| class | lifeYears | residual% | util% | maint% | ins% | over% | m+i+o% | margin% |
|---|---|---|---|---|---|---|---|---|
| camera_body | 3.5 | 20 | 5 | 7.5 | 2.5 | 12 | 22.0 | 21 |
| lens | 6 | 33 | 5 | 4 | 2 | 10 | 16.0 | 20 |
| lighting | 4 | 15 | 5 | 6.5 | 1.8 | 11 | 19.3 | 21 |
| support | 8 | 28 | 5 | 3 | 1.4 | 8 | 12.4 | 18 |
| electronics | 4 | 20 | 5 | 5 | 2 | 11 | 18.0 | 20 |
| accessory | 3 | 10 | 5 | 5 | 1.5 | 13 | 19.5 | 20 |
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%):
| utilization | raw $/day | rounded $/day net |
|---|---|---|
| 3% | 518.55 | 520 |
| 5% (default) | 311.13 | 310 |
| 10% | 155.56 | 155 |
| 15% | 103.71 | 105 |
| 20% | 77.78 | 80 |
| 25% | 62.23 | 60 |
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%):
| margin | raw $/day | rounded net |
|---|---|---|
| 11% | 276.17 | 275 |
| 21% (default) | 311.13 | 310 |
| 31% | 356.22 | 355 |
±10 pts of margin moves the rate roughly ±$40 (~±13%) here.
lifeYears (depreciation divisor; default 3.5):
| lifeYears | raw $/day | rounded net |
|---|---|---|
| 1.5 | 522.51 | 525 |
| 3.5 (default) | 311.13 | 310 |
| 5.5 | 253.48 | 255 |
Shorter life → faster recovery → higher rate; the effect tapers because opex (the non-depreciation term) is unaffected by life.
residualPct (default 20%):
| residual | raw $/day | rounded net |
|---|---|---|
| 0% | 350.76 | 350 |
| 20% (default) | 311.13 | 310 |
| 40% | 271.50 | 270 |
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) | Unit | Current | Plausible range | Trade-off / lever |
|---|---|---|---|---|
cop_per_usd_snapshot | COP/USD | 4000 | 3,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 | ×day | 3.0 | 2.5–5.0 | Wired. 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.0 | 1.0–2.0 | Dormant (no engine consumer). Keep 1.0 at launch; revisit only if weekend pricing is wired. |
half_day_multiplier | ×day | 0.6 | 0.4–0.8 | Dormant. Keep 0.6; min billed span is 1 day today. |
deposit_percent | fraction 0–1 | 1.0 (100%) | 0.5–1.0 | Whole 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_cents | USD (form: $) | $500 | $200–$1,000 | Floor 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_minutes | minutes | 60 | 15–120 | Policy only (not enforced). Bigger = friendlier but more abuse; #1 dispute source in rental — document it. |
late_per_day_factor | ×day | 1.0 | 1.0–2.0 | Policy only. 1.0 = late days bill as normal days; >1 = punitive deterrent. |
iva_rate | fraction | 0.19 (19%) | fixed by DIAN | Mandated VAT on the total. Do not change without legal advice. |
day_rate_floor_usd_cents | USD (form: $) | $15 | $10–$30 | Minimum line charge (checkout labor). Too high prices out accessories; too low loses money on cheap rentals. |
day_rate_round_usd_cents | USD (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)
| Param | Unit | Current range across classes | Direction | Trade-off / lever |
|---|---|---|---|---|
lifeYears | years | 3 (accessory) – 8 (support) | ↑ lowers rate | Expected useful/serviceable life. Shorter for fast-obsoleting electronics/bodies; longer for mechanical support gear. |
residualPct | fraction 0–1 | 0.10 (accessory) – 0.33 (lens) | ↑ lowers rate | Fraction of value retained at end of life. Glass (lens) holds value; consumables don't. |
utilization | fraction 0–1 | 0.05 all classes | ↑ lowers rate, strongly | Expected 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. |
maintenancePct | fraction/yr | 0.03 (support) – 0.075 (camera_body) | ↑ raises rate | Annual upkeep as % of RC. |
insurancePct | fraction/yr | 0.014 (support) – 0.025 (camera_body) | ↑ raises rate | Annual risk loading as % of RC (note: there is no separate insurance product; this is a cost loading). |
overheadPct | fraction/yr | 0.08 (support) – 0.13 (accessory) | ↑ raises rate | Allocated business overhead as % of RC. |
marginPct | fraction 0–1 | 0.18 (support) – 0.21 (camera_body/lighting) | ↑ raises rate | Profit markup via ÷(1−margin). |
Only the sum
maintenancePct + insurancePct + overheadPctaffects 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.