01Why now
The current engine computes an invoice by walking the subscription's history at read time. That was a reasonable choice when there was one plan and no proration, and it is why we cannot answer the question a customer actually asks — why is this month different? — without re-running the calculation and hoping it comes out the same.
Twice this year it did not come out the same. Both times the cause was a plan change that had since been superseded, and both times we credited the customer because we could not prove the original number. That is the failure this rewrite exists to end.
The second reason is that finance closes the month by exporting a CSV and correcting it by hand. The corrections are consistent enough that they are effectively a spec, and that spec belongs in the system.
02Requirements
Each one names how we will know it is met.
An issued invoice is immutable
Once issued, an invoice's line items and total never change. A correction is a new credit note that references it. This is the requirement the whole design turns on.
Verified by a test that mutates the underlying subscription after issue and asserts the stored invoice is byte-identical.
Every line item explains itself
Each line carries the rule that produced it, the inputs it used, and the period it covers — enough for support to answer "why is this month different" without opening a console.
Verified by rendering a proration case and reading it aloud to two support engineers who did not build it.
Reruns are identical
Recomputing an invoice from the same inputs produces the same total, to the cent, regardless of when it is run.
Verified by replaying twelve months of production invoices against the new engine and diffing totals. Target: zero differences.
Finance closes the month without editing a CSV
The corrections finance currently applies by hand — rounding on annual plans, the mid-cycle upgrade rule, the tax-inclusive display for two regions — move into the engine.
Verified by finance closing one month with no manual edits, with the old process running alongside as a check.
Customers can see a forecast of the next invoice
The same engine, run against the current period with no issue step. Cheap once the rest exists, and out of scope until it does.
Not scheduled. Listed so it is not designed out.
03The model
An invoice is a document produced by an event, not a view over the subscription. It has four states and only one of them is reversible.
draft. That is the point.The stored shape
Each line carries its own explanation. The rule and inputs
fields are what BR-2 is asking for, and they are why support can answer a question
without a console.
{
"invoice_id": "in_9fQ2xK",
"state": "issued",
"issued_at": "2026-08-01T00:00:04Z",
"period": { "from": "2026-07-01", "to": "2026-07-31" },
"lines": [
{
"description": "Team plan, 12 seats",
"amount_cents": 34800,
// the two fields that let support explain this line
"rule": "seat_price * seats * days_in_period / days_in_month",
"inputs": { "seat_price": 2900, "seats": 12, "days": 31 }
},
{
"description": "Proration, 3 seats added 14 Jul",
"amount_cents": 5142,
"rule": "seat_price * added * remaining_days / days_in_month",
"inputs": { "seat_price": 2900, "added": 3, "remaining_days": 18 }
}
],
"total_cents": 39942,
"engine_version": "2026.08.1"
}
engine_version is stamped at issue. When the rules change, old invoices
stay explicable by the rules that produced them, which is the difference between a
record and a re-derivation.
04Decisions
Decision 1 · accepted
Store the computed invoice, do not derive it on read
Because a derived invoice can silently change when the rules or the subscription change, and twice this year it did. Storage is cheap; a credit we cannot explain is not.
Cost we now have two representations of the same truth and a job that has to keep them honest. We accept that in exchange for a number that never moves.
Decision 2 · accepted
Integer cents everywhere, no floating point
Because the current engine has three places where a float rounds differently depending on evaluation order. That is the root cause of one of this year's two credits.
Cost every currency conversion needs an explicit rounding decision at the call site. That is more code and it is the correct amount of code.
Decision 3 · accepted
Run both engines in parallel for one full cycle before cutover
Because BR-3 is a claim about twelve months of history, and the only way to test it is to make it against real data with the old engine still available to disagree.
Cost a month of double compute and a reconciliation job nobody will ever need again. Cheaper than one wrong invoice sent to a customer.
Decision 4 · rejected
Not moving to a third-party billing provider
Because we evaluated two and both handle proration in a way that would change roughly 400 customers' totals. The rewrite exists so nobody's number moves; buying a system that moves them defeats it.
Revisit if we add usage-based pricing, where the build-versus-buy maths is genuinely different.
05Migration
Four phases. Only the third is irreversible, and it is one command.
Backfill
Replay twelve months of invoices through the new engine into a shadow table. Nothing customer-facing. Diff every total against what was actually sent.
week 9 · done, 0 differences on 14,206 invoices
Parallel run
Both engines compute the September cycle. The old one issues; the new one writes to the shadow table. Any difference blocks the next phase.
week 10 · in progress
Cutover
A feature flag flips which engine issues. The old one keeps computing into the shadow table for one more cycle, so the comparison keeps running in reverse.
week 11 · rollback tested, takes under a minute
Remove the old engine
After one clean cycle. Deleting it is what makes the rewrite finished rather than merely shipped.
week 13 · Q4
06Risks
| Risk | What we do about it | Residual |
|---|---|---|
| A total moves | Backfill diff on twelve months, then a parallel cycle. A single difference blocks cutover. | Low |
| Cutover lands mid-cycle | The flag only takes effect at period boundaries. Flipping it mid-month changes nothing until the first of the next. | Low |
| Tax rules we do not know about | The two regions finance corrects by hand are covered. Any third would be found by the parallel run, not before it. | Medium |
| Shadow table drifts after cutover | The reconciliation job runs in both directions and alerts on any difference. It is deleted in phase 4. | Low |
07Open questions
Named owner, needed-by date. These are the reasons this document is not finished.
| Question | Owner | Needed by |
|---|---|---|
| Credit notes | finance | week 10 |
| Does a credit note need its own numbering sequence for the two regions where invoices do? We have assumed yes and built it; we need confirmation before cutover because the sequence cannot be changed afterwards. | ||
| Annual plan rounding | finance | week 10 |
| The manual correction rounds to the nearest cent per line. Per invoice would be a difference of a few cents a year, and would be simpler. We need someone to say which is correct rather than which is easier. | ||
| Invoice PDF | support | week 12 |
| Should the rule and inputs appear on the customer-facing PDF, or only in the support view? Showing them is more honest and considerably more confusing. | ||
| Old engine deletion | platform | Q4 |
| How long do we keep it after cutover? One clean cycle is our proposal. Longer is tempting and is how systems end up with two billing engines for three years. | ||
Nothing here blocks the parallel run
All four questions can be answered while phase 2 is running. Only the first two block cutover, and both are one conversation with finance.