Every dollar is now an integer of cents
We migrated the claim, remittance, and wallet spine off floating-point dollars and onto integer cents — and the compiler-invisible unit change surfaced three latent 100x bugs that were already in the tree.
The split-brain the audit found
Our combined-build audit flagged a money spine that spoke two units at once. The claim and remittance columns — totalCharge, billed, amountPaid, amountDenied, patientResponsibility, and Remittance.paidAmount — were Float dollars. Meanwhile every newer module (estimates, the WORM ledger, the underpayment sentry, PaymentTransaction) already stored integer cents. That is not a cosmetic inconsistency. A value read from Claim.amountPaid (dollars) and a value read from the ledger (cents) are both number to TypeScript. A crossed wire between them is a silent 100x error that the compiler will never catch.
Two real defects, not one
The first is float drift. ProviderWallet.lifetimeEarnings/lifetimeFees and Claim.amountPaid are incremented repeatedly — settlement credits, corrected-ERA re-posts. Repeated floating-point addition accumulates representation error and diverges from the cent-true PaymentTransaction rows that are supposed to be the truth. The second is unit ambiguity: dollars and cents are indistinguishable at the type level, so the split-brain was one refactor away from a hundredfold mispayment at all times. We did ProviderWallet first as a self-contained pilot — worst drift, fewest read sites — before touching the wider claim spine.
Rename so the compiler enumerates every consumer
A blind Float->Int swap with a *100 backfill is unsafe precisely because the unit change is invisible to tsc. So we didn't do a silent swap — we renamed the seven columns to a *Cents Int suffix. That forces the compiler to enumerate all 134 consumer sites across 25 files. No silent misses. Four partitioned agents fixed the sites under one strict units convention, then a reconciliation pass swept the out-of-scope dollar-native consumers. Storage is integer cents end-to-end; conversions now live ONLY at true dollar boundaries.
// Writes from dollar sources (API input, ClaimLine.charge sums)
totalChargeCents: Math.round(dollars * 100)
// Reads for dollar output (837P EDI, scrubber, human display)
const display = (cents / 100).toLocaleString('en-US',
{ style: 'currency', currency: 'USD' });
// Stripe amounts are ALREADY cents -> direct assign, no *100
amountPaidCents: stripeEvent.amount;The 100x bugs the rename exposed
Because the rename made every consumer recompile, it surfaced pre-existing latent bugs that had been masked by the split-brain: the Stripe webhook was adding + amountCents/100 where both operands were already cents; settlement.ts was calling Math.round(grossDollars*100) on a source that was now cents; the gate was applying dollarsToCents()to the already-cents billed columns. Each of these was a live 100x error waiting for the wrong input. The migration didn't introduce them — it found them.
Verified green, apply still gated
The code is verified green: tsc reports zero errors, and the timeline, rate-floors (19), physician-revenue-alert (25), and eob-intel (76) suites pass. The RCM read/display routes deliberately keep their dollar-named response keys (with a /100at the edge) so there's no API contract break for frontends that render dollars. What is NOT done is the production apply: migration 20260714130000_claim_remittance_cents (rename + retype + *100 backfill) runs the expand/contract pattern against real Cloud SQL, and that is user-gated behind a coordinated redeploy. Code-green is a different claim than in-production, and we register it as such.