Fee and lien money validation (M6)
Every percent and dollar figure that feeds settlement math validates before it is used. Corrupt metadata fails loudly instead of silently bending the money — a single NaN anywhere poisons every total downstream of it.
Shared validators (src/lib/referrals/fee-schedule.ts)
parseFeePercent— referral percentages, 0–100,referral-fee:prefix.parseContingencyPercent— contingency percentages, 0–100,selectTierPercent— picks the current tier (else the first) with its
contingency-fee: prefix. Same bound, own prefix so 400s name the right field owner.
percentage validated; null on absent/empty tiers; throws fail-closed on malformed rows. Both disbursement sheets read tiers through it so they always agree.
Null/empty input reads as "unset" (the caller applies its default); anything else must be a finite number in range, otherwise it throws.
Per-route rules
Single-case routes fail with 400 Validation failed on corrupt input:
| Route | Guarded | |-------|---------| | Fee agreement POST | Tier percentages and referralFeePercent | | Disbursement GET | Stored referral percent, tier rows, custom attorneyFees dollars | | Disbursement-calc GET | Stored flat percent and tier rows | | Profitability GET | Stored feePercent | | Cost-benefit POST/GET | feePercentage, litigationCostEstimate, settlementProbability, on write and read | | Settlement wizard PUT | Per-scenario feePercent | | Valuation GET | Firm contingencyPercent | | Settlement-distribution GET/POST | Stored and override percents via resolveFeePercentage | | Lien negotiations POST | originalAmount, currentOffer, acceptedAmount, entry, accept, and counter figures |
Defaults when unset: 33.33 contingency (33.3 firm valuation default), 40 for litigation rows where the route defines one.
Aggregate exception
The revenue projector loops many cases, so one corrupt row must neither poison portfolio totals nor brick the whole dashboard: corrupt per-case percents fall back to the default and are listed in a top-level warnings array (FEE_PERCENT_INVALID with case id/number).
Tenant-safe writes
Money writes go through updateCaseMetadataForFirm (src/lib/cases/metadata.ts) — firm scope lives in the write itself (updateMany on { id, firmId }, 404 on zero matches), not just in a prior read. Fee-agreement and all eight lien-negotiations mutation paths (add, update, entry, letter, accept, counter, paid, dispute) use it.