# ADR-0001 — Move refund amounts to minor units with expand/contract - **Status:** accepted - **Date:** 2026-09-04 - **Deciders:** returns team lead, finance systems lead - **Supersedes:** — ## Context `returns.refund_amount` is a SQLite `REAL` holding dollars. The service reads it into a Python `float` and converts to minor units with `int(amount * 100)`, which truncates: a $76.49 refund reaches the payment vendor as 7,648 cents. The ledger and the vendor disagree by one cent on roughly a third of refunds, and the finance reconciliation has been absorbing the difference manually. Fixing the domain model (ADR-0000, money as an integer of minor units) means the column must change too. The constraint that makes this a decision rather than a task: `reporting.py` — finance's nightly job — reads `refund_amount` directly. It is scheduled outside our repository, deployed by the finance systems team, and their next release window is three weeks out. The returns pilot is committed for 30 September. ## Options considered **A. One deploy in a maintenance window.** Take the service down at 04:00, rewrite the column, deploy both sides, come back up. Simplest code: no dual writes, no backfill job, no version marker. *Rejected because* it needs finance to deploy in the same window, and their release process cannot be scheduled at 04:00 on our date. Coupling our migration to their release calendar puts the pilot date at risk for a change that is not on the pilot's critical path. **B. Expand/contract in four steps.** Add `refund_amount_minor`; write both; backfill in batches; switch reads; then, after finance has deployed, stop writing the old column and drop it. **C. Leave the column and fix only the conversion.** Keep dollars in the database, convert with `Decimal(str(amount))` instead of `float * 100`. *Rejected because* it fixes the symptom and keeps a `REAL` column as the system of record for money. Every future reader has to know not to do arithmetic on it, and floating-point storage of money will produce a different bug later. It is, however, a legitimate emergency patch if the cent discrepancy became urgent before the pilot — noted as the fallback. ## Decision Option B. Four steps, each independently deployable and independently reversible, with the schema version marker advancing 1 → 1.1 → 1.2 → 2 so an operator can see which stage is live. Steps 1–3 are ours alone. Step 4 waits on a written confirmation from finance that their job reads `refund_amount_minor`. ## Consequences **Accepted:** - Four deploys instead of one, and a period of roughly three weeks in which two columns hold the same fact and can disagree. Mitigated by a nightly check that asserts `refund_amount_minor == round(refund_amount * 100)` for every row and alerts on any mismatch. - A dual-write path in the persistence layer that must be deleted at step 4 and will be forgotten if it is not on someone's list. It is on the returns team's board with a due date. - A dependency on a team that does not report to us for the final step. If finance slips, we sit at step 3 indefinitely — which is stable and correct, just untidy. - The backfill touches every historical row. At current volume that is under a minute; the batching exists so it stays under a minute at ten times the size. **Gained:** - The pilot is not blocked on finance's release calendar. - Every step is reversible by deploying the previous build, except step 4. - The cent discrepancy is gone from the moment step 3 ships. ## Revisit trigger If step 4 has not happened 60 days after step 3, escalate: either finance commits to a date or we accept the dual-write path as permanent and document `refund_amount` as a derived compatibility column with an owner. If a second external consumer of the `returns` table is discovered before step 4, stop and re-plan — the four-step sequence assumes exactly one reader we do not control.