carlos / platform Public

Merge per-line tax codes, the mechanics behind the hold (design-per-line-tax-codes at 12590e3a): three code reviews, both reviewers accepting at the third with its fixes made

A month whose paid spans carry two product tax codes still holds
(billing_invoice_tax_code_mixed); which code each overage line carries is
decision 16 and 16a to 16d, the owner's. Built and inert: a line may
carry its own code and the allowance names its spans; the invoice
engine journals, recovers and validates a two-code line by meter and
code; eligible days are attributed to the plan in force and each axis's
unchanged amount apportioned; the activation glue exists uncalled.
Every one-code month's bytes, digest, requests and operation kinds are
pinned to the base commit's. One behaviour changes for every month: a
resumed finalize validates the draft it read. Gate alone and CI passed
on 12590e3a. The activation rule is in ENABLEMENT.md section 3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e2adf26f4610c42a805d10de8593375bc23701d3 parent 1bda76aparent 12590e3
22 files changed, +2809 −54
  • docs/superpowers/plans/2026-10-03-per-line-tax-codes-plan.md +49 −0
  • docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md +68 −0
  • docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review1.md +82 −0
  • docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review2.md +106 −0
  • docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review3.md +193 −0
  • docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md +82 −0
  • docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review1.md +149 −0
  • docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review2.md +119 −0
  • docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review3.md +148 −0
  • internal/billingstripe/invoices.go +12 −0
  • internal/billingstripe/invoices_test.go +26 −0
  • internal/console/billingcalculation.go +91 −32
  • internal/console/billinginvoice.go +70 −16
  • internal/console/billinginvoice_test.go +45 −6
  • internal/console/billingtaxlifecycle_test.go +474 −0
  • internal/console/billingtaxshape_fixtures_test.go +209 −0
  • internal/console/billingtaxshape_test.go +78 −0
  • internal/console/billingtaxsplit.go +360 −0
  • internal/console/billingtaxsplit_test.go +362 −0
  • internal/plans/lines.go +6 −0
  • internal/plans/month.go +18 −0
  • internal/plans/month_test.go +62 −0
diff --git a/docs/superpowers/plans/2026-10-03-per-line-tax-codes-plan.md b/docs/superpowers/plans/2026-10-03-per-line-tax-codes-plan.md
new file mode 100644
index 000000000..404cf7294
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-03-per-line-tax-codes-plan.md
@@ -0,0 +1,49 @@
+# Plan: per-line tax codes, the mechanics (design revision 3)
+
+Date: 2026-10-03. Builds `docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md` on the branch `design-per-line-tax-codes` (renamed in spirit: the design and its build share the branch). Every task is test first, keeps the hold (`errBillingTaxCodeMixed`) in place, and leaves every one-code calculation, request and operation byte-identical. Decision 16 and 16a to 16d are Paul's; nothing here activates anything.
+
+## Task 0. Pin today's bytes before any change
+
+In `internal/console` and `internal/plans`, a test file `billingtaxshape_fixtures_test.go` with literal JSON of today's frozen calculations and their digests, request hashes and operation kinds for: a legacy version 1 month; a price-book version 2 month with one plan; a one-code month with two plans (Personal to Community); a quantity change inside a month; a partly covered month; a waived month; a fully covered one-code month with non-zero usage and no `Days`. The literals are produced once by a throwaway test on the unchanged code and committed as text, never regenerated. Tests assert that pricing those inputs reproduces the literal bytes. This task changes no production code and is its own commit.
+
+## Task 1. The two fields, in `internal/plans`
+
+- `OverageLine.TaxCode string \`json:"tax_code,omitempty"\``; `Meter` and `AmountCents` keep their untagged names.
+- `PlanSpan{From, To time.Time; Plan string}` and `MonthAllowance.Spans []PlanSpan \`json:"spans,omitempty"\``.
+- `AllowanceForMonth` returns the unmerged paid intervals, half-open, one per counted assignment, in `Spans`; a new exported helper `(MonthAllowance).WithoutSpans()` (or the console simply clears the field) so the console drops them for a one-code month.
+- Tests: Task 0's fixtures still byte-identical when `Spans` is cleared and no line carries a code; spans for a continuous change, a quantity change (two spans, same plan), a lapse, a change at the month's first instant.
+
+## Task 2. The shape rule and the line identity, in `internal/console`
+
+- `wellFormed`: the tax shape (one-code / two-code / waived branch; `txcd_` syntax wherever a code appears; a non-waived calculation with no lines refused).
+- The entry guard keeps account, month and `wellFormed`; drops the separate top-level predicate.
+- `lineParams(line)`: the line's code or the calculation's; refuse both or neither.
+- `billingstripe.InvoiceItemParams.TaxCodeMetadata string \`json:",omitempty"\`` sent as `metadata[tax_code]` when set; `InvoiceItem`/`InvoiceLine` metadata already decoded.
+- Operation kind: `line-<meter>` for a line without a code, `line-<meter>-<code>` for one with; the POST check, the recovery match and the draft validation require `metadata[tax_code]` equal to the frozen line's for a line that carries one.
+- Tests: Task 0's request hashes and kinds unchanged; the shape refusals (both levels, neither, one line lacking, malformed code, non-waived with no lines, waived branch accepted); the fake provider stores and returns `tax_code` and `metadata[tax_code]`; corruption of the metadata in the POST answer and in the draft listing each refused; equal-amount lines distinguished.
+
+## Task 3. Attribution and apportionment, behind the hold
+
+- `eligibleDays(statement, allowance)`: today's rule for a partly covered month (`billingUsageOnPaidDays` unchanged), the whole month as the one interval when `Covered` is nil.
+- `usageByCode(statement, spans, codeOf func(plan) string, rule)`: per (code, statement meter) float sums over eligible days in date order (16a's proposal as the one rule body: the span containing 00:00 UTC of the day); one `billingBaseUnits` per sum; transfer meters added per code with the overflow check; returns the four axes' weights per code. `Days` absent: waived when every billed meter is zero, else `errBillingDays`.
+- `apportion(amountMinor, weights map[code]int64) []share`: floor shares, largest remainder, byte-order ties, zero shares omitted, zero weight sum with a positive amount an error.
+- `taxCodesForLines(...)`: returns `errBillingTaxCodeMixed` for two codes (today's behaviour); two unexported bodies, `splitByGrossUsage` and `lastSpansCode`, exist and are tested but are not called from `freeze`.
+- Tests: the attribution and apportionment lists in the design, including the fractional transfer boundary asserted on the weights, the shuffled-map determinism, and the end-to-end two-code calculation produced through a test seam (not through `freeze`), with its JSON shape, digest and lines.
+
+## Task 4. The lifecycle and the older reader
+
+- A test seam lets a test freeze a two-code calculation (the body chosen explicitly); the invoice run then creates two lines for one axis, with equal and unequal amounts; in-window replay and expired-window recovery for each split line, a paginated draft listing; a one-code month's journal unchanged.
+- The older reader as a harness (`olderReaderStep` in `billingtaxlifecycle_test.go`): the entry of `29ea6676` copied (the terminal skip, the decode, the account, month, money and top-level checks, the review filed by the run's own method), calling the shared `wellFormed`, unchanged since; a two-code record makes the old flow file `billing_invoice_record_mismatch`, write nothing to the provider and leave the bytes unchanged; the new reader blocked until the review is resolved; an unrelated second review keeps the month held after that.
+- A mixed history that is waived, and one emitting one code only, through both readers.
+
+## Task 5. Records, at the merge
+
+On the launch branch: `ENABLEMENT.md` gains the activation rule, keyed to `freeze` (before any build whose `freeze` can write a two-code calculation runs anywhere, every invoice worker is on a build containing the merge and older ones are stopped, and stopped before their accidental reviews are resolved; no rollback below that build afterwards; the generic rollback note does not apply past activation; 16d's conditions on a held month's release), and states the every-month tightening from the merge (a resumed finalize validates the draft for every month; a one-code draft that changed under a journaled finalize holds with `billing_invoice_lines_mismatch`); `OUTSTANDING.md` item 17 points at the build and the hold; `READINESS.md` names the branch. The design's "Not in this design" stays true.
+
+## Task 6. Mutants and dispositions
+
+In a git-less tree copy: the shape rule's each clause; the three metadata checks; the `omitempty` on each new field (a one-code fixture changes bytes); the floor-before-add order; the tie order; the zero-weight hold; the eligibility-before-attribution order; the whole-month interval. Each with its killing test logged. Dispositions per review round in `DISPOSITIONS-per-line-tax-codes.md`.
+
+## Reviews
+
+Each task's commit gets the full `internal/console` and `internal/plans` run (`-timeout 30m`, `GOFLAGS=-buildvcs=false`); the branch gets the gate alone on its exact tip, Opus (AGENTS' production reviewer) and Astra on the whole branch, rounds until both accept; the merge into the launch branch is a merge commit after that and CI.
diff --git a/docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md b/docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md
new file mode 100644
index 000000000..846cb7653
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md
@@ -0,0 +1,68 @@
+# Per-line tax codes for a month in which the plan changed
+
+Date: 2026-10-03. Status: design, revision 6, as built and confirmed (the third code review, both accept with fixes, no P1 or P2: a race test for the single read, the roll rule keyed to `freeze`, wording; made before the merge). Revision 6 was written after (the second code review, both accept with fixes, no P1 or P2, corrected: the body's shape covers the two proposals and not every conceivable answer; the shape rule is `taxShapeOK` beside `wellFormed`; the apportionment review is tested through the preparer seam; a resumed finalize validates the invoice it read and that tightening applies to every month. Revision 5 was as built after the first code review (the first code review, Opus and Astra both accept with fixes, corrected three claims here: activation is more than one line, the glue now exists uncalled; maps are not all iterated in sorted order, and need not be; a waived calculation has no lines. Revision 4 was built from (revision 3 accepted with fixes by Astra's confirming review: no P1; one P2, the month-end answer's daily-data exemption contradicted partial-month pricing, and two qualifications, the waiver sentence and the older worker's nonterminal records; corrected here. Revision 2 accepted with fixes by Astra: no P1; three P2, a daily-data requirement that would regress one-code months, the line identity not checked on every acceptance path, two readings of the transfer arithmetic; two P3, references not yet written, "the next pass" promised without its conditions; each answered below. Revision 1 rejected by Astra on two P1s, four P2s and a P3: the invoice engine's per-meter line identity, held months that cannot resume, an older worker's durable review, the day-eligibility rule, policy stated as mechanics, imprecise apportionment, the zero-usage statement; each answered below). Answers `OUTSTANDING.md` item 17 and prepares decision 16 (`DECISIONS-FOR-PAUL.md`), which is Paul's; nothing here chooses it. Branch `design-per-line-tax-codes`, off the launch branch at `29ea6676`.
+
+## What happens today
+
+An account's month is invoiced from one frozen calculation (`billingCalculation`, `internal/console/billingcalculation.go`): one overage line per billed meter (`plans.InvoiceOverageLines`, `OverageInvoiceFor`), and one product tax code for the whole calculation (`TaxCode`), taken from the plan of each paid assignment that covered the month (`BillingConfig.taxCode(plan)`). Personal and Community share `txcd_10102001` (software, personal use) and Business has `txcd_10102000` (software, business use); these are the approved codes and stay as they are. When the month's covering assignments carry two codes (a change to or from Business inside the month), `freeze` returns `errBillingTaxCodeMixed` and the month holds with the review `billing_invoice_tax_code_mixed`, for a person. Nothing is invoiced for it until a person acts, and today a person has no door that invoices it either.
+
+Two facts make this a tax-code question and nothing else:
+
+- allowances are already time-weighted across the paid spans (`plans.AllowanceForMonth` weights each assignment's allowance by its paid interval over the month), so the money of a month with a plan change is already right for the time each plan was in force;
+- overage rates and the floor are per currency, not per plan (`PriceTerms.overageRates`; `OverageFor` takes the plan only to check it exists), so the amount of each meter's overage line does not depend on which plan was in force when the usage happened.
+
+So the only thing missing is which tax code each overage line carries. That is decision 16, with two answers on the table: split each meter's line by span (one line per tax code, the usage of each UTC day attributed to the plan in force at that day's start) or tax the whole month by the plan in force at the month's end. Both keep every amount as it is today.
+
+## What is the owner's, stated as such
+
+Decision 16 says "split by span, with the usage of each UTC day attributed to the plan in force on that day" or "the month-end plan's code". Neither sentence is precise enough to build from, and the refinements are choices, not mechanics. They are listed in `DECISIONS-FOR-PAUL.md` under 16 as 16a to 16d and this design chooses none of them; it builds the two proposals as bodies of one shape (the lines, a way to the per-code weights, the last paid span's code), and the glue holds what another answer would need (the spans, the month) without pretending the shape already expresses 16b's alternatives or a 16c hold:
+
+- **16a, the change day.** A UTC day is the finest grain the frozen billing statement provides (the collection underneath has finer readings; the statement does not). Proposed approximation: a day's usage follows the plan in force at the day's start (00:00 UTC). It can attribute nearly a whole day to the earlier plan.
+- **16b, how a meter's unchanged amount is divided between codes.** Proposed: in proportion to the usage attributed to each code (gross usage, after the eligibility rule below). The alternatives differ: by each span's own excess over its own share of the allowance (a span whose usage stayed under its allowance gets nothing), or by consuming the pooled allowance chronologically and attributing only the excess after it. With span allowances of 100 and 100 and usage of 50 and 250 the pooled overage is 100: gross weighting gives the first span a sixth of it, the other two give it nothing. Pricing spans independently would charge 150, not 100, and is not an option here: amounts do not change.
+- **16c, no paid plan at the month's end.** "The month-end plan" is undefined when the last paid span ended before the month did (a cancellation; the account became Free). The last paid span's code, or a hold, is the owner's.
+- **16d, months already held.** A month held today with `billing_invoice_tax_code_mixed` is not invoiced by the switch: its review stays open until an operator resolves it (`carlos billing resolve`). After that the pass prices it only when nothing else holds it: no other review of the account is open (`freezeBillingMonth` refuses while any is, so release is account-wide where other reviews remain), its retry backoff has passed, and the month is within the pass's twelve-month lookback; a held month older than that is not scheduled by resolving its review and needs its own handling, which this design does not provide. Whether every such month is resolved at once on activation, or case by case, is the owner's.
+
+What the design asserts without a decision: pre-tax amounts (each resource axis's minor units and the subtotal) do not change under any answer; the floor stays the monthly floor; the tax-inclusive total can differ, because attribution is what tax is computed from.
+
+## What this design builds, and what it leaves to the decision
+
+It builds the mechanics every answer needs, keeps the hold exactly where it is until the answer, and keeps every calculation that has one tax code frozen byte for byte as today (its digest, request hashes and operation names unchanged).
+
+1. **A line carries its own tax code.** `plans.OverageLine` gains `TaxCode string \`json:"tax_code,omitempty"\``; its existing fields keep their untagged names (`Meter`, `AmountCents`), so no old byte moves. Two shapes: a **one-code calculation** has `tax_code` set and no line carrying one (today's shape, every existing record, every month whose counted spans map to one code, including Personal to Community changes, quantity changes and partial coverage); a **two-code calculation** has `tax_code` empty and every line carrying a code. `taxShapeOK`, asked beside `wellFormed` at the entry guard and in `lineParams`, is the tax shape (the money shape stays `wellFormed`'s): exactly one of the two; a code of the form `txcd_` plus at least one character wherever one appears; a non-waived calculation with no lines refused; and an explicit waived branch, no lines, carrying the one-code shape when the spans map to one code and the two-code shape (empty `tax_code`, no lines) otherwise. Two distinct codes among the emitted lines are not required: a two-code history can have chargeable usage under one code only, and the month-end answer puts one code on every line. Version 1 calculations stay version 1 and are read as today. The entry guard in `billingInvoiceRun` keeps its account and month checks and `wellFormed`, and drops only the separate top-level `txcd_` predicate, which the shape rule now covers; `lineParams` takes the line's code or, for the one-code shape, the calculation's, and refuses a line with a code at both levels or at neither.
+
+2. **A line has a stable identity, checked on every acceptance path.** The invoice engine journals each provider operation under a kind (`line-<meter>`), replays by request hash (a hash of the `InvoiceItemParams` JSON), checks the provider's answer to a POST against the frozen line, recovers a lost answer by matching the provider's lines on `metadata[meter]` and `metadata[calculation_digest]`, and validates the draft by the same kinds and keys. One-code calculations keep all of that unchanged: the new `InvoiceItemParams` field for the line's metadata tax code is `omitempty`, so an old request's JSON, and its hash, are byte-identical. A two-code line's kind is `line-<meter>-<tax code>`; its request carries `tax_code` and `metadata[tax_code]`, both from the frozen line; the POST answer is accepted only if the item's `metadata[tax_code]` is the frozen line's; recovery matches meter, digest and tax code; the draft validation expects the operation of each line's own kind and requires each listed line's `metadata[tax_code]` to be the frozen line's, equal-amount lines included. The old kinds and the new cannot collide (a tax code is never empty); a calculation is frozen before any operation and its shape decides every kind, and an old worker refuses the new shape, so no path acknowledges an old-kind and a new-kind operation for the same axis of one calculation.
+
+3. **Eligible days first, as today; then attribution.** Which days of a partly covered month are billed does not change: a day counts only when the whole of it lies inside the merged paid coverage (`billingUsageOnPaidDays`, `Allowance.Covered`, `CountedDays`). A continuous change from Personal to Business at 15:00 UTC keeps that day eligible (the merged coverage is continuous); a lapse at 15:00 excludes it, as today. For a month covered from its first instant to its last `Allowance.Covered` is nil by design; attribution then takes the whole month as the one eligible interval, and no day is lost to a nil list. Attribution runs on eligible days only, against the unmerged spans: `plans.MonthAllowance` gains `Spans []PlanSpan \`json:"spans,omitempty"\`` with `From`, `To` (half-open, `[From, To)`) and `Plan`, one per paid assignment interval the allowance counted, written by the console only for a two-code month (the plans package knows no tax codes; the console asks for the spans and drops them when they map to one code), so no one-code month's JSON changes. Under 16a a day belongs to the span that contains 00:00 UTC of that day.
+
+4. **Usage per code, five meters to four axes, in today's order.** For each (code, statement meter) the eligible days' values are accumulated in date order as the statement's floating values, one sum per pair (spans carrying the same code aggregate into the same sum); each accumulated sum is converted once by `billingBaseUnits` (the floor the month's own usage gets); then, per code, the two transfer meters' integers are added with the overflow check, into the four axes. That is the month's own order (floor each of the five meters, then add the two transfer integers), so a month with ordinary egress under Personal and store egress under Business has both in the transfer axis's split and a fractional boundary in one meter cannot move a unit by being floored after the addition. These per-code integers are allocation weights only; the month's billed usage and amounts come from the whole month's meters exactly as today. **The daily data requirement does not widen for one-code months:** `Days` are read and validated only where they are today (a partly covered month) and, new, for a two-code whole month under the split answer, which cannot be attributed without them; a two-code whole month without `Days` and with non-zero billed usage holds as `billing_statement_days_unusable` under the split answer (that is the one new behaviour, confined to months that hold today anyway); the month-end answer adds no daily-data requirement of its own, and partly covered months keep today's requirement under either answer (a mixed-code month that was also partly covered, say Personal then Business then a cancellation on the 20th, cannot be priced without `Days` whichever code its lines carry, because the eligible days cannot be told from the rest); a statement without `Days` and every billed meter zero prices as waived today, though a mixed-code history still holds before that today, and will until the switch. Validation of `Days` when read is today's (dates inside the month, finite non-negative values, sums agreeing with the monthly totals).
+
+5. **A meter's amount is apportioned, never re-priced.** The four axes' minor-unit amounts and the subtotal are computed exactly as today (`overageLines`: floor, floor test, invoice-wide largest-remainder hand-out between axes). Then, under 16b's gross-usage proposal, each axis's amount is divided among that axis's codes: weights are the per-code base units; `share_c = floor(amount * weight_c / sum_weights)` in minor units, with the minor units still owed to reach the axis's amount handed out one at a time to the codes with the largest remainder, ties broken by the code string's byte order; a code's share of zero is omitted; an axis with a positive amount and a zero weight sum holds (`billing_invoice_tax_code_mismatch`: the usage that produced the charge is not attributed). Lines are ordered by axis order then code byte order. The invoice-wide rounding is never rerun over the expanded lines. Under 16c's last-span answer, every line carries one code and nothing is divided. Where order matters (the days, the codes of an axis, the lines) it is fixed by date, byte order and axis order; the maps that are ranged without sorting feed sums whose result does not depend on order, and two runs over the same inputs produce identical bytes (tested).
+
+6. **The hold stays until the decision.** `freeze` keeps returning `errBillingTaxCodeMixed` for a two-code month, through `taxCodesForLines`. Behind it, uncalled, is everything activation needs: `twoCodeCalculation`, the glue that takes a month priced as today, asks the allowance for its spans again, attributes the eligible days (only if the body divides by them) and hands the lines to the chosen body (`bodySplitByGrossUsage`, `bodyLastSpansCode`), returning the two-code shape with its spans kept; and the review for a charge with no attributed usage (`errBillingTaxApportion` is `billing_invoice_tax_code_mismatch`). Activation is still a reviewed change of its own: the switch in `freeze` from the error to the glue with the decided body, its tests through `freeze` (the attribution that produces `errBillingTaxApportion` reached from a real statement and history; the review it files is already tested through the preparer seam), and the roll rule; it is not one line, and nothing here pretends it is.
+
+## Compatibility and the roll rule
+
+- **Roll order is not free.** An older invoice worker that reads a nonterminal two-code calculation (terminal records are skipped before the guard) passes today's money-only `wellFormed`, fails the top-level code check, and files `billing_invoice_record_mismatch` for the month: a durable, account-wide hold that then blocks the new worker too (`checkBillingHoldTx`), and that it can file again after an operator resolves it while it still runs. It does not invoice and does not rewrite the calculation, but it is not harmless. So: before any build whose `freeze` can write a two-code calculation runs anywhere, every console that can run the invoice pass is on a build containing this change, and older ones are stopped (and stopped before their accidental reviews are resolved, since a running one files them again); no older build is rolled back to afterwards; and the generic rollback note in `ENABLEMENT.md` ("an older binary runs against the migrated schema") does not apply past activation. Those sentences are a required edit to `ENABLEMENT.md` in the plan's last task, beside the retirement protocol's roll rule, before activation; until activation no two-code calculation exists, so the change itself rolls in any order.
+- A finalize that was journaled and then lost is not replayed or renewed over a draft whose lines would not pass today: the resumed step reads the invoice once and, while it is a draft, validates that reading's lines (the metadata code among them) first; a finalize landing between reads cannot look like one made outside. This applies to every month from the merge, one-code months included: a one-code draft that changed under a journaled finalize now holds (`billing_invoice_lines_mismatch`) instead of being finalized. `ENABLEMENT.md` says so.
+- A calculation frozen before this change is read exactly as before. The barrier trigger on `billing_months` reads `$.v` and is untouched; the version of a price-book calculation stays 2, a legacy one stays 1.
+- Months already held: 16d, with its conditions (no other open review, the backoff, the twelve-month lookback). Nothing resolves a review but the operator door, and this design adds no automatic resolution and no handling for a held month outside the lookback.
+- `DECISIONS-FOR-PAUL.md` carries 16a to 16d (launch branch `27419dcf`); the design's own text calls each a proposal and nothing in it reads as decided.
+
+## Tests
+
+- Byte and digest fixtures, pinned as literal bytes recorded before the change (not regenerated by it): a legacy version 1 month; a price-book version 2 month with one plan; a one-code month with two plans (Personal to Community); a quantity change; a partly covered month; a waived month; a fully covered one-code month with non-zero usage and no `Days` (priced today, priced after). Each byte-identical, with its request hashes and operation kinds.
+- A two-code month still holds, with the same review, and nothing is frozen; its review resolved by the operator door and the month priced on the next pass once a body is enabled in the test.
+- The attribution: a change at 00:00 UTC; a continuous change at 15:00 (the day eligible, attributed under 16a to the earlier plan); a lapse at 15:00 (the day excluded as today); an expiry mid-month; a change exactly at the month's first instant; a fully covered month (nil `Covered`) attributed over the whole month; repeated spans with one code aggregated; the no-paid-plan month end; both transfer inputs under different codes; malformed `Days` (a date outside the month, a negative value, sums disagreeing) holding; `Days` absent with zero usage waived; absent with non-zero usage on a fully covered two-code month holding under the split answer and priced under the month-end one; absent on a partly covered two-code month holding under both.
+- The apportionment: each axis's shares sum to its amount and the subtotal is unchanged; a code with all of an axis's usage gives one line; equal remainders broken by byte order; a tiny share rounding to zero omitted; a zero weight sum with a positive amount holding; overflow refused; shuffled map insertion giving identical bytes; a fractional boundary in one transfer meter (two fractions of a byte under one code, a whole gigabyte under the other) asserted on the weights (floor each meter, then add), which conservation alone would not catch.
+- The invoice lifecycle for a two-code month, through a record frozen by hand: two lines for one axis with equal and with unequal amounts; for each split line an in-window replay (one more arrival under the same key) and an expired-window recovery (no arrival), with a paginated draft listing; recovery by the three metadata keys; the draft validated against both kinds; the POST answer and the draft listing each refused when `metadata[tax_code]` is missing or wrong on one line (the fake provider stores and returns the tax code and the metadata, which today it does not); the transmitted `tax_code` and `metadata[tax_code]` asserted to be the frozen line's; a one-code month's journal, request bytes and hashes unchanged; a mixed history that is waived, and one that emits one code only, through both reader generations.
+- The older reader, as a harness copying the entry of `29ea6676` (the terminal skip, the decode, the account, month, money and top-level code checks, the review filed by the run's own method; `wellFormed` is the shared function, unchanged since): a two-code record makes it file `billing_invoice_record_mismatch`, write nothing to the provider, leave the calculation bytes unchanged, and block a subsequent new-reader run until the review is resolved; and with a second, unrelated review open on the account, the resolution of the tax-code review alone does not free the month.
+- `taxShapeOK` and `lineParams`: a code at both levels; at neither; a two-code calculation with one line lacking a code; a malformed code; a non-waived calculation with no lines; a waived one with lines.
+- Mutants in a tree copy once the assertions above exist, each with its killing test named.
+
+## Not in this design
+
+16a to 16d; any change to pre-tax amounts or to the monthly floor; a door that invoices a held month by hand (the operator door resolves the review and the pass invoices); the box side (`Days` are written for every account with usage); copy on the invoice line (the description stays input-bound until the copy review).
+
+## Questions for the reviewer
+
+A. Does the line identity (`line-<meter>-<code>`, `metadata[tax_code]`) cover every path the journal, replay, recovery and validation take, and is there a path where an old-kind operation and a new-kind one for the same axis could both be acknowledged? B. Is "eligible days as today, then attribution against unmerged spans" the right order, and is there an eligible day that no span contains? C. Is the apportionment now reproducible from the text alone (five to four, base units per code, floor and largest remainder within the axis, byte-order ties, sorted iteration)? D. Is the roll rule sufficient for the older worker, given that the hold it files is resolvable by the operator door? E. Anything the owner is being asked to decide that is in fact mechanics, or the reverse.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review1.md b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review1.md
new file mode 100644
index 000000000..9bf62506a
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review1.md
@@ -0,0 +1,82 @@
+**The dispositions mostly hold in the implementation, but the verification claims are overstated.** I found no change to valid one-code calculation bytes or pricing by inspection. The remaining issues concern finalization recovery, compatibility fixtures, and missing lifecycle coverage. No tests or network commands were run; nothing was modified. All code references below are at `61313bec`.
+
+| Design finding | Assessment against the code |
+|---|---|
+| Round 1 P1: per-meter identity cannot support two lines | Addressed: kinds, request metadata, POST checks, recovery matching and draft lookup distinguish codes. Finalization recovery needs the qualification in finding 1. |
+| Round 1 P1: held months cannot resume automatically | Addressed: no automatic resolution was added. Operator resolution remains necessary. |
+| Round 1 P2: older workers create account-wide holds | Operational rule is correct. The promised old-reader test is incomplete; see finding 3. |
+| Round 1 P2: eligibility before attribution | Addressed: whole-day eligibility precedes half-open span attribution; full coverage gets a whole-month interval. |
+| Round 1 P2: attribution choices belong to the owner | Addressed: the proposed algorithms remain unused behind the mixed-code hold. |
+| Round 1 P2: precise apportionment | Addressed in code: chronological accumulation, separate floors, checked transfer addition, integer largest remainders and byte-order ties. |
+| Round 1 P3: absent `Days` with zero usage | Preserved wherever daily data is required. Whole one-code months retain their existing exemption. |
+| Round 2 P2: broader daily requirements regress one-code months | Addressed in production code. Task 0’s compatibility evidence needs correction; see finding 2. |
+| Round 2 P2: POST and draft metadata checks | Both are implemented correctly. Tests cover missing metadata, but not wrong nonempty metadata. |
+| Round 2 P2: transfer arithmetic ambiguity | Addressed: floor each meter, then add. The fractional test checks weights, not the promised resulting shares. |
+| Round 2 P3: decision and rollout references | 16a–16d exist at `27419dcf`; rollout documentation remains Task 5 at merge. |
+| Round 2 P3: unconditional “next pass” | Qualified correctly in the design. The claim that those conditions are written in the cited decision 16d is inaccurate. |
+| Round 3 P2: month-end exemption versus partial-month pricing | Addressed in prose and preserved pricing code. The promised combined policy/partial-month test is absent. |
+| Round 3 qualifications: waiver and terminal records | Correctly qualified in the design. Reader tests do not exercise these cases. |
+
+1. **P2 — Resuming finalization bypasses the new draft metadata check.**
+ **Location:** `internal/console/billinginvoice.go:1199`; initial validation at `:1065`, renewal at `:1172`.
+
+ **Scenario:** Both split lines pass validation. The engine journals `finalize`, then crashes before its POST. While the invoice remains a draft, one item loses or acquires incorrect `metadata[tax_code]`. On resumption, `stepFinalizing` sends the request without calling `validateDraftLines`. Beyond the replay window, `recoverFinalize` likewise checks only the invoice envelope before permitting a renewed request. `verifyFinalized` checks amounts and tax status, not line metadata.
+
+ Thus a draft whose split-line metadata would now fail validation can reach finalization. This retry structure predates the branch, but the new line-identity invariant does not cover it.
+
+ **Smallest acceptable fix:** Before replaying or renewing finalization of an invoice that is still a draft, validate its lines, including tax metadata. Preserve recovery of already-finalized outcomes. Add a two-code test interrupted after journaling finalization, with metadata corrupted before resumption, inside and outside the replay window.
+
+2. **P2 — Task 0 does not pin the actual frozen calculations or the engine’s legacy request hashes.**
+ **Location:** `internal/console/billingtaxshape_fixtures_test.go:104`, `:124`, `:126`.
+
+ The literals are literal, and their file has only the original Task 0 commit in this branch’s history. However:
+
+ - Every calculation literal has an empty top-level `tax_code`: encoding happens before `freeze` assigns it at `billinginvoice.go:485`. These are pricing outputs, not the stored calculation bytes and digests.
+ - Requests are constructed manually instead of through `lineParams`; kinds are reconstructed instead of calling `lineKind`.
+ - The test calls `billingRequestHash` directly. Version-1 invoice operations actually call `requestHash`, which removes `Currency` before hashing at `billinginvoice.go:895`.
+ - `legacy_two_codes` describes a history that the real freeze path holds.
+
+ **Scenario:** A regression in one-code `lineParams`, operation naming, or the legacy currency adapter can leave these fixture assertions unchanged. The pinned legacy hashes are not the hashes that the engine journals.
+
+ **Smallest acceptable fix:** Keep these literals as pricing fixtures, accurately labelled. Add independently recorded base-commit literals for real one-code frozen bytes/digests and requests, and compare through `lineParams`, `lineKind` and `requestHash`, including version 1. Include the draft request as well as line requests. Do not regenerate historical expectations using the changed implementation.
+
+3. **P2 — The promised reader and lifecycle verification is incomplete.**
+ **Location:** `internal/console/billingtaxlifecycle_test.go:111`, `:137`, `:169`, `:188`.
+
+ The tests establish useful happy-path and missing-metadata behavior, but omit important promised distinctions:
+
+ - Only the second split line loses its response. Recovery of the first line, with no sibling yet present, is not exercised.
+ - Corruption removes metadata; it never substitutes a wrong nonempty code. Replacing the POST or draft equality check with a mere nonempty check would survive these cases.
+ - The “older reader” calls the current `wellFormed`, evaluates a predicate, then files the review manually. It does not execute copied old decoding/control flow. The predicate matches the old tax condition, and `wellFormed` is unchanged in this diff, but account/month checks and terminal short-circuiting are absent.
+ - Mixed-history waivers and records emitting only one code are not exercised through both readers.
+
+ **Scenario:** An equal-amount Business line reported with Personal metadata must fail. The existing missing-metadata tests do not protect that equality requirement. Likewise, manually inserting a review cannot prove that an older reader files it only for nonterminal records.
+
+ **Smallest acceptable fix:** Add wrong-code cases independently for POST, recovery and draft validation; cover losses of both split lines; preserve the old reader’s relevant structures and control flow in a test harness; exercise nonterminal and terminal waivers plus single-emitted-code records.
+
+4. **P3 — Mutation evidence claims test kills for two edits that do not compile.**
+ **Location:** `docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md:38`; affected code at `internal/console/billingcalculation.go:84` and `internal/console/billingtaxsplit.go:96`.
+
+ **Scenario:** As recorded, M3 removes the only use of the `strings` import. M15 removes the only read of `inside`. Both produce compilation failures, and neither log entry names a failing test. This does not establish that assertions killed those mutants.
+
+ **Smallest acceptable fix:** Make those mutations compile, rerun them in the authorized verification phase, and record the actual failing tests. Until then, distinguish invalid mutants from test kills.
+
+**Other checked claims and contradictions**
+
+- **Byte identity and shape:** The production changes preserve existing field order and names; empty additions disappear through `omitempty`; `priceBillingCalculation` clears spans; request-map encoding remains deterministic. I found no valid one-code counterexample by inspection. The new guard intentionally rejects nonwaived empty-line records that passed the old entry predicate, and accepts valid per-line shapes—including empty-line waivers—that the old predicate refused. Codes at both levels, neither level on a nonempty calculation, and malformed prefixes are refused. A waived calculation with lines still passes `taxShapeOK`, contrary to the design’s explicit no-lines waiver branch; `stepFrozen` separately rejects it at `billinginvoice.go:614`.
+
+- **Identity:** For valid immutable calculations, I found no ordinary path acknowledging both old and new kinds for the same axis. Pending operations compare request hashes; split-line POST, recovery and draft checks use the frozen code. Equal amounts do not collapse identity. Pagination in the client is correct by inspection, although the lifecycle fake exercises an abbreviated embedded listing followed by one complete `/lines` response, rather than multiple endpoint pages.
+
+- **Attribution and arithmetic:** Eligibility, `CountedDays`, monthly usage and pricing remain unchanged. Repeated codes aggregate before flooring; an eligible day with no span errors; an unmapped plan errors. Missing and malformed daily data retain the described rules. `apportion` uses `big.Int` multiplication, checked weight sums, floor shares and largest remainders; zero shares disappear and positive amounts with zero weight fail. Successful splitting conserves each axis and therefore the subtotal. No invoice-wide rounding is repeated.
+
+- **Determinism:** The design’s “any map is iterated in sorted order” at `docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md:39` is literally false: several maps are ranged directly. This does not change successful outputs because chronological accumulation and final share ordering are deterministic.
+
+- **M14:** Its equivalence reasoning holds for ordered spans and coverage produced together by `AllowanceForMonth`. It is not equivalent for arbitrary inconsistent helper inputs: whole-month eligibility plus a first span starting at 15:00 makes the original reject midnight attribution and the mutant accept it. Label it equivalent under the production input invariant.
+
+- **Activation:** The “reviewed one-line change” claim at design `:41` is unsupported. `taxCodesForLines` accepts only codes and returns one string; it cannot access statements, recover spans, split lines or persist those spans. Activation needs additional wiring and its tests. This need not be built before the owner decides, but the documentation must state it accurately.
+
+- **Older reader and Task 5:** Nonterminal per-line records fail the old top-level check before provider work and can create or reopen an account-wide review. Terminal records are skipped before decoding. Resolving one review cannot bypass another, retry backoff or the twelve-month lookback. Those conditions are in this design, but not in decision 16d at the cited `27419dcf`, despite disposition `:25`. At merge, `ENABLEMENT.md` must require upgrading or stopping every older invoice worker before activation, prohibit rollback to an older reader afterwards, and explicitly override the generic rollback instruction.
+
+**Before merging:** fix the findings and correct the completion claims, complete Task 5’s launch records, then obtain the required exact-tip gate, CI and reviews. Decision 16 is not required for this inactive mechanics branch.
+
+**Verdict: accept with fixes.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review2.md b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review2.md
new file mode 100644
index 000000000..1e9154a14
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review2.md
@@ -0,0 +1,106 @@
+The design dispositions hold in the production code, with remaining qualifications about test coverage and rollout documentation. I found no P1 or P2 defect at `8715e4cd`. Two P3 verification fixes remain.
+
+This was source-only review: nothing modified, no tests or network commands run. References below are at `8715e4cd` unless another commit is named. Test counterfactuals are assessed by inspection.
+
+**Design dispositions, finding by finding**
+
+| Design finding | Assessment |
+|---|---|
+| Round 1 P1: identity keyed only by meter | Holds. Kinds, requests, POST checks, recovery and draft validation distinguish codes (`internal/console/billinginvoice.go:990`, `:1036`, `:1048`, `:1138`). |
+| Round 1 P1: held months cannot resume automatically | Holds. Nothing automatically resolves reviews. Resolution, other account reviews, backoff and lookback remain separate conditions. |
+| Round 1 P2: older workers create durable account-wide reviews | Holds. The older entry predicate rejects nonterminal per-line records. The new harness files through `r.review` (`billingtaxlifecycle_test.go:248`). Task 5’s operational documentation remains due at merge. |
+| Round 1 P2: eligibility before attribution | Holds. Whole-day eligibility precedes span selection (`billingtaxsplit.go:87`); full coverage becomes a whole-month interval (`:51`). The new glue composes these helpers. |
+| Round 1 P2: attribution choices are policy | Holds. Midnight attribution, gross weighting and last-paid-span attribution remain dormant proposals. |
+| Round 1 P2: precise apportionment | Holds. Chronological accumulation, separate meter floors, checked transfer addition, largest remainders, byte-order ties and omission of zero shares are implemented. |
+| Round 1 P3: zero usage without `Days` | Holds wherever daily data is required. Full one-code months retain their existing exemption. |
+| Round 2 P2: universal daily requirements would regress one-code months | Holds. The refactor preserves existing pricing behavior; the full-month, nonzero-usage, no-`Days` fixture covers it. |
+| Round 2 P2: POST and draft identity checks | Holds in code. Missing and wrong codes are tested. The recovery-specific assertion needs strengthening; finding 2 below. |
+| Round 2 P2: ambiguous transfer arithmetic | Holds. Each meter is floored before transfer integers are added (`billingtaxsplit.go:126`). The fractional test checks weights, not resulting shares. |
+| Round 2 P3: decision and rollout references | Decision refinements exist, and the substantive corrections are present at launch commit `02e3538a`. The rollout edit is explicitly deferred to Task 5. |
+| Round 2 P3: unconditional “next pass” | Corrected in the design and decision 16d at `02e3538a`: other reviews, backoff and twelve-month lookback remain conditions. |
+| Round 3 P2: month-end exemption versus partial-month pricing | Holds. Partial-month pricing requires usable days before either body runs; the month-end body adds no daily-data requirement (`billingtaxsplit_test.go:289`). |
+| Round 3 qualifications: waiver and terminal records | Hold. Waivers require no lines; terminal records are skipped before decoding and tax-shape checks. |
+
+**First code review, finding by finding**
+
+| Finding | Closed? | Would the relevant test pass without the fix? |
+|---|---|---|
+| **Opus P2-1: missing activation glue and review mapping** | Implementation closed; verification partly open. `twoCodeCalculation` exists, remains uncalled, and the error maps to a review. | Glue assertions detect missing spans, incorrect attribution and eager daily-data requirements. Removing the review mapping still passes the current suite’s new tests: M27 remains uncovered. See finding 1. |
+| **Opus P3-1: hand-built fixture requests** | Closed. The second table uses engine functions and literal expected hashes. | These compatibility tests should pass against the previous production behavior; this was a test-only deficiency. They now detect drift in the engine functions that the original table bypassed. |
+| **Opus P3-2: predicate standing in for an older reader** | Substantively closed. The harness includes terminal skip, decoding, account/month checks, money validation and the old top-level predicate, then calls `r.review`. | The new tests should pass with the previously correct production behavior. Removing rejection from the harness fails the expected-review assertions. `wellFormed` remains shared, but its implementation is unchanged from the base. |
+| **Opus P3-3: waived shape** | Closed as an implementation issue; intentional compatibility differences remain. | Removing the no-lines waiver requirement fails the new shape-table case at `billingtaxshape_test.go:29`. Engine tests alone would not isolate it because `stepFrozen` also rejects waived records with lines. |
+| **Opus P3-4: stale decision text** | Substantive corrections verified at `02e3538a:implementation/carloku-paid-launch/DECISIONS-FOR-PAUL.md:54`. | No behavioral test applies. Minor stale wording remains, listed below. |
+| **Opus P3-5: mutant evidence and negative amount** | Negative-amount coverage closed. Reported mutant reruns cannot be independently verified from this commit. | Removing the negative-amount check fails the case at `billingtaxsplit_test.go:150`. M3/M15’s rewritten mutations and execution results are absent from the reviewed tree. |
+| **Opus P3-6: lookup ordering before mixed-code detection** | Correctly dispositioned as unreachable under production configuration, not changed. | No regression test establishes otherwise. `validBillingTaxCodes` requires all three paid plans (`billingservice.go:136`); pricing rejects unknown plans earlier. |
+| **Astra 1: resumed finalization bypass** | Closed. | **No.** Without `stepFinalizing`’s validation at `billinginvoice.go:1214`, the four cases at `billingtaxlifecycle_test.go:175` finalize the corrupted draft and fail. |
+| **Astra 2: wrong compatibility fixtures** | Closed for the requested frozen shape and engine hashes. | The new tests pass against the previous correct production behavior, intentionally. They now exercise `lineParams`, `lineKind`, `draftParams` and `requestHash`. |
+| **Astra 3: lifecycle and reader coverage** | Mostly closed. Wrong-code cases, first-line expired recovery, the older-reader harness, waivers and one-emitted-code records are present. Two narrow gaps remain in finding 2. | The additional coverage largely passes against the first-review production code. Wrong POST and draft equality assertions detect their respective regressions; the wrong-code recovery case does **not** independently establish recovery rejection. |
+| **Astra 4: invalid mutants reported as kills** | Reported corrected, but execution evidence is unavailable in `8715e4cd`. | Cannot assess the actual M3/M15 runs without their mutation definitions and logs. Compilation failure would still not count as an assertion kill. |
+
+**Remaining findings, most severe first**
+
+1. **P3 — The apportionment error-to-review mapping is testable now, but remains untested.**
+
+ **Location:** `internal/console/billinginvoice.go:319`; claimed limitation at `docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md:41` and `DISPOSITIONS-per-line-tax-codes.md:55`.
+
+ **Concrete scenario:** Delete the `errBillingTaxApportion` mapping. The current new tests remain green because none returns that error through `invoiceBillingMonth`. Once activation reaches this error, the engine returns it without filing the promised durable review.
+
+ The production path is dormant, but testing the mapping does not require activation or decision 16. The existing `billingInvoiceState.prepare` seam (`billinginvoice.go:85`, `:124`) can return the error; `freeze` propagates it at `:459`.
+
+ **Smallest acceptable fix:** Add a fixture test whose preparer returns `errBillingTaxApportion`. Assert `errBillingInvoiceHeld`, the exact `billing_invoice_tax_code_mismatch:<month>` review, no frozen record and no provider mutation. Correct the claim that no test can reach the mapping today. Keep the real attribution-to-review integration test as an activation obligation.
+
+2. **P3 — The wrong-code recovery test can pass without recovery rejecting the wrong code; first-line in-window replay is still missing.**
+
+ **Location:** `internal/console/billingtaxlifecycle_test.go:143`, `:157`; replay cases at `:104`.
+
+ **Concrete scenario:** Remove the recovery tax-code predicate at `billinginvoice.go:1048`. In the wrong-code recovery subtest, only the lost first item exists. Recovery accepts that item despite its wrong metadata, creates the second item, and later draft validation rejects the listing. The test still passes because it only requires an error, an unfinished month and no finalize/payment.
+
+ Separately, the added first-line loss case runs only after 25 hours. The promised in-window replay of **each** split line remains incomplete.
+
+ **Smallest acceptable fix:** For wrong-code recovery, assert the exact unrecovered review, that the first operation remains unacknowledged, and that no second item is created. Add the first-line loss at two hours, asserting replay arrival count, the unchanged key and one provider mutation.
+
+**Checked claims A–H**
+
+- **A — Byte identity.** I found no valid production one-code input whose calculation JSON, digest, request JSON/hash or operation kind changes. Existing field names and order remain; the three new fields use `omitempty`; `priceBillingCalculation` clears spans at `billingcalculation.go:174`; metadata is omitted from one-code provider forms; JSON map encoding remains deterministic. The daily-validation extraction and scale helper preserve the previous arithmetic.
+
+ The original table contains seven pricing outputs. The second contains six one-code frozen-shape fixtures, correctly excluding `legacy_two_codes`. Their expectations are literals; history shows the original table introduced in `5429b690` and the second in `8715e4cd`. The second table pins engine hashes, including the legacy currency adapter. It reconstructs freeze’s code-setting step rather than running evidence preparation and database freeze; its `StatementObject` is empty. That is useful compatibility coverage, but not a complete production-freeze fixture.
+
+- **B — Shape and guard differences.** The new guard intentionally accepts per-line shapes and empty-code, empty-line waivers that the old top-level predicate rejected. It rejects nonwaived empty-line records, waived records containing lines, and top-level-code records also carrying line codes. The latter fields would have been ignored by an older decoder. These are not identical acceptance sets.
+
+ `validTaxCode` preserves the old syntactic rule: `txcd_` plus at least one byte. It does not certify that a provider code exists. `lineParams` independently refuses both levels, neither level and malformed codes; `lineKind` preserves old names for untagged lines.
+
+- **C — Identity through the engine.** Pending operations compare request hashes before replay. Split POST checks, recovery matching and draft validation use the frozen code; equal amounts do not collapse identity. Draft validation binds listed items to acknowledged operation IDs as well as metadata. The resumed-finalize fix closes the previously identified draft bypass.
+
+ A mismatching POST response with an object ID is deliberately **acknowledged as an existing object and then held** (`billinginvoice.go:834`); it is not accepted as permission to finalize. Recovery of an already-finalized outcome remains distinct from validating a still-draft invoice.
+
+ I found no ordinary engine path producing both old-kind and new-kind operations for the same axis of one immutable calculation. Pagination follows `has_more`; the lifecycle fake exercises an abbreviated embedded listing followed by a complete `/lines` response, while actual endpoint pagination belongs to the client tests.
+
+- **D — Attribution.** Existing partial-month pricing still determines billed days and `CountedDays`. Attribution repeats that eligibility rule before assigning a day to the half-open span containing midnight. Continuous changes retain the day; lapses exclude partial days. Full coverage receives a whole-month interval despite nil `Covered`.
+
+ Repeated codes aggregate; an eligible day with no containing span returns `errBillingDaysMismatch`; an unmapped plan returns `errBillingUnavailable`. Missing days with zero meters are usable; nonzero usage without days fails where attribution requires them. Malformed daily data retains the existing validation. None of these dormant allocation helpers replaces the month’s billed usage or prices.
+
+- **E — Arithmetic.** Each `(code, meter)` accumulates in date order and is floored once. Transfer integers are then added with an overflow check. `apportion` uses exact integer multiplication/division, rejects negative amounts/weights and overflowing weight totals, orders ties by code bytes, and omits zero shares. Positive amounts with no weight fail. Successful shares conserve each axis’s amount, hence the subtotal. No expanded-line invoice-wide rounding occurs.
+
+ Unsorted map iteration does not affect successful output: chronological floating accumulation and final code ordering are fixed; remaining sums are checked nonnegative integers.
+
+- **F — Policy and hold.** Nothing activates 16 or 16a–16d. `twoCodeCalculation` and both bodies remain uncalled from production freeze. The last-span body implements one proposed answer to 16c; it does not establish that answer as policy. Under valid production configuration, `taxCodesForLines` preserves today’s one-code result and mixed-code hold, including mixed histories that would otherwise waive.
+
+- **G — Older reader and rollout.** The harness’s entry predicate matches the base behavior; money-only `wellFormed` is unchanged. Nonterminal per-line records fail the old top-level check and file `billing_invoice_record_mismatch` before provider work. Terminal records are skipped first. An older worker can reopen the review after resolution; unresolved reviews block authorizing steps account-wide.
+
+ At merge, Task 5 must update `ENABLEMENT.md` to require upgrading or stopping every older invoice worker before activation, prohibit rollback to an older reader afterwards, and explicitly override the generic older-binary rollback sentence at `ENABLEMENT.md:206`. It should describe resolving accidental older-reader reviews and the separate conditions for releasing previously mixed-code months. `OUTSTANDING.md` and `READINESS.md` also need their planned updates.
+
+- **H — Tests and mutants.** M14’s stated equivalence is valid **under ordered spans and coverage produced together by `AllowanceForMonth`**: eligibility guarantees a containing span, and earlier spans have already ended. It is not equivalent for inconsistent helper inputs.
+
+ M27 remains a surviving, testable omission. The negative-amount omission is now caught. Missing/wrong POST and draft metadata checks have meaningful assertions; the recovery-specific limitation is finding 2. The cited `logs/taxcodes-mutants-round2.txt` does not exist in `8715e4cd`, so “27 killed, two surviving” remains reported evidence rather than independently verified evidence in this review.
+
+**Claims still overstated or contradicted**
+
+- Design `:41` says no test can reach the apportionment review today. The existing prepare seam can.
+- Design `:57` promises in-window replay and expired recovery for each split line. The first line has only expired recovery coverage.
+- Design `:58` and plan `:36` describe a copied old `wellFormed`. The harness calls the shared current function, which is presently unchanged.
+- Design `:56` and plan `:31` promise exact fractional-transfer **shares**. The test asserts weights; dispositions `:51` accurately acknowledges this.
+- At launch commit `02e3538a`, decision context `:52` still says “Nothing is built for it,” while `:56` says the mechanics are built. Its revision reference also remains 4 although the reviewed design identifies itself as revision 5.
+
+**Before merge:** require the two small verification fixes, the corresponding claim corrections, Task 5’s launch records, and the stipulated reviews, exact-tip isolated gate and CI. Decision 16 is not required to merge these inactive mechanics.
+
+**Verdict: accept with fixes.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review3.md b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review3.md
new file mode 100644
index 000000000..47fbbb097
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review3.md
@@ -0,0 +1,193 @@
+The design dispositions hold in the implementation, subject to Task 5’s pending rollout records. I found no P1 or P2. Two P3 verification/documentation fixes remain.
+
+This was source-only review: no changes, tests, or network commands. References are at **6488c198**, except the explicitly identified launch commits. Unqualified Go filenames below are under `internal/console`. Test counterfactuals are assessed by inspection.
+
+**Design dispositions, finding by finding**
+
+| Design finding | Assessment at 6488c198 |
+|---|---|
+| Round 1 P1: meter-only operation identity | Holds. `lineKind`, POST validation, recovery and draft validation distinguish codes (`billinginvoice.go:994`, `:1036`, `:1048`, `:1144`). |
+| Round 1 P1: held months cannot resume automatically | Holds. No automatic resolution was introduced. Operator resolution, other account reviews, backoff and lookback remain separate conditions. |
+| Round 1 P2: older workers create durable account-wide reviews | Holds. Nonterminal per-line records fail the older top-level predicate. Account-wide enforcement remains at `billinginvoicestore.go:165`. Task 5 remains due. |
+| Round 1 P2: eligibility before attribution | Holds. Whole-day eligibility precedes span selection (`billingtaxsplit.go:87`). Full coverage gets a whole-month interval (`:51`). |
+| Round 1 P2: midnight, gross weighting and cancelled-month attribution are policy | Holds. These are dormant proposals; production `freeze` calls none of their bodies. |
+| Round 1 P2: precise apportionment | Holds. Chronological sums, separate meter floors, checked transfer addition and deterministic largest remainders are implemented (`billingtaxsplit.go:119`, `:168`). |
+| Round 1 P3: zero usage without `Days` | Holds. Missing days with zero billed meters are usable. The empty split now avoids requesting weights altogether (`billingtaxsplit.go:346`). |
+| Round 2 P2: universal daily requirement would regress one-code months | Holds. Existing pricing requirements are unchanged (`billingcalculation.go:183`); the full-month, nonzero-usage, no-days fixture remains literal. |
+| Round 2 P2: POST and draft identity checks | Holds. Both require the frozen line’s metadata code. Missing and wrong codes have meaningful negative tests. |
+| Round 2 P2: transfer arithmetic ambiguity | Holds. Each transfer meter is floored before its integer is added (`billingtaxsplit.go:126`). The fractional test checks weights, not shares. |
+| Round 2 P3: decision and rollout references | Decision refinements exist at the supplied launch commits. The operational rollout edit is still Task 5, not completed at this tip. |
+| Round 2 P3: unconditional “next pass” | Corrected. Decision 16d at `68b16c52:implementation/carloku-paid-launch/DECISIONS-FOR-PAUL.md:60` states other reviews, backoff and the twelve-month lookback. |
+| Round 3 P2: month-end exemption versus partial-month pricing | Holds. Partial-month pricing requires daily data before either body runs (`billingtaxsplit_test.go:301`). The month-end body adds no requirement. |
+| Round 3 qualifications: waivers and terminal records | Hold. Waivers require no lines (`billingcalculation.go:104`); terminal records are skipped before decoding and guards (`billinginvoice.go:332`). |
+
+**Second review, finding by finding**
+
+| Finding | Closed? | Would the relevant test pass without the fix? |
+|---|---|---|
+| Opus P3-1: duplicate invoice read; every-month tightening unstated | Code fix closed: `stepFinalizing` passes its invoice to `validateDraftLinesOf` (`billinginvoice.go:1223`). Design states the scope; ENABLEMENT remains pending. | **Yes, if the duplicate read is restored.** The existing corruption tests do not exercise the intervening finalization. They fail if validation itself is removed. See P3-1 below. |
+| Opus P3-2: weights requested for an empty split | Closed (`billingtaxsplit.go:346`). | **No.** The house month with usage and no days at `billingtaxsplit_test.go:348` would return `errBillingDays`. |
+| Opus P3-3: body signature described as supporting any answer | Closed substantively. Design and comments describe the two proposals and the additional work alternative answers require (`billingtaxsplit.go:280`). | Not a behavioral fix. Tests do not establish wording accuracy. |
+| Opus P3-4: one-line record rewritten with a stale digest | Closed. The final one-line calculation is marshalled and passed to `freezeBillingMonth` (`billingtaxlifecycle_test.go:395`). | Restoring the old SQL construction could still pass the lifecycle assertions: they do not independently recompute the digest. Closure rests on using the real freeze function, which computes it. |
+| Opus P3-5: top-level-code and transfer-overflow guards untested | Closed (`billingtaxsplit_test.go:337`, `:343`). | **No.** Removing either guard fails its new assertion. |
+| Astra 1: apportionment review mapping untested | Closed (`billingtaxlifecycle_test.go:426`). | **No.** Removing the mapping returns the apportionment error without `errBillingInvoiceHeld` or the required review. |
+| Astra 2: weak wrong-code recovery assertion; missing first-line replay | Closed (`billingtaxlifecycle_test.go:112`, `:175`). | Removing recovery’s code predicate now fails the unacknowledged-operation, no-sibling and exact-review assertions. The added first-line replay case passes the previously correct implementation, intentionally; it now detects failure to replay or a second mutation. |
+
+**Remaining findings, most severe first**
+
+1. **P3 — The duplicate-read regression has no distinguishing test.**
+
+ **Location:** `billingtaxlifecycle_test.go:197`; fix at `billinginvoice.go:1223`; disposition at `docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md:59`.
+
+ **Scenario:** Restore `r.validateDraftLines(ctx)` at the resumed-finalize call. All four cited cases still read a persistently corrupted draft and produce the expected lines-mismatch review. None makes a previously sent finalize land between invoice reads, so none detects the false `billing_invoice_finalized_externally` review identified by Opus.
+
+ **Smallest acceptable fix:** Add a controlled provider case that returns a valid draft on the resumed read and makes the already-sent finalize complete immediately afterward. Assert successful recovery without an externally-finalized review. Restoring the second validation read must fail that test. The production fix itself is correct.
+
+2. **P3 — The plan still contains claims the second review explicitly required correcting.**
+
+ **Location:** `docs/superpowers/plans/2026-10-03-per-line-tax-codes-plan.md:31`, `:36`, `:41`; related surviving wording at `docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md:72`.
+
+ **Scenario:** A maintainer following Task 5 still sees activation described as switching `taxCodesForLines`, despite that function returning only a string or error. The same plan promises exact fractional-transfer shares and a copied old `wellFormed`, whereas the test checks weights and the harness calls the shared unchanged function.
+
+ **Smallest acceptable fix:** Align the plan with revision 6: activation changes `freeze` to invoke the decided body through the glue; the fractional test asserts weights; the harness copies the entry checks and shares unchanged `wellFormed`. Include the every-month resumed-finalize tightening explicitly in Task 5. These corrections can accompany the launch records.
+
+**A. Byte identity**
+
+I found no valid production one-code input whose calculation JSON, digest, request JSON/hash or operation kind changes.
+
+- `OverageLine.TaxCode` and `MonthAllowance.Spans` are omitted when empty (`internal/plans/lines.go:30`, `internal/plans/month.go:87`). Existing field names and order remain.
+- `priceBillingCalculation` clears spans before either version’s return (`billingcalculation.go:174`). The only additional production caller of `AllowanceForMonth` is the dormant glue.
+- `InvoiceItemParams.TaxCodeMetadata` is omitted when empty, including from legacy requests whose currency is stripped before hashing (`internal/billingstripe/invoices.go:274`; `billinginvoice.go:895`). One-code forms gain no metadata field.
+- Daily validation and meter scaling were extracted without changing their checks or arithmetic. JSON map keys remain deterministically encoded.
+
+The fixture expectations are literal. There are **seven pricing fixtures and six frozen-shape fixtures**, not seven frozen one-code records (`billingtaxshape_fixtures_test.go:31`, `:145`). `legacy_two_codes` correctly has no frozen fixture; production holds it. The one-plan price-book fixture also supplies the nonzero-usage/no-days case.
+
+The second table exercises `lineParams`, `lineKind`, `draftParams` and `requestHash`. It reconstructs freeze’s code-setting step with empty statement evidence rather than executing complete evidence preparation and database freezing. That limits its coverage but does not invalidate its compatibility assertions. Source inspection cannot independently prove the claimed historical generator execution.
+
+**B. Tax shape and entry guard**
+
+The acceptance sets deliberately differ (`billingcalculation.go:94`; `billinginvoice.go:341`):
+
+| Record | Old guard | New guard |
+|---|---|---|
+| Valid top-level code, ordinary untagged lines | Accepts | Accepts |
+| Empty top-level code, every line tagged validly | Refuses | Accepts |
+| Waived, no lines, empty top-level code | Refuses | Accepts |
+| Nonwaived, no lines, valid top-level code | Accepts entry | Refuses entry |
+| Waived with lines, valid top-level code | Accepts entry | Refuses entry |
+| Valid top-level code plus line codes | Older decoder ignores line codes | Refuses |
+
+For the malformed empty-line/waived cases, `stepFrozen` already refused them with record-mismatch. Earlier rejection does not change valid produced records.
+
+`validTaxCode` preserves the old syntactic rule: `txcd_` plus at least one byte. It does not verify provider existence. Both-level, neither-level and malformed line requests are refused by `lineParams` (`billinginvoice.go:970`). Its checks are local to the supplied line; it does not call `taxShapeOK` for the entire calculation. `lineKind` retains old names for untagged lines and adds the code for tagged ones.
+
+**C. Identity through the engine**
+
+The relevant paths are consistent:
+
+- Pending replay compares request hashes before sending (`billinginvoice.go:753`).
+- POST checking requires the frozen line’s code (`:1036`).
+- Expired recovery matches meter, digest and code (`:1048`).
+- Draft validation binds provider item IDs to acknowledged operations of each line’s kind, then checks metadata, amounts, currency and subtotal (`:1142`).
+- `has_more` causes complete line listing; client pagination remains implemented in `internal/billingstripe/invoices.go:152`.
+
+Equal amounts do not collapse identity. I found no normal engine path acknowledging both an old-kind and a new-kind operation for one axis of the same frozen calculation. The stored shape determines all its kinds; older workers reject the per-line shape.
+
+One qualification matters: a mismatching POST response containing an object ID is **acknowledged as an existing object, then held** (`billinginvoice.go:834`). It is not permission to finalize.
+
+A tagged line lacking metadata is rejected on POST, recovery or draft validation, including resumed draft finalization. Already-finalized recovery does not revalidate historical line metadata. Nor does validation provide an atomic guarantee against provider-side changes after the read. Those are limits of the claim “checked on every acceptance path,” not a newly identified bypass in this delta.
+
+**D. Attribution**
+
+Eligibility remains the existing whole-paid-day rule (`billingcalculation.go:322`). Attribution applies that rule before selecting the half-open span containing midnight (`billingtaxsplit.go:87`).
+
+Concrete cases behave as intended:
+
+- A continuous 15:00 plan change retains the day and attributes it to the earlier plan under proposal 16a.
+- A 15:00 lapse excludes that partially covered day.
+- Full coverage, whose `Covered` is nil, receives the entire month as its eligible interval.
+- Repeated spans sharing a code accumulate together.
+- An eligible day with no containing span returns `errBillingDaysMismatch`; an unmapped selected plan returns `errBillingUnavailable`.
+- Missing days with zero billed meters yield no weights. Missing days with usage fail when weights are needed.
+- Partial months still require usable daily data before either proposed body. Whole-month month-end attribution adds no such requirement.
+- Waived empty splits now request no weights.
+
+Malformed daily data retains the previous date, finite/nonnegative-value and sum checks. These helpers allocate existing charges; they do not replace billed usage, `CountedDays`, allowances or prices.
+
+**E. Arithmetic**
+
+The implementation follows the specified order (`billingtaxsplit.go:119`, `:168`, `:239`):
+
+1. Accumulate each `(code, meter)` in date order.
+2. Floor each accumulated meter once.
+3. Add the two transfer integers per code, with overflow checking.
+4. Divide each existing axis amount using exact integer products, floors and largest remainders.
+5. Break ties by code bytes and omit zero shares.
+
+Negative inputs and overflowing weight totals are refused. A positive amount with zero total weight returns `errBillingTaxApportion`. Successful shares conserve each axis amount, hence the subtotal. Invoice-wide rounding is not rerun over expanded lines.
+
+Unsorted map iteration does not affect successful output: floating accumulation is chronological, final code ordering is explicit, and intervening sums are checked nonnegative integers.
+
+**F. Policy and the hold**
+
+Nothing chooses decision 16 or 16a–16d. `twoCodeCalculation` and both bodies have no production caller. `freeze` still returns the mixed-code error before persisting a calculation (`billinginvoice.go:487`).
+
+Under valid production configuration, one-code and mixed-code behavior is unchanged, including mixed histories that would otherwise waive. The previously noted lookup-order difference for an unconfigured later plan remains unreachable with the required configuration (`billingservice.go:136`).
+
+The last-span body implements one proposed answer to 16c; its comments now say so. Alternative weighting or cancellation-hold policy still requires implementation and review at activation.
+
+**G. Older reader and rollout**
+
+The harness copies the base entry predicate’s behavior (`billingtaxlifecycle_test.go:270`); `wellFormed` is shared and unchanged from the base.
+
+An older worker:
+
+- Refuses a **nonterminal** per-line record, including an empty-code waiver, and files `billing_invoice_record_mismatch`.
+- Leaves the frozen bytes unchanged and performs no invoice mutation.
+- Skips terminal `complete`, `waived` and `payment_failed` records before decoding.
+- Can reopen its review after resolution while it continues running.
+
+The review blocks authorizing work account-wide. Resolving it does not release the account while another review remains open.
+
+Task 5 must land in the same merge and state:
+
+- Before any build can **freeze per-line calculations**, upgrade every invoice worker or stop older ones.
+- After activation, prohibit rollback to an older reader and override `ENABLEMENT.md:206`’s generic older-binary statement.
+- Stop offending old workers before resolving their accidental reviews.
+- Preserve 16d’s other-review, backoff and lookback conditions.
+- From this merge, resumed-finalize draft validation applies to **every month**, including one-code months.
+- Mark OUTSTANDING item 17 as mechanics built but inactive, with the mixed-code hold and decision still outstanding; record the reviewed commit and actual gate/CI results in READINESS.
+
+**H. Tests and mutants**
+
+The new guard, empty-split, mapping and recovery assertions distinguish their intended regressions. Compatibility tests intentionally pass against the previous correct behavior; test-only improvements do not need a production failure to be useful.
+
+M14’s stated equivalence holds **under the production invariant**: ordered, disjoint spans and coverage come from the same `AllowanceForMonth`. After whole-day eligibility, the first overlapping span necessarily contains midnight. It is not equivalent for inconsistent helper inputs.
+
+Removing the M27 review mapping now fails the preparer-seam test by inspection. However, `logs/taxcodes-mutants-round3.txt` is absent from 6488c198. I cannot independently confirm the reported 32 executions or classify unseen mutation definitions.
+
+One concrete additional mutant would survive the cited regression tests: restoring the duplicate validation read, as described in P3-1. The one-code form of the resumed-finalize tightening also lacks a dedicated corruption case; its implementation is shared and unconditional. Real attribution-to-review integration through production `freeze` remains an activation obligation.
+
+**I. Delta and merge readiness**
+
+The delta closes the identified implementation problems and strengthens the two Astra verification gaps. It does not overturn either report’s conclusions about byte identity, prices, the hold, owner policy or older-reader refusal. It does overturn Opus’s claim that M27 cannot be tested before activation.
+
+I would require the distinguishing race test and the remaining wording corrections before landing. Decision 16 is **not** required. After those fixes, merge into `feat/carloku-paid-launch-20260916` as a merge commit with Task 5’s records included. If the test changes the branch tip, run the isolated gate and CI on that resulting tip; a green run on 6488c198 alone would not cover it.
+
+**Claims still overstated or contradicted**
+
+| Prior claim | Status |
+|---|---|
+| Opus claim 1: any answer fits one body | Corrected in design revision 6 and `billingtaxsplit.go:280`. |
+| Opus claim 2: activation switches `taxCodesForLines` | Corrected in launch decision 16 at `68b16c52:56`; still present in plan `:41`, disposition `:72` and `billinginvoice.go:317`. |
+| Opus claim 3: `wellFormed` gains the tax shape | Corrected in the design’s main description. Its test list still names `wellFormed` at design `:59`; the actual tested predicate is `taxShapeOK`. |
+| Opus claim 4: weights requested only when dividing | Now true: the empty split returns first. |
+| Astra: apportionment mapping cannot be tested today | Corrected, with a distinguishing test. |
+| Astra: each split line has in-window replay | Now true. |
+| Astra: old `wellFormed` copied | Corrected in design `:58`; still claimed in plan `:36`. |
+| Astra: fractional-transfer test asserts exact shares | Corrected to weights in design `:56`; still claimed in plan `:31`. |
+| Astra: decision says nothing is built; stale revision | “Nothing is built” is corrected at launch commit `68b16c52`. Its revision reference is 5, while this design is now revision 6. |
+| Disposition `:72`: nothing changes for a one-code month | True for bytes and pricing, too broad for behavior: resumed-finalize validation now also holds corrupted one-code drafts. |
+| Design `:46`: ENABLEMENT states the tightening | Still a Task 5 obligation; it is absent from this commit’s ENABLEMENT. |
+
+**Verdict: accept with fixes.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md b/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md
new file mode 100644
index 000000000..9667dffae
--- /dev/null
+++ b/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md
@@ -0,0 +1,82 @@
+# Dispositions: per-line tax codes
+
+Branch `design-per-line-tax-codes`, off the launch branch at `29ea6676`. Design `2026-10-03-per-line-tax-codes-design.md` (revision 4), plan `../plans/2026-10-03-per-line-tax-codes-plan.md`. Reports are in the run directory's logs (`astra-taxcodes-design-review{1,2,3}.md`) and, from the first code review on, in this directory.
+
+## Design review one (revision 1; Astra: reject, two P1, four P2, one P3)
+
+| Finding | Disposition |
+| --- | --- |
+| P1: the invoice engine keys a line's operation, recovery and draft validation by meter alone; two lines of one meter cannot pass. | A two-code line's kind is `line-<meter>-<code>`, its request carries `metadata[tax_code]`, and the POST answer, the recovery match and the draft validation require it (revision 2; built in Task 2, tested in Task 4). |
+| P1: a month already held cannot resume by itself. | No automatic resolution; the operator door resolves the review, with the pass's conditions stated (16d). |
+| P2: an older worker files a durable account-wide review. | The roll rule: older invoice workers stopped before activation, no rollback past it; the older reader's behaviour tested (Task 4). |
+| P2: the whole-paid-day eligibility rule must come before attribution. | Eligibility first, as today; attribution against unmerged half-open spans; a fully covered month takes the whole month as its interval (Task 3). |
+| P2: midnight attribution, gross-usage weighting and the no-paid-plan month end are policy. | 16a to 16d in `DECISIONS-FOR-PAUL.md`; the design chooses none; the bodies exist behind the hold. |
+| P2: the apportionment was imprecise (five meters to four axes, rounding, ties, aggregation, zero weights). | Stated exactly and built: per (code, meter) sums in date order, one floor each, transfer added per code; floor and largest remainder within the axis, byte-order ties, zero shares omitted, an amount with no weight an error (Task 3). |
+| P3: a zero-usage statement has no `Days`. | Kept as today: usable when every billed meter is zero, a hold otherwise. |
+
+## Design review two (revision 2; Astra: accept with fixes, three P2, two P3)
+
+| Finding | Disposition |
+| --- | --- |
+| P2: a universal daily-data requirement would regress one-code months. | `Days` are read where they are today and, new, only for a two-code whole month under the split answer; a fixture of a fully covered month with usage and no `Days` is pinned (Task 0). |
+| P2: the line identity was not checked on the POST answer and the draft. | Both check `metadata[tax_code]` (Task 2); corruption on each path tested (Task 4); mutants M8 and M10 die. |
+| P2: two readings of the transfer arithmetic. | Floor each meter, then add, per code (Task 3); the fractional-boundary test; mutant M16 dies. |
+| P3: 16a to 16d and the roll rule not yet written. | 16a to 16d are on the launch branch (`27419dcf`); the roll rule is Task 5 at the merge. |
+| P3: "the next pass" promised without its conditions. | Stated in 16d: no other open review, the backoff, the twelve-month lookback. |
+
+## Design review three (revision 3, confirming; Astra: accept with fixes, one P2)
+
+| Finding | Disposition |
+| --- | --- |
+| P2: the month-end answer's daily-data exemption contradicted partial-month pricing. | Revision 4: the month-end answer adds no requirement of its own; partly covered months keep today's under either answer. |
+| The waiver sentence and the older worker's nonterminal records. | Qualified in revision 4. |
+
+Revision 4's corrections were not sent for a fourth design round; the first code review reads the design with the code.
+
+## Code review one (`61313bec`; Opus: accept with fixes, one P2, six P3; Astra: accept with fixes, three P2, one P3)
+
+Reports: `OPUS-REVIEW-per-line-tax-codes-review1.md`, `CODEX-FINDINGS-per-line-tax-codes-review1.md`. Both found no change to a one-code month's bytes or pricing and no P1. The gate on `61313bec` passed alone and CI passed.
+
+| Finding | Disposition | Where | Test |
+| --- | --- | --- | --- |
+| Astra 1 (P2): a finalize journaled and then lost was replayed or renewed without validating the draft again, so a line whose metadata code went missing or wrong meanwhile could be finalized. | The resumed step reads the invoice and, while it is a draft, validates its lines first. | `billinginvoice.go` `stepFinalizing` | `TestAResumedFinalizeValidatesTheDraftFirst` (missing and wrong, inside and past the window); mutant M24 |
+| Astra 2 (P2), Opus P3-1: Task 0 pinned the pricing output (before `freeze` sets the code) and hand-built requests hashed with `billingRequestHash`, not the engine's `requestHash`, which strips a legacy calculation's currency. | Kept and relabelled as the pricing fixtures; a second table, recorded on the base commit `29ea6676` in a detached worktree, pins the frozen record (after the code is set), its digest, the draft request's hash and each line's hash through `lineParams`, `lineKind`, `draftParams` and `requestHash`. | `billingtaxshape_fixtures_test.go` | `TestTodaysFrozenRecordsAndRequestsArePinned` |
+| Astra 3 (P2), Opus P3-2: the lifecycle lost only the second line, corrupted only by removal, and the older reader was a predicate with a hand-filed review; waived and one-emitted-code records were not driven through both readers. | The first line's loss (no sibling yet) added; a wrong code on the POST answer, the listing a recovery reads and the draft, with equal amounts; the older reader is a harness copying the entry of `29ea6676` (terminal skip, decode, the account, month, money and top-level checks, the review filed by the run's own method) and runs again to reopen its review; a waived mixed history and a one-emitted-code record through both readers. | `billingtaxlifecycle_test.go` | `TestATwoCodeLinesLostAnswerIsRecoveredByItsCode`, `TestAWrongMetadataCodeHoldsTheMonth`, `TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount`, `TestWaivedAndOneEmittedCodeRecordsThroughBothReaders` |
+| Opus P2-1: activation was called a one-line change while nothing turned a statement and history into a two-code calculation, and `errBillingTaxApportion` fell through to a quiet retry. | The glue exists, uncalled (`twoCodeCalculation`, the two bodies in its shape, weights asked for only by the body that divides); the error is the `billing_invoice_tax_code_mismatch` review; the design, decision 16 and this file say activation is a reviewed change of its own. | `billingtaxsplit.go`, `billinginvoice.go` | `TestTheActivationGlueProducesATwoCodeMonth`; mutants M25 to M27 |
+| Astra 4 (P3), Opus P3-5: M3 and M15 did not compile; no negative-amount case. | Both rewritten to compile and rerun with their killing tests; the negative amount tested; M14 relabelled equivalent under the production invariant (spans and coverage from one `AllowanceForMonth`). | `taxcodes-mutants-round2.txt` | `TestAnAxisIsApportionedWithoutRoundingBeyondIt`; mutant M28 |
+| Opus P3-3: the new guard accepts a waived record with no lines and an empty code, which the old refused, and `taxShapeOK` accepted a waived calculation with lines. | Stated; `taxShapeOK` now requires a waived calculation to have no lines and a non-waived one to have some. | `billingcalculation.go` | the shape table; mutant M29 |
+| Opus P3-4: decision 16 on the launch branch said the box side needs a small change, 16d promised the next pass without its conditions, and cited revision 2. | Corrected on the launch branch (`02e3538a`). | `DECISIONS-FOR-PAUL.md` | |
+| Opus P3-6: `freeze` looks up every plan's code before checking for a mix, so an unconfigured plan after two codes is `errBillingUnavailable` rather than the mixed review. | Noted; unreachable, since the configuration requires exactly the three paid plans. | | |
+
+Stated and not changed: the fractional-boundary test asserts the weights, not shares; the lifecycle fake's paged listing is an abbreviated embedded page then one complete listing, the client's pagination being tested on its own.
+
+## Code review two (`8715e4cd`; Opus: accept with fixes, five P3; Astra: accept with fixes, two P3)
+
+Reports: `OPUS-REVIEW-per-line-tax-codes-review2.md`, `CODEX-FINDINGS-per-line-tax-codes-review2.md`. No P1 or P2; every first-review finding closed. The gate on `8715e4cd` passed alone and CI passed.
+
+| Finding | Disposition | Where | Test |
+| --- | --- | --- | --- |
+| Opus P3-1: the resumed finalize read the invoice twice, so a finalize landing between the reads would file `billing_invoice_finalized_externally` for a correct one; and the tightening applies to every month, unstated. | `validateDraftLinesOf` takes the invoice already read; the design and `ENABLEMENT.md` (Task 5) state the tightening for every month. | `billinginvoice.go` | `TestAResumedFinalizeValidatesTheDraftFirst` |
+| Opus P3-2: the split body asked for weights with nothing to divide (a waived two-code month without `Days` would retry forever after activation). | No lines, no weights asked. | `billingtaxsplit.go` | `TestTheGluesGuards` (a house month without `Days` waived under the split) |
+| Opus P3-3: "any answer is one body of one function" overstated; `bodyLastSpansCode` quietly took 16c's first option. | Reworded in the design and the comments: the shape is the two proposals'; the glue holds the spans and the month for what another answer would need; a 16c hold is written when 16c is. | design, `billingtaxsplit.go` | |
+| Opus P3-4: the one-emitted-code test rewrote the record by SQL and left the digest stale. | The one-line record is frozen through `freezeBillingMonth`. | `billingtaxlifecycle_test.go` | `TestWaivedAndOneEmittedCodeRecordsThroughBothReaders` |
+| Opus P3-5: two guards untested (a one-code calculation handed to the glue; the transfer overflow). | Both tested. | | `TestTheGluesGuards` |
+| Astra 1: the apportionment review's mapping was testable through the preparer seam. | Tested there; the design no longer says no test can reach it; the attribution that produces the error through `freeze` remains the activation change's test. | | `TestAnUnattributedChargeHoldsTheMonthForAPerson`; mutant M27 now dies |
+| Astra 2: the wrong-code recovery test accepted any error; the first line had no in-window replay case. | The recovery case asserts the `billing_invoice_unrecovered` review alone, the first operation unacknowledged and no sibling created; the first line's loss inside the window asserts one more arrival under its key. | | `TestAWrongMetadataCodeHoldsTheMonth`, `TestATwoCodeLinesLostAnswerIsRecoveredByItsCode` |
+| Both: claims (`wellFormed` gains the shape; copied old `wellFormed`; exact shares; "the switch in `taxCodesForLines`"; decision 16's "nothing is built", revision 4). | Reworded in the design, this file, and decision 16 on the launch branch (`68b16c52`). | | |
+
+## Code review three (`6488c198`, confirming; Opus: accept with fixes, three P3; Astra: accept with fixes, two P3)
+
+Reports: `OPUS-REVIEW-per-line-tax-codes-review3.md`, `CODEX-FINDINGS-per-line-tax-codes-review3.md`. No P1 or P2; every second-review finding closed; both name the branch ready to merge once these are made, and neither requires another round. The gate on `6488c198` passed alone and CI passed.
+
+| Finding | Disposition | Where | Test |
+| --- | --- | --- | --- |
+| Opus P3-1, Astra 1: the single-read fix had no test that fails without it. | A fake knob finalizes the invoice right after a resumed run reads it; the run completes with no `billing_invoice_finalized_externally` review, inside and past the window; the double-read revert (mutant M33) dies on it. | `billingtaxlifecycle_test.go`, the fake | `TestAResumedFinalizeReadsTheInvoiceOnce` |
+| Opus P3-2 (required at the merge): the roll rule was keyed to `taxCodesForLines`, which can never express a two-code result. | Keyed to `freeze`: before any build whose `freeze` can write a two-code calculation runs anywhere; in the design, the plan, this file, the comment and the merge's `ENABLEMENT.md` text, with Opus's four points and Astra's six (older workers stopped before their accidental reviews are resolved; no rollback below the merge after activation; section 7's note overridden; 16d's conditions; the every-month tightening; OUTSTANDING 17 as built but inactive). | design, plan, this file, `billinginvoice.go`, Task 5 | |
+| Opus P3-3, Astra 2: wording the code contradicted (the plan's exact shares, copied old `wellFormed`, the switch in `taxCodesForLines`; the design's test list naming `wellFormed`; this file's "nothing changes for a one-code month"; decision 16's revision). | Each corrected; decision 16 cites revision 6 (`1bda76a7`). | | |
+
+## The build
+
+Tasks 0 to 4 and the mutants of Task 6 are committed; Task 5 (the records on the launch branch) comes at the merge. `wellFormed` kept its money shape and the tax shape is `taxShapeOK`, asked beside it at the entry guard, as the second review's note on "`wellFormed` alone" asked; the design's "gains the tax shape" is met by the guard, not by one function. Mutants: twenty-three in the first batch, twenty-nine in the second and thirty-three in the third, in git-less tree copies, each with its killing tests logged (`taxcodes-mutants-round{1,2,3}.txt`); all die but M14, equivalent under the production invariant (spans and coverage from one `AllowanceForMonth`; not for arbitrary inconsistent helper inputs); M27 survived the second batch and dies in the third (`taxcodes-mutants-round3.txt`) on the preparer-seam test. Every one-code month's bytes, digest, request hashes and operation kinds are the ones pinned in Task 0 from the unchanged code.
+
+What a reader of the invoice should know: a two-code month does not exist until decision 16 is taken and `freeze` is switched to the glue, after the roll rule; nothing on this branch changes a one-code month's bytes, digest, requests, amounts, the floor or the price book. One behaviour does change for every month from the merge: a resumed finalize validates the draft first, so a one-code draft that changed under a journaled finalize holds (`billing_invoice_lines_mismatch`) rather than being finalized.
diff --git a/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review1.md b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review1.md
new file mode 100644
index 000000000..17fdb7182
--- /dev/null
+++ b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review1.md
@@ -0,0 +1,149 @@
+# Opus production review: `design-per-line-tax-codes` at 61313bec against 29ea6676
+
+## The design dispositions against the code
+
+| Round, finding | Holds? |
+|---|---|
+| R1 P1: a line's identity was keyed by meter alone | **Holds.** `lineKind` (billinginvoice.go:986) makes `line-<meter>-<code>`. `lineParams` (966) sends `metadata[tax_code]`. The POST check (1030), the recovery match (1042) and the draft validation (1142) all require it. Mutants M7–M10 die. |
+| R1 P1: held months cannot resume | **Holds.** Nothing in the code resolves a review automatically. |
+| R1 P2: the older worker's durable review | **Partly holds.** The roll rule is Task 5, still to come. The test stands in a one-line predicate and files the review by hand (P3-2). |
+| R1 P2: eligibility before attribution | **Holds in the parts.** `eligibleIntervals` (billingtaxsplit.go:51) and the eligibility test inside `usageByCode` (77) are right, but nothing composes them (P2-1). |
+| R1 P2: policy stated as mechanics | **Mostly holds.** Nothing chooses 16. "Any answer is one body of one function" is overstated (P2-1). |
+| R1 P2: imprecise apportionment | **Holds.** Floor, largest remainder, byte-order ties, zero shares omitted, zero weight an error, overflow checked, sorted iteration. |
+| R1 P3: the zero-usage statement | **Holds.** |
+| R2 P2: the daily-data requirement | **Holds.** No new `Days` read on any one-code path. The no-`Days` fixture is pinned. |
+| R2 P2: identity on the POST answer and the draft | **Holds.** |
+| R2 P2: transfer arithmetic | **Holds.** Each meter is floored, then the two are added. M16 dies. |
+| R2 P3: 16a–16d written | **Partly holds.** The launch branch's 16 and 16d text is stale (P3-4). |
+| R2 P3: the next pass's conditions | **Holds in the design's 16d only.** DECISIONS 16d still promises the pass with no conditions. |
+| R3 P2 and the two qualifications | **Hold in the text.** No code exists for the month-end answer's data path. |
+
+## Findings, most severe first
+
+No P1.
+
+### P2-1. Activation is not "a one-line change", and the planned end-to-end test is missing
+- **Where:** `internal/console/billingtaxsplit.go:36–275`, `billinginvoice.go:482`; design :18 and :41; plan :31.
+- **What is missing:** nothing turns a statement and history into a two-code calculation. Nothing calls `AllowanceForMonth` again for the spans, or chains `eligibleIntervals` → `usageByCode` → body. Nothing sets `TaxCode=""` or decides whether spans are kept, which the design says they are for a two-code month.
+- **The reviews that don't exist yet:** `errBillingTaxApportion` (:29) is not mapped to a review. Returned from `freeze` today, it would fall through to `return err` at billinginvoice.go:322. That is a silent retry every pass, not the `billing_invoice_tax_code_mismatch` hold the design promises.
+- **Why the claims are false:**
+ - 16a's rule is hard-wired inside `usageByCode` (:101).
+ - 16b's two alternatives (each span's excess, chronological consumption) need per-span allowances that nothing supplies.
+- **Planned tests that don't exist:** the plan's "end-to-end two-code calculation through a test seam, with its JSON shape, digest and lines". Also these design tests:
+ - no `Days` on a whole month vs a partial month, under each answer;
+ - resolve, then priced on the next pass;
+ - a waived mixed history, and one emitting one code, through both readers.
+
+ The lifecycle tests split lines by hand (`storeTwoCodeMonth`).
+- **Scenario:** Paul answers 16 expecting a one-line switch. The activation PR then carries untested pricing glue and the error-to-review mapping, reviewed as if it were trivial.
+- **Smallest fix (either):**
+ - Correct design §6, the "one body" sentences, DISPOSITIONS and DECISIONS 16 to list the activation work: the glue, the review mapping for `errBillingTaxApportion` and `errBillingDays`, spans in the record, and the tests above.
+ - Or build an uncalled `twoCodeLines(calc, history, codeOf, body)` with the end-to-end test now.
+
+ The doc fix is enough for this merge.
+
+### P3-1. Task 0 pins the request hash of a hand-built struct, not the engine's request
+- **Where:** billingtaxshape_fixtures_test.go:124–125.
+- **What:** the test builds `InvoiceItemParams` and the kind (`"line-"+meter`) itself. It never calls `r.lineParams`, `lineKind` or `r.requestHash`.
+- **The legacy fixtures' hashes are not what the engine journals:** they are hashed with `Currency:"eur"`, but `requestHash` clears the currency for v1.
+- **Scenario:** `lineParams` changes to always set `TaxCodeMetadata`. The one-code journal hash moves and Task 0 still passes. Only the fake's kind mapping catches it, by accident.
+- **By reading the code, today's engine path is byte-identical.** This is a gap in the test, not a defect.
+- **Fix:** in the same loop, build a run with `calc.TaxCode=code` and assert `lineParams(line)` equals the fixture's `p` and `lineKind(line)` equals the fixture's kind. Don't touch the literals.
+
+### P3-2. The "older reader" test is not the old control flow
+- **Where:** billingtaxlifecycle_test.go:170–215.
+- **What:** the design (:57) promises "the real old control flow", and the plan (:36) a `billingoldreader_test.go` "copied verbatim". The test instead:
+ - uses a predicate with the current `wellFormed`, which is unchanged since 29ea6676, so that part is fine;
+ - drops the account and month clauses;
+ - files the review by hand.
+
+ "Makes it file the review and write nothing" is therefore asserted of the new reader under a hand-filed review.
+- **On the substance:** the 29ea6676 guard (billinginvoice.go:336 there) does refuse a record with an empty top-level code before any provider call. That is correct.
+- **Fix:** either reword the design and plan claims, or copy the 29ea6676 guard body in full and drive the review through `r.review`.
+
+### P3-3. The entry guard now accepts records the old one refused
+- **Where:** billingcalculation.go:107–110, billinginvoice.go:335.
+- **Records the new guard accepts that the old one refused:**
+ - every line tagged with an empty top-level code (intended);
+ - a waived record with no lines and an empty `tax_code`, including one frozen before tax codes existed. The old guard filed `record_mismatch`; the new one advances it to `waived`.
+- **Records the new guard refuses that the old one accepted:** a non-waived record with no lines. `stepFrozen` (:619) already refused this with the same review, so behaviour is unchanged.
+- Nothing is invoiced under any of these. The design intends the waived branch; this is noted only so nobody calls it byte-identical behaviour.
+- **Fix:** none required. Optionally add an engine test that pins the waived, empty-code case.
+
+### P3-4. DECISIONS 16 on the launch branch (27419dcf, lines 54–60) contradicts the design
+- **"A small change on the box side":** the design says `Days` are already written for every account with usage. `usage.Statement.Days` and its comment agree with the design.
+- **16d:** says "the next pass then prices it" with no conditions. The design's 16d has three: no other open review, the backoff, and the twelve-month lookback.
+- **Revision:** it cites revision 2.
+- **Scenario:** the owner decides 16 on stale cost and resumption claims.
+- **Fix:** correct these in Task 5.
+
+### P3-5. Mutant log and test gaps
+- **Missing kill output:** M3 and M15 have none recorded. By reading, both die:
+ - M3 dies on the `"txcd_"` and `"10102001"` shape cases;
+ - M15 dies on the lapse case (160 h vs 150 h).
+
+ The disposition's "each with its killing tests logged" is not literally true. Re-run and record them.
+- **A mutant that would survive:** deleting `if amount < 0` in `apportion` (:169), because there is no negative-amount case. Add one.
+- **The fractional-boundary test** asserts weights, not "the exact expected shares" the design names. That is acceptable, but say so.
+
+### P3-6. `freeze` now looks up every code before checking for a mix
+- **Where:** billinginvoice.go:470–482.
+- **What changed:** the order. Personal, then Business, then an unconfigured plan would now give `errBillingUnavailable` instead of `tax_code_mixed`.
+- **Unreachable today:** `validBillingTaxCodes` requires exactly personal, community and business, and only those exist as paid plans.
+- **Fix:** none. Noted for the record.
+
+## The questions, briefly
+
+- **A. Byte identity:** I found no input that changes a one-code calculation's bytes.
+ - `OverageLine.TaxCode`, `Spans` and `TaxCodeMetadata` are all `omitempty`.
+ - `Spans` is cleared at billingcalculation.go:173, and `AllowanceForMonth` has no other caller.
+ - The `checkedStatementDays` and `billingMeterScale` refactors keep the same logic.
+ - JSON maps encode sorted, so no ordering change.
+ - The fixtures have not changed since Task 0 (5429b690), which touched no production code. They pin priced calculations, not frozen ones (`tax_code:""`).
+- **B. The tax shape:** see P3-3.
+ - A code at both levels, at neither, or malformed is refused by both `taxShapeOK` and `lineParams`.
+ - A `lineParams` refusal is still `tax_code_mismatch`.
+ - Dropping the guard is killed by the existing test `TestInvoiceEngineLegacyCalculationsFailClosed…`.
+- **C. Line identity through the engine:** sound.
+ - The digest covers the record's shape and a record is frozen once, so an old-kind and a new-kind operation can't both be acknowledged for one axis.
+ - A tagged line without metadata is held at the POST answer, at recovery (unrecovered, not resent), and at validation, before finalization. Equal amounts are covered; paged listings are tested.
+- **D. Attribution:** correct.
+ - The eligibility predicate is identical to `billingUsageOnPaidDays`.
+ - When `Covered` is nil the whole month is the interval.
+ - Spans are half-open.
+ - I agree M14 is equivalent: spans are ordered and don't overlap, so the span holding the day's first instant is always the first match. The logged reasoning is slightly off, but the conclusion holds.
+ - Nothing changes which days are billed, or any usage or amount.
+- **E. Arithmetic:**
+ - Each per-code sum is floored once, and the transfer addition is checked for overflow.
+ - `apportion` is exact (big.Int), conserves each axis's amount and is independent of map order.
+ - The subtotal can't move, and nothing rounds beyond the axis.
+- **F. Policy:**
+ - Nothing is decided. 16a's rule is hard-coded but labelled as a proposal.
+ - `taxCodesForLines` matches today's behaviour for one code and for two (apart from P3-6).
+- **G. The older reader and the roll rule:**
+ - An older binary files `record_mismatch` on a non-terminal two-code record. That review is account-wide.
+ - A terminal record is skipped, and nothing outside the invoice engine reads the line codes.
+- **What Task 5's ENABLEMENT.md edit must say, beside the retirement roll rule and amending §7 "an older binary runs against it":**
+ - every console that can run the invoice pass is on this build, and older ones are stopped, before `taxCodesForLines` changes;
+ - no rollback to an older build while any non-terminal record has an empty top-level `tax_code`;
+ - an older binary's `record_mismatch` hold is account-wide and is cleared with `carlos billing resolve`;
+ - held months follow 16d with its three conditions, and months outside the lookback are not covered.
+- **H. Tests and mutants:** see P3-1, P3-2 and P3-5.
+- **I. Required before the merge:**
+ - P2-1's documentation correction, or the uncalled glue with its end-to-end test;
+ - P3-1's engine-path assertion;
+ - P3-4's corrections to DECISIONS 16 and 16d in Task 5;
+ - the ENABLEMENT.md roll rule above.
+
+ The rest can follow.
+
+## Claims the code contradicts
+- "Activation is a reviewed one-line change" (design :41) and "any answer is one body of one function" (design :18; DECISIONS 16).
+- "The older reader, with the real old control flow … copied verbatim" (design :57; plan :36).
+- "End-to-end two-code calculation through a test seam" (plan :31). It doesn't exist.
+- "Twenty-two die … each with its killing tests logged" (DISPOSITIONS). M3 and M15 have no kill output.
+- "The next pass's conditions stated in 16d" (DISPOSITIONS R2). True of the design, not of DECISIONS 16d.
+- DECISIONS 16: "a small change on the box side". The design and `usage.Statement` say `Days` are already written.
+- Task 0 "pins request hashes". It pins a hand-built request; the v1 hashes include the currency, which the engine never journals.
+
+**Verdict: accept with fixes.**
diff --git a/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review2.md b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review2.md
new file mode 100644
index 000000000..45ce3d233
--- /dev/null
+++ b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review2.md
@@ -0,0 +1,119 @@
+# Second code review: `design-per-line-tax-codes` at 8715e4cd (base 29ea6676)
+
+I found no P1 or P2. All eleven first-review findings are closed. Every code fix has a test that fails without it, except M27, which no test can reach until activation. The design's dispositions hold, with four wording exceptions listed under "Claims the code contradicts". By reading the code, nothing changes for a one-code month: its bytes, digest, request hashes, operation kinds and amounts are all as before. Nothing decides 16 or 16a to 16d, and the hold is today's.
+
+## First review: is each finding closed at 8715e4cd?
+
+| Finding | Closed? | Fails without its fix? |
+|---|---|---|
+| Opus P2-1: activation glue, and `errBillingTaxApportion` retried quietly | Yes. `twoCodeCalculation` and the two bodies exist and nothing calls them (billingtaxsplit.go:295–347). The error now files a review (billinginvoice.go:319). | The glue test fails without the glue (M25, M26, M19, M23 die). The review mapping (M27) survives, and only the activation change can test it. That is stated correctly. |
+| Opus P3-1 / Astra 2: Task 0 pinned the wrong things | Yes. `TestTodaysFrozenRecordsAndRequestsArePinned` pins the frozen bytes after the code is set. It goes through `lineParams`, `lineKind`, `draftParams` and `requestHash`, which strips the currency of a version 1 month. It asserts no metadata code. | Yes. M11, M12 and M13 die. If `lineParams` always set the metadata code, the test would now catch it. |
+| Opus P3-2 / Astra 3: the lifecycle tests were too narrow | Yes, all of it. The first line's loss is covered, and wrong codes on the POST answer, the recovery listing and the draft, with equal amounts. The older-reader harness copies 29ea6676:336 exactly, files through `r.review`, and skips terminal records. Waived and one-emitted-code records go through both readers. | Yes (M6–M10 die). |
+| Opus P3-3: the waived branch of the shape | Yes. A waived calculation must have no lines (billingcalculation.go:104). | Yes (M4, M29). |
+| Opus P3-4: stale sentences in decision 16 | Mostly. 02e3538a fixes the box-side claim, the 16d conditions and the revision number. One wording slip remains (claim 2 below). | n/a |
+| Opus P3-5: M3/M15 didn't compile; no negative amount | Yes. Both now compile, and a negative-amount case exists (M28). | Yes. |
+| Opus P3-6: an unconfigured plan is reported before the mix | Noted and unchanged. It is unreachable because the configuration requires all three paid plans. | n/a |
+| Astra 1: a lost finalize was replayed over a draft without validating it again | Yes. `stepFinalizing` validates a draft first (billinginvoice.go:1210–1218). | Yes (M24 dies in all four cases). |
+| Astra 4: two mutants did not compile | Yes. | n/a |
+| Astra's checked claims (iteration order, M14 label, one-line activation, 16d citation, partial month with no `Days`) | All corrected in design revision 5, the dispositions and 02e3538a. | n/a |
+
+## The three design rounds against the code
+
+Every disposition holds.
+- **Round 1:** the line identity is built; there is no automatic resolution; the roll rule is waiting for Task 5; eligibility comes before attribution; 16a–16d are stated as the owner's; the apportionment is exact; zero usage with no `Days` behaves as stated.
+- **Round 2:** one-code months need no new daily data; the POST answer and the draft both check the metadata code; transfer meters are floored first, then added (M16 dies); the references and 16d conditions are written.
+- **Round 3:** the month-end answer needs no `Days` on a whole month, and a partly covered month without `Days` still can't be priced (tested in the glue test).
+
+## Findings, most severe first
+
+**P3-1. The resumed finalize can file a false review (billinginvoice.go:1210–1218).** `stepFinalizing` reads the invoice once, sees `draft`, then `validateDraftLines` reads it again.
+- **Scenario:** a finalize from the dead process lands between the two reads. The second read sees `open`, and `validateDraftLines` files `billing_invoice_finalized_externally` for a correctly finalized invoice.
+- **Scope:** this applies to every month, one-code included. The window is tiny.
+- **Related, also every month:** a one-code draft that changed under a journaled finalize now gets a lines-mismatch review instead of being finalized. That is a deliberate tightening. The design doesn't say it applies to every month.
+- **Smallest fix:** pass the invoice already read into the validation, or let an open/paid invoice on that path fall through to `perform`. State the tightening in the design and in ENABLEMENT.
+
+**P3-2. The split body asks for weights when it has nothing to divide (billingtaxsplit.go:337–343).** `bodySplitByGrossUsage` calls `weights()` even when there are no lines.
+- **Scenario (only after activation):** a waived two-code whole month (house account, or below the floor) whose statement has usage but no `Days`. It gets the quiet `errBillingDays` and retries forever instead of being waived.
+- **Contradicts:** the design's "attributes the eligible days (only if the body divides by them)".
+- **Smallest fix:** if there are no lines, return them before calling `weights()`; add a test case. Alternatively, name it as an activation obligation.
+
+**P3-3. The body's signature can only express the proposals (billingtaxsplit.go:282, 313, 345).** `twoCodeBody` receives per-code usage and the last span's code only.
+- It cannot express 16b's alternatives (span excess, chronological consumption), which need per-span allowances.
+- It cannot express 16c's "hold", which needs to know whether the last span reaches the month's end.
+- `bodyLastSpansCode` quietly applies 16c's first option to a cancelled month.
+- **Nothing is decided** (none of it is called), but the design's "any answer is one body of one function" is not true.
+- **Smallest fix:** reword the design and the comments (e.g. "as 16c would settle it" becomes "if 16c takes the last paid span's code"), or pass the spans and the month's end to the body.
+
+**P3-4. The one-emitted-code test stores an inconsistent record (billingtaxlifecycle_test.go:371–379).** It rewrites `billing_months.calculation` by SQL and leaves the digest of the two-line record.
+- **Scenario:** the record's digest isn't the hash of its bytes. The test passes only because nothing re-hashes the stored record.
+- **Smallest fix:** build the one-line calculation first and freeze it through `freezeBillingMonth`.
+
+**P3-5. Two guards no test kills.**
+- `twoCodeCalculation`'s `calc.TaxCode != ""` check (:296): the "split twice" case passes a calculation whose own code is empty, so the line check catches it first. A one-code calculation fed to the glue would be silently converted.
+- `usageByCode`'s transfer overflow check (:131).
+- **Smallest fix:** a case each: a frozen one-code calculation passed to the glue, and two transfer meters near the int64 maximum.
+
+## A–I
+
+**A. Byte identity.** I found no input that changes a one-code month.
+- `OverageLine.TaxCode`, `MonthAllowance.Spans` and `InvoiceItemParams.TaxCodeMetadata` are all `omitempty`. Field order and names are untouched.
+- `priceBillingCalculation` drops the spans before every return (:174). `AllowanceForMonth`'s only serialized caller is that function.
+- JSON sorts map keys. `metadata[tax_code]` is sent only when the field is set.
+- Extracting `checkedStatementDays` and `billingMeterScale` changes no check, order or error.
+- Both fixture tables are literal and are the right ones: legacy, one plan, two plans with one code, a quantity change, partial, waived, and no `Days`. A one-code partial month with two spans would be a useful extra, but M13 covers the risk.
+
+**B. Shape and entry guard.**
+- Records the new guard refuses and the old accepted: a non-waived record with no lines, and a waived record with lines. `stepFrozen` already refused both with the same review.
+- Records the new guard accepts and the old refused: the two-code shapes, including waived with no code. This is intended, and the older reader refuses all of them.
+- A code at both levels, at neither, or malformed is refused by both `taxShapeOK` and `lineParams`.
+- `lineKind` can't collide with an old kind, because a code is never empty.
+
+**C. Line identity.** The calculation is fixed once frozen (`reload` checks the digest), and its shape is all one or all the other. Every kind therefore comes from one shape, so an old-kind and a new-kind operation for one axis can't both be acknowledged.
+- The POST answer, recovery (meter, digest, code), paged listing and draft validation all require the code.
+- Equal amounts are told apart by the code.
+- A line without metadata is caught at the POST answer, the draft and, now, the resumed finalize.
+- For untagged lines the new clauses are equivalent to before.
+
+**D. Attribution.**
+- Eligibility uses the same predicate as `billingUsageOnPaidDays`. A fully covered month (`Covered` nil) takes the whole month.
+- Spans are half-open. The first-instant rule is labelled as 16a's proposal.
+- Two spans of one code aggregate.
+- An eligible day no span holds can't happen with spans from one `AllowanceForMonth` (they are ordered, disjoint, and their union is the coverage); the code returns an error anyway. A plan with no code returns `errBillingUnavailable`.
+- No `Days` with zero usage gives no weights; no `Days` with usage gives `errBillingDays`. Malformed `Days` fail exactly as today.
+- Billed days, usage and amounts are untouched.
+
+**E. Arithmetic.**
+- Per (code, meter), days are summed in date order and floored once; transfer is added per code with the overflow check.
+- `apportion` multiplies in `big.Int`, floors, hands out the largest remainders with a stable sort over byte-sorted codes, and omits zero shares.
+- Zero weight with a positive amount is an error. A wrapped sum is caught on its first overflow.
+- The result doesn't depend on map order. Shares sum to the axis amount, so the subtotal can't change. Nothing rounds across axes.
+
+**F. Policy.** `taxCodesForLines` is today's loop, and every two-code month still holds with `billing_invoice_tax_code_mixed`. The only difference is P3-6 of review 1 (unreachable). Wording is the only issue (P3-3).
+
+**G. Older reader and roll rule.**
+- An older binary files `billing_invoice_record_mismatch` for any nonterminal two-code record, waived included, before touching the provider. The harness's predicate is 29ea6676:336 verbatim. A terminal record is skipped.
+- That review holds the whole account, and the older binary can file it again after it is resolved, for as long as it runs. The test proves this.
+- Until activation no two-code record can exist, so this merge rolls in any order.
+
+ENABLEMENT.md (Task 5) must say:
+1. Before any build whose `freeze` can write a two-code calculation (the switch is in `freeze`, not in `taxCodesForLines`), every console that can run the invoice pass is on a build containing this merge, and older ones are stopped.
+2. No rollback below it after that.
+3. "An older binary runs against the migrated schema" does not apply past activation.
+4. The resumed finalize now validates the draft for every month.
+
+**H. Tests and mutants.** Each code change has a killing test (M1–M29), except:
+- **M14:** truly equivalent. Spans are ordered and disjoint, eligibility is decided first, and the span holding the day's first instant is always the first span that overlaps the day.
+- **M27:** reachable only after activation; correctly named as that change's obligation.
+
+Untested and worth having are P3-5's two guards. Cosmetic: the mutant log has a Python traceback from a stale M4 pattern after M3, then the real M4.
+
+**I. Before the merge.** Nothing is required beyond the gate alone on the exact tip, CI, and Task 5's records worded as in G (keyed to `freeze`, not `taxCodesForLines`). P3-1 to P3-5 are cheap and I'd take them now. If they wait, P3-2 and P3-3 must be listed as obligations of the activation change.
+
+## Claims the code contradicts
+
+1. Design, "What is the owner's": "it builds so that any answer is one body of one function". The body's signature can't express 16b's alternatives or 16c's hold (P3-3).
+2. Decision 16 (02e3538a) and plan Task 5: "the switch in `taxCodesForLines`". It only returns one string; activation switches `freeze` to `twoCodeCalculation`, which is what the design says.
+3. Design §1, revision 5: "`wellFormed` gains the tax shape". The shape is a separate `taxShapeOK`, checked beside `wellFormed` at the guard. The dispositions say this; the design body doesn't.
+4. Design §6 and the glue's comment: weights are asked for "only if the body divides by them". The split body asks even with zero lines (P3-2).
+
+**Verdict: accept with fixes.** There is no P1 or P2. The P3s are wording and test fixes that can land with Task 5, or else be named as obligations of the activation change.
diff --git a/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review3.md b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review3.md
new file mode 100644
index 000000000..f818c1377
--- /dev/null
+++ b/docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review3.md
@@ -0,0 +1,148 @@
+# Third code review: `design-per-line-tax-codes` at 6488c198 (base 29ea6676)
+
+I found no P1 or P2. The branch is ready to merge into `feat/carloku-paid-launch-20260916` as a merge commit once CI is green on 6488c198. The gate log shows `gate exit 0 … on 6488c198`, with the darwin, linux/arm64 and windows cross-builds ok. I did not check CI.
+
+One condition: the merge's Task 5 records must key the roll rule to `freeze`, not to `taxCodesForLines` (P3-2 below). The other two findings are a test gap and wording, and can land with Task 5 or after it.
+
+The standard holds:
+- **One-code months:** byte-identical. Bytes, digest, request hashes and operation kinds are unchanged.
+- **Money:** no amount, floor or price changes.
+- **Decision 16:** nothing decides 16 or 16a–16d, and the hold is today's.
+- **Older binary:** it refuses a nonterminal two-code record with a review and never invoices it.
+
+## The design's dispositions against the code
+
+All three design rounds' dispositions hold.
+- **Round 1:**
+ - The line identity is built.
+ - There is no automatic resolution.
+ - The older reader's behaviour is tested.
+ - Eligibility comes before attribution.
+ - 16a–16d are stated as the owner's.
+ - The apportionment is exact.
+ - Zero usage without `Days` behaves as stated.
+- **Round 2:**
+ - One-code months gain no daily-data requirement.
+ - The POST answer and the draft both check the metadata code.
+ - Transfer meters are floored first, then added.
+ - The references are written.
+- **Round 3:** the month-end answer adds no `Days` requirement, and a partly covered month without `Days` still can't be priced.
+
+## Second review, finding by finding
+
+| Finding | Closed at 6488c198? | Would its test pass without the fix? |
+|---|---|---|
+| **Opus P3-1:** the resumed finalize read the invoice twice | Yes. `stepFinalizing` reads once (billinginvoice.go:1216) and validates that reading through `validateDraftLinesOf` (:1223, :1127). The every-month tightening is stated in design §Compatibility. | **Yes, it would pass.** No test lands a finalize between two reads, and no mutant reverts to `validateDraftLines(ctx)`. The disposition names `TestAResumedFinalizeValidatesTheDraftFirst`, but that test passes with the double read. See P3-1 below. |
+| **Opus P3-2:** the split body asked for weights with no lines | Yes (billingtaxsplit.go:346–350). | No. M30 dies on `TestTheGluesGuards` (:360). |
+| **Opus P3-3:** the body's shape was called "any answer" | Yes, in the design and the comments (billingtaxsplit.go:260–290). | n/a |
+| **Opus P3-4:** the one-emitted-code record was rewritten by SQL | Yes. It is frozen through `freezeBillingMonth` (billingtaxlifecycle_test.go:395–404). | Test-only fix. |
+| **Opus P3-5:** two guards untested | Yes. | No. M31 and M32 die. |
+| Opus claim 1: "any answer is one body" | Corrected. | |
+| Opus claim 2: "the switch in `taxCodesForLines`" | **Partly.** Corrected in decision 16 (68b16c52:56) and design §6. It survives in design §Compatibility (:45), plan Task 5 (:41), DISPOSITIONS (:72) and the comment at billinginvoice.go:317 (P3-2 below). | |
+| Opus claim 3: "`wellFormed` gains the tax shape" | Corrected. A new slip in design §1, "asked … and in `lineParams`", is listed in the claims section below. | |
+| Opus claim 4: weights asked for "only if the body divides" | Now true. | |
+| **Astra 1:** the apportionment review mapping was untested | Yes. `TestAnUnattributedChargeHoldsTheMonthForAPerson` checks the exact review, that nothing is frozen and that nothing reaches the provider. | No. M27 now dies. |
+| **Astra 2:** the wrong-code recovery test accepted any error; no in-window first-line replay | Yes. The recovery case asserts that the operation is not acknowledged, that the sibling was not created, and the `billing_invoice_unrecovered` review alone. If the recovery predicate (:1048) is removed, recovery acknowledges the wrong-coded item and the test fails at :179. The in-window first-line case asserts one more arrival, and `assertMutations` asserts one mutation; the fake only counts a mutation for a new key, so the key is the same. | No. |
+| Astra's overstated claims | Mostly corrected: design :41 and :57, design :58 (the copied `wellFormed`), design :56 (shares versus weights), and decision 16's "nothing is built". Plan :31 ("exact shares") and plan :36 (`billingoldreader_test.go`, "old `wellFormed` … copied verbatim") are unchanged. | |
+
+**Does the delta since 8715e4cd change a conclusion?** No.
+- **Production changes:** the delta touches only two production files.
+ - `validateDraftLinesOf` is a pure extraction. `validateDraftLines` still does one read and calls it, so its other callers behave identically.
+ - `stepFinalizing` now has strictly fewer false reviews than before.
+ - `bodySplitByGrossUsage` is uncalled.
+- **Unaffected:** one-code bytes, the hold and the older reader are untouched. The Task 0 fixtures file is unchanged since 8715e4cd.
+
+## Findings
+
+**P3-1. The single-read fix has no test that fails without it** (billinginvoice.go:1216–1226)
+- **Scenario:** change :1223 back to `r.validateDraftLines(ctx)`, which reads the invoice again. Every test still passes, and the round-3 mutant log has no such mutant.
+- **Effect:** the fix is correct by inspection. The disposition's test column overclaims.
+- **Smallest fix:** a fake hook that turns the invoice `open` after the first `GetInvoice` in finalizing. Assert there is no `billing_invoice_finalized_externally` review and the month reaches `open` or `complete`. Alternatively, mark the disposition "by inspection".
+
+**P3-2. The roll-rule trigger is still keyed to `taxCodesForLines`** (design :45; plan :41; DISPOSITIONS :72; billinginvoice.go:317)
+- **Why it's wrong:** `taxCodesForLines` returns one string, so it can never express a two-code result. Activation can leave it unchanged and catch `errBillingTaxCodeMixed` in `freeze`. In that case "before `taxCodesForLines` stops returning the error" never happens, and an ENABLEMENT sentence copied from plan Task 5 never triggers.
+- **Smallest fix:** in the Task 5 text, key it to "before any build whose `freeze` can write a two-code calculation". Reword the four places above to match.
+- **This one is required at the merge,** because the merge carries the ENABLEMENT text.
+
+**P3-3. Wording the code contradicts.** See "Claims the code contradicts" below. Doc-only; can land with Task 5.
+
+## A–I
+
+**A. Byte identity.** I found no input that changes a one-code calculation.
+- **New fields:** `OverageLine.TaxCode`, `MonthAllowance.Spans` and `InvoiceItemParams.TaxCodeMetadata` are all `omitempty`. The last sits before `Currency` in field order, so an empty value moves nothing.
+- **Spans:** `priceBillingCalculation` sets `allowance.Spans = nil` before every return (billingcalculation.go:174). `AllowanceForMonth` has no other caller.
+- **Metadata:** `metadata[tax_code]` is sent only when the field is set (billingstripe/invoices.go:295).
+- **Hashing:** `requestHash`'s legacy strip is unchanged, and `lineKind` is `line-<meter>` for an untagged line.
+- **Extractions:** `checkedStatementDays` and `billingMeterScale` keep every check, its order and its errors.
+- **Map ordering:** JSON map keys are sorted.
+- **Task 0 fixtures:** right and literal. Both tables were recorded at 29ea6676 (5429b690 and 8715e4cd) and have not changed since. M11, M12 and M13 die on them.
+
+**B. Shape and guard.**
+- **New guard refuses what the old accepted:** a non-waived record with no lines, and a waived record with lines. Neither can be produced: a non-waived total is at least the floor, so it has lines. `stepFrozen` already refused both.
+- **New guard accepts what the old refused:** the two-code shapes, including waived with no code. A waived one goes to `waived` with no provider call. This is intended and tested.
+- **Bad codes:** both `taxShapeOK` and `lineParams` refuse a code at both levels, at neither, or a malformed code (M1, M2, M3, M5).
+- **`lineKind`:** it can't collide with an old kind.
+- **Note:** `taxShapeOK` doesn't require unique (meter, code) pairs. A duplicate can't come from `apportion`, and if one were stored by hand the shared kind would leave the draft sum short of the total and hold with `billing_invoice_lines_mismatch`. Not a finding.
+
+**C. Line identity.**
+- **One shape per record:** the record is frozen before any operation, and the digest is checked on every `reload`. Its shape decides every kind, and an older worker refuses the two-code shape at entry. So no path acknowledges an old-kind and a new-kind operation for the same axis.
+- **Code checked everywhere:** the POST answer (:1036), recovery (:1048), paged listing and draft validation (:1154) all require the code. Equal amounts are told apart by it.
+- **No metadata, no finalize:** a line without metadata is caught at the POST answer, the draft, and the resumed finalize before it reaches finalization.
+
+**D. Attribution.**
+- **Eligibility:** uses the same whole-day predicate as `billingUsageOnPaidDays`. A fully covered month (`Covered` nil, `!Partial`) takes the whole month.
+- **Spans:** half-open; the first-instant rule is labelled as 16a's proposal; two spans of one code aggregate.
+- **Edge cases:**
+ - A day no span holds is unreachable, and returns `errBillingDaysMismatch` anyway.
+ - A plan with no code returns `errBillingUnavailable`.
+ - `Days` absent with zero usage gives no weights; absent with usage gives `errBillingDays`. Malformed `Days` fail as today.
+- **Unchanged:** billed days, `CountedDays`, usage and amounts.
+
+**E. Arithmetic.**
+- **Weights:** per (code, meter), summed in date order and floored once. Transfer is added per code with the overflow check (now tested by M32).
+- **`apportion`:**
+ - exact `big.Int` arithmetic, floor, then largest remainder with a stable sort over byte-sorted codes;
+ - zero shares are omitted;
+ - a negative amount or weight, zero weight with a positive amount, and a wrapped sum are all errors.
+- **Determinism and subtotal:** the result doesn't depend on map order, and shares sum to each axis's amount, so the subtotal can't change. Nothing rounds beyond the axis.
+
+**F. Policy.**
+- **The hold:** `taxCodesForLines` is today's loop, so one code is carried as before and two hold with `billing_invoice_tax_code_mixed`. The only difference is an unconfigured plan being looked up before the mix check, which the configuration makes unreachable.
+- **The bodies:** both are uncalled and labelled as proposals. `bodyLastSpansCode` is now described as conditional on 16c.
+
+**G. Older reader and roll rule.**
+- **The harness:** its predicate (billingtaxlifecycle_test.go:282) is 29ea6676:336 verbatim, and `wellFormed` is unchanged.
+- **What an older binary does:**
+ - A nonterminal two-code record, waived included, gets `billing_invoice_record_mismatch` before any provider call.
+ - A terminal record is skipped.
+ - The review holds the whole account, and the older binary can file it again after it is resolved. Both are tested.
+- **What ENABLEMENT.md must say at the merge:**
+ 1. Before any build whose `freeze` can write a two-code calculation, every console that can run the invoice pass is on a build containing this merge, and older ones are stopped.
+ 2. No rollback below that build afterwards.
+ 3. Line 206 ("an older binary runs against the migrated schema") does not apply past activation.
+ 4. From this merge, a resumed finalize validates the draft for every month. A one-code draft that changed under a journaled finalize now holds with `billing_invoice_lines_mismatch`.
+- **Also in the merge:** OUTSTANDING item 17 points at the build and the hold; READINESS names the branch. Until activation the merge rolls in any order.
+
+**H. Tests and mutants.**
+- **Kills:** M1–M13 and M15–M32 each die on a named test.
+- **M14 is equivalent for the reason given:** spans are ordered and disjoint, eligibility comes first, and the span holding a day's first instant is the first span that overlaps that day.
+- **Would survive:** the double-read revert in P3-1.
+- **Unreachable guards a mutant would survive:** the glue's final `taxShapeOK`, `len(Spans)==0`, and `apportion`'s `extra` and `IsInt64` bounds. That's acceptable.
+- **Untested:** the one-code side of the every-month tightening. Its code path is the same one the two-code tests exercise.
+
+**I. Before the merge.** Required:
+- CI green on 6488c198 (the gate already passed alone on it);
+- Task 5's records worded as in G, keyed to `freeze` (P3-2).
+
+Nothing else, and decision 16 is not needed. P3-1 and P3-3 can land with Task 5 or after.
+
+## Claims the code contradicts
+
+1. **The roll-rule trigger** (design :45, plan :41, DISPOSITIONS :72, billinginvoice.go:317): "before `taxCodesForLines` stops returning the error" / "`taxCodesForLines` is switched". Activation is in `freeze` (P3-2).
+2. **Design §1:** "`taxShapeOK`, asked beside `wellFormed` at the entry guard and in `lineParams`". `lineParams` doesn't call it; it has its own equivalent check (billinginvoice.go:973–983).
+3. **Design §Tests:** "its review resolved by the operator door and the month priced on the next pass once a body is enabled in the test". No test enables a body through `freeze`; that is the activation change's test.
+4. **DISPOSITIONS row for Opus P3-1:** names `TestAResumedFinalizeValidatesTheDraftFirst`. That test doesn't exercise the double read (P3-1).
+5. **Plan Tasks 3 and 4** (:31, :36): "exact shares", `billingoldreader_test.go`, "the old `wellFormed` and entry guard copied verbatim". The test asserts weights, the file is `billingtaxlifecycle_test.go`, and `wellFormed` is shared. The DISPOSITIONS row says these were reworded, but the plan was not.
+6. **Revision numbers:** the DISPOSITIONS header cites design revision 4 and decision 16 (68b16c52:56) cites revision 5. The design is revision 6.
+
+**Verdict: accept with fixes.** The one required fix is the `freeze`-keyed wording in the merge's own Task 5 records.
diff --git a/internal/billingstripe/invoices.go b/internal/billingstripe/invoices.go
index b00b61069..65f75ea43 100644
--- a/internal/billingstripe/invoices.go
+++ b/internal/billingstripe/invoices.go
@@ -266,6 +266,12 @@ func (x *InvoiceItem) UnmarshalJSON(data []byte) error {
type InvoiceItemParams struct {
Customer, Invoice, Meter, TaxCode, Description, CalculationDigest string
AmountCents, PeriodStart, PeriodEnd int64
+ // TaxCodeMetadata, when set, is sent as metadata[tax_code] and must
+ // equal TaxCode: a line that carries its own code (one of two for a
+ // meter) names it in metadata so it can be told from its sibling when
+ // read back. Omitted from the encoding when empty, so a request
+ // without it hashes exactly as before the field existed.
+ TaxCodeMetadata string `json:",omitempty"`
// Currency is the currency of the item: the invoice's and the
// calculation's own. Required, lower case; omitted from the encoding
// when empty for the same reason as DraftInvoiceParams.Currency.
@@ -277,12 +283,18 @@ func (c *Client) CreateInvoiceItem(ctx context.Context, p InvoiceItemParams, key
!validID(p.Meter) || !validDigest(p.CalculationDigest) || p.AmountCents <= 0 || p.PeriodStart <= 0 || p.PeriodEnd <= p.PeriodStart || len(p.Description) > 500 || !validCurrency(p.Currency) {
return nil, &Error{Code: CodeInvalidParams}
}
+ if p.TaxCodeMetadata != "" && p.TaxCodeMetadata != p.TaxCode {
+ return nil, &Error{Code: CodeInvalidParams}
+ }
form := url.Values{
"customer": {p.Customer}, "invoice": {p.Invoice}, "amount": {strconv.FormatInt(p.AmountCents, 10)}, "currency": {p.Currency},
"discountable": {"false"}, "tax_behavior": {"exclusive"}, "tax_code": {p.TaxCode},
"description": {p.Description}, "period[start]": {strconv.FormatInt(p.PeriodStart, 10)}, "period[end]": {strconv.FormatInt(p.PeriodEnd, 10)},
"metadata[meter]": {p.Meter}, "metadata[calculation_digest]": {p.CalculationDigest},
}
+ if p.TaxCodeMetadata != "" {
+ form.Set("metadata[tax_code]", p.TaxCodeMetadata)
+ }
var out InvoiceItem
if err := c.do(ctx, http.MethodPost, "/v1/invoiceitems", form, key, &out); err != nil {
return nil, err
diff --git a/internal/billingstripe/invoices_test.go b/internal/billingstripe/invoices_test.go
index 637597890..7a20c6650 100644
--- a/internal/billingstripe/invoices_test.go
+++ b/internal/billingstripe/invoices_test.go
@@ -163,6 +163,32 @@ func TestInvoiceItemTargetsOnlyItsDraftWithNoDiscount(t *testing.T) {
}
}
+// A line that carries its own tax code names it in metadata too; one that
+// does not sends no metadata[tax_code] at all; a metadata code that is not
+// the item's tax code is refused before any request.
+func TestInvoiceItemNamesItsOwnTaxCodeInMetadata(t *testing.T) {
+ var form url.Values
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ form = r.PostForm
+ w.Write([]byte(`{"id":"ii_usage","invoice":"in_usage","customer":"cus_paid","livemode":false,"currency":"eur","amount":800,"discountable":false}`))
+ }))
+ defer srv.Close()
+ p := invoiceItemFixture()
+ if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k1"); err != nil || form.Has("metadata[tax_code]") {
+ t.Fatalf("a line without its own code: %v %v", form, err)
+ }
+ p.TaxCodeMetadata = p.TaxCode
+ if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k2"); err != nil || form.Get("metadata[tax_code]") != p.TaxCode || form.Get("tax_code") != p.TaxCode {
+ t.Fatalf("a line with its own code: %v %v", form, err)
+ }
+ form = nil
+ p.TaxCodeMetadata = "txcd_other"
+ if _, err := testClient(t, srv.URL).CreateInvoiceItem(context.Background(), p, "k3"); err == nil || form != nil {
+ t.Fatalf("a metadata code that is not the tax code: %v %v", form, err)
+ }
+}
+
func TestFinalizeInvoiceLeavesCollectionExplicit(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/invoices/in_usage/finalize" || r.Method != "POST" || r.Header.Get("Idempotency-Key") != "stable-finalize" {
diff --git a/internal/console/billingcalculation.go b/internal/console/billingcalculation.go
index a8efdde8f..1ffe2760e 100644
--- a/internal/console/billingcalculation.go
+++ b/internal/console/billingcalculation.go
@@ -9,6 +9,7 @@ import (
"math"
"math/big"
"sort"
+ "strings"
"time"
"github.com/carlosframework/platform/internal/plans"
@@ -77,6 +78,39 @@ func (c *billingCalculation) wellFormed() bool {
return false
}
+// validTaxCode is the shape of a product tax code: "txcd_" and at least
+// one more character. Which codes exist is the provider's; the console
+// checks the shape alone.
+func validTaxCode(code string) bool { return strings.HasPrefix(code, "txcd_") && len(code) > 5 }
+
+// taxShapeOK reports whether the calculation carries its tax codes in one
+// of the two shapes it may: one code on the calculation and none on any
+// line (every calculation frozen before lines carried codes, and every
+// month whose paid spans map to one code), or no code on the calculation
+// and one on every line (a month whose paid spans carry two codes). Either
+// way a calculation that is not waived has lines and a waived one has
+// none, and every code present has the shape of one. A calculation in neither shape is not invoiced and
+// not priced again: it is a record to look at.
+func (c *billingCalculation) taxShapeOK() bool {
+ tagged := 0
+ for _, l := range c.Lines {
+ if l.TaxCode != "" {
+ if !validTaxCode(l.TaxCode) {
+ return false
+ }
+ tagged++
+ }
+ }
+ if waived := c.waived(); waived != (len(c.Lines) == 0) {
+ // A waived calculation has no lines; one that is not has some.
+ return false
+ }
+ if c.TaxCode != "" {
+ return validTaxCode(c.TaxCode) && tagged == 0
+ }
+ return tagged == len(c.Lines)
+}
+
// waived and totalMinor read the invoice total of either version. They are
// meaningful only for a well-formed calculation.
func (c *billingCalculation) waived() bool {
@@ -133,6 +167,11 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme
if err != nil {
return nil, err
}
+ // The spans the allowance counted are not part of a calculation with
+ // one tax code (its bytes are what they were before spans existed);
+ // the one path that needs them, a month whose plans carry two codes,
+ // asks the allowance for them again.
+ allowance.Spans = nil
if len(allowance.Assignments) == 0 {
return nil, plans.ErrAssignmentHistory
}
@@ -150,14 +189,7 @@ func priceBillingCalculation(statement usage.Statement, history []plans.Assignme
}
var amounts [5]int64
for i, meter := range monthlyBillingMeters {
- scale := int64(1_000_000_000)
- if meter == usage.MeterInstanceHours {
- scale = 3600
- }
- if meter == usage.MeterDurabilityWrites1k {
- scale = 1000
- }
- amounts[i], err = billingBaseUnits(billed[meter], scale)
+ amounts[i], err = billingBaseUnits(billed[meter], billingMeterScale(meter))
if err != nil {
return nil, err
}
@@ -206,34 +238,37 @@ var errBillingDays = errors.New("billing_statement_days_unusable")
// quiet: the invoice pass logs it by name on every attempt.
var errBillingDaysMismatch = errors.New("billing_statement_days_mismatch")
-// billingUsageOnPaidDays returns each billed meter summed over the UTC days
-// that lie wholly inside one paid span, and those days in order.
-//
-// A day counts only when all 24 hours are inside a single covered interval.
-// A day the plan covered only part of (it started, ended or lapsed inside
-// the day) is not counted, because the statement cannot say which of that
-// day's usage fell on which side. A plan that starts exactly at midnight
-// covers that whole day, and it counts. The rounding is toward the
-// customer, by at most two partial days for each continuous paid span, so
-// a month with several spans (lapses) can leave out more than two.
-//
-// Before anything is counted the days must account for the statement: every
-// day inside the month, and each billed meter's days summing to its monthly
-// total. The days are walked in date order, so the sums, and with them
-// whether a statement at the edge of the tolerance is accepted, are the
-// same on every run.
-func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredInterval) (map[string]float64, []string, error) {
- start, err := time.Parse("2006-01", statement.Month)
+// billingMeterScale is the base unit of a billed meter: seconds of an
+// awake hour, thousandths of a thousand writes, nanos of everything else.
+func billingMeterScale(meter string) int64 {
+ switch meter {
+ case usage.MeterInstanceHours:
+ return 3600
+ case usage.MeterDurabilityWrites1k:
+ return 1000
+ }
+ return 1_000_000_000
+}
+
+// checkedStatementDays validates a statement's daily record as a whole
+// and returns the month's bounds: a statement with no days is usable only
+// when the month recorded nothing at all; every date lies inside the
+// month; every value is finite and not negative; each billed meter's days
+// sum to its monthly total within a relative tolerance. The eligibility
+// of days (billingUsageOnPaidDays) and the attribution of days to plans
+// (billingtaxsplit.go) both read the days only after this.
+func checkedStatementDays(statement usage.Statement) (start, end time.Time, err error) {
+ start, err = time.Parse("2006-01", statement.Month)
if err != nil {
- return nil, nil, errBillingDaysMismatch
+ return start, end, errBillingDaysMismatch
}
- end := start.AddDate(0, 1, 0)
+ end = start.AddDate(0, 1, 0)
if statement.Days == nil {
// No day recorded anything. That is a usable statement only when
// the month recorded nothing at all.
for _, meter := range monthlyBillingMeters {
if statement.Meters[meter] != 0 {
- return nil, nil, errBillingDays
+ return start, end, errBillingDays
}
}
}
@@ -246,7 +281,7 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn
for _, date := range dates {
day, perr := time.Parse("2006-01-02", date)
if perr != nil || day.Before(start) || !day.Before(end) {
- return nil, nil, errBillingDaysMismatch
+ return start, end, errBillingDaysMismatch
}
for _, meter := range monthlyBillingMeters {
v, ok := statement.Days[date][meter]
@@ -254,7 +289,7 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn
continue
}
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
- return nil, nil, errBillingMeter
+ return start, end, errBillingMeter
}
sums[meter] += v
}
@@ -262,9 +297,33 @@ func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredIn
for _, meter := range monthlyBillingMeters {
total := statement.Meters[meter]
if diff := math.Abs(sums[meter] - total); diff > 1e-9*math.Max(1, math.Abs(total)) {
- return nil, nil, errBillingDaysMismatch
+ return start, end, errBillingDaysMismatch
}
}
+ return start, end, nil
+}
+
+// billingUsageOnPaidDays returns each billed meter summed over the UTC days
+// that lie wholly inside one paid span, and those days in order.
+//
+// A day counts only when all 24 hours are inside a single covered interval.
+// A day the plan covered only part of (it started, ended or lapsed inside
+// the day) is not counted, because the statement cannot say which of that
+// day's usage fell on which side. A plan that starts exactly at midnight
+// covers that whole day, and it counts. The rounding is toward the
+// customer, by at most two partial days for each continuous paid span, so
+// a month with several spans (lapses) can leave out more than two.
+//
+// Before anything is counted the days must account for the statement: every
+// day inside the month, and each billed meter's days summing to its monthly
+// total. The days are walked in date order, so the sums, and with them
+// whether a statement at the edge of the tolerance is accepted, are the
+// same on every run.
+func billingUsageOnPaidDays(statement usage.Statement, covered []plans.CoveredInterval) (map[string]float64, []string, error) {
+ start, end, err := checkedStatementDays(statement)
+ if err != nil {
+ return nil, nil, err
+ }
out := map[string]float64{}
var counted []string
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go
index 07f78a6b1..ebdfdb06d 100644
--- a/internal/console/billinginvoice.go
+++ b/internal/console/billinginvoice.go
@@ -10,7 +10,6 @@ import (
"errors"
"fmt"
"os"
- "strings"
"sync"
"time"
@@ -314,6 +313,12 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s
if errors.Is(err, errBillingTaxCodeMixed) {
return r.review("billing_invoice_tax_code_mixed")
}
+ // A two-code month whose charge has no attributed usage to divide it
+ // by (reachable only once decision 16 switches freeze to the glue,
+ // twoCodeCalculation) is a hold for a person, not a quiet retry.
+ if errors.Is(err, errBillingTaxApportion) {
+ return r.review("billing_invoice_tax_code_mismatch")
+ }
// A month paid for in two currencies or from two price books, or from
// one this build does not have, is priced in neither. It will not mend
// by waiting, so it is not left to retry quietly: a person is told.
@@ -333,7 +338,7 @@ func (s *Server) invoiceBillingMonth(ctx context.Context, acct *Account, month s
// The stored calculation must be whole for its version and name one
// currency throughout. One that is not is never invoiced, and is never
// priced again in some other currency: it is a record to look at.
- if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || !r.calc.wellFormed() || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 {
+ if r.calc.Statement.Account != acct.Sqid || r.calc.Statement.Month != month || !r.calc.wellFormed() || !r.calc.taxShapeOK() {
return r.review("billing_invoice_record_mismatch")
}
if err := r.reload(); err != nil {
@@ -468,16 +473,20 @@ func (r *billingInvoiceRun) freeze(ctx context.Context) (billingMonthRecord, err
if identity.LiveMode != r.b.cfg.LiveMode {
return billingMonthRecord{}, errBillingModeMismatch
}
- taxCode := ""
+ codes := make([]string, 0, len(calc.Allowance.Assignments))
for _, assignment := range calc.Allowance.Assignments {
code, ok := r.b.cfg.taxCode(assignment.Plan)
if !ok {
return billingMonthRecord{}, errBillingUnavailable
}
- if taxCode != "" && taxCode != code {
- return billingMonthRecord{}, errBillingTaxCodeMixed
- }
- taxCode = code
+ codes = append(codes, code)
+ }
+ // One code, and the calculation carries it as it always has; two, and
+ // the month holds (taxCodesForLines; decision 16 decides what a
+ // two-code month's lines carry, and nothing here guesses).
+ taxCode, err := taxCodesForLines(codes)
+ if err != nil {
+ return billingMonthRecord{}, err
}
calc.TaxCode = taxCode
raw, err := json.Marshal(calc)
@@ -955,15 +964,38 @@ func (r *billingInvoiceRun) stepCreating(ctx context.Context) error {
})
}
+// lineParams is the provider request for one line: the line's own tax
+// code when it carries one (a two-code calculation; the code is then also
+// sent as the item's metadata, which is how the line is told apart from
+// its sibling of the same meter afterwards), else the calculation's. A
+// line with a code at both levels, or at neither, is not sent.
func (r *billingInvoiceRun) lineParams(line plans.OverageLine) (billingstripe.InvoiceItemParams, error) {
- if !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 {
+ code, meta := r.calc.TaxCode, ""
+ if line.TaxCode != "" {
+ if code != "" {
+ return billingstripe.InvoiceItemParams{}, errBillingInvoiceMismatch
+ }
+ code, meta = line.TaxCode, line.TaxCode
+ }
+ if !validTaxCode(code) {
return billingstripe.InvoiceItemParams{}, errBillingInvoiceMismatch
}
- return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: r.calc.TaxCode,
+ return billingstripe.InvoiceItemParams{Customer: r.rec.Customer, Invoice: r.rec.InvoiceID, Meter: line.Meter, TaxCode: code, TaxCodeMetadata: meta,
Description: billingInvoiceLineDescription(line.Meter, r.month), CalculationDigest: r.rec.Digest,
AmountCents: line.AmountCents, PeriodStart: r.start.Unix(), PeriodEnd: r.end.Unix(), Currency: r.calc.Currency}, nil
}
+// lineKind is the journal's name for a line's operation: by meter, as it
+// always was, for a line without its own code; by meter and code for a
+// line with one, so two lines of one meter in a two-code calculation are
+// two operations. The two cannot collide: a code is never empty.
+func lineKind(line plans.OverageLine) string {
+ if line.TaxCode == "" {
+ return "line-" + line.Meter
+ }
+ return "line-" + line.Meter + "-" + line.TaxCode
+}
+
// billingInvoiceLineDescription is what the customer reads on the invoice
// line. There is no approved invoice copy yet (the billing-launch copy
// review has no result), so the text stays input-bound: the meter's own
@@ -993,7 +1025,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error {
if err != nil {
return r.review("billing_invoice_tax_code_mismatch")
}
- kind := "line-" + line.Meter
+ kind := lineKind(line)
if err := r.perform(ctx, providerOp{
kind: kind, hash: r.requestHash(kind, params),
post: func(ctx context.Context, key string) (string, string, error) {
@@ -1001,7 +1033,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error {
if err != nil {
return "", "", err
}
- if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != r.calc.Currency || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest {
+ if item.Invoice != r.rec.InvoiceID || item.Customer != r.rec.Customer || item.LiveMode != r.b.cfg.LiveMode || item.Amount != line.AmountCents || item.Currency != r.calc.Currency || item.Discountable || item.Metadata["meter"] != line.Meter || item.Metadata["calculation_digest"] != r.rec.Digest || (line.TaxCode != "" && item.Metadata["tax_code"] != line.TaxCode) {
return item.ID, "attached", errBillingInvoiceMismatch
}
return item.ID, "attached", nil
@@ -1013,7 +1045,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error {
}
var matches []billingstripe.InvoiceLine
for _, l := range lines {
- if l.Metadata["meter"] == line.Meter && l.Metadata["calculation_digest"] == r.rec.Digest && l.InvoiceItem != "" {
+ if l.Metadata["meter"] == line.Meter && l.Metadata["calculation_digest"] == r.rec.Digest && l.InvoiceItem != "" && (line.TaxCode == "" || l.Metadata["tax_code"] == line.TaxCode) {
matches = append(matches, l)
}
}
@@ -1088,7 +1120,13 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error {
if err != nil {
return err
}
- if err := r.verifyInvoice(inv, "draft"); err != nil {
+ return r.validateDraftLinesOf(ctx, inv)
+}
+
+// validateDraftLinesOf is validateDraftLines over an invoice already read.
+func (r *billingInvoiceRun) validateDraftLinesOf(ctx context.Context, inv *billingstripe.Invoice) error {
+ var err error
+ if err = r.verifyInvoice(inv, "draft"); err != nil {
if inv != nil && (inv.Status == "open" || inv.Status == "paid") {
// Finalized outside this engine: record the fact and hold.
return r.review("billing_invoice_finalized_externally")
@@ -1103,7 +1141,7 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error {
}
expected := map[string]plans.OverageLine{}
for _, line := range r.calc.Lines {
- op, ok := r.ops["line-"+line.Meter]
+ op, ok := r.ops[lineKind(line)]
if !ok || op.State != billingOpAcked || op.ObjectID == "" {
return errBillingConflict
}
@@ -1113,7 +1151,7 @@ func (r *billingInvoiceRun) validateDraftLines(ctx context.Context) error {
var sum int64
for _, l := range lines {
want, ok := expected[l.InvoiceItem]
- if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != r.calc.Currency || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest {
+ if !ok || seen[l.InvoiceItem] || l.Amount != want.AmountCents || l.Currency != r.calc.Currency || l.Discountable || l.Metadata["meter"] != want.Meter || l.Metadata["calculation_digest"] != r.rec.Digest || (want.TaxCode != "" && l.Metadata["tax_code"] != want.TaxCode) {
return r.review("billing_invoice_lines_mismatch")
}
seen[l.InvoiceItem] = true
@@ -1162,7 +1200,12 @@ func (r *billingInvoiceRun) ackFinalized(_, outcome string) (string, string, str
return "", "", "billing_invoice_mismatch"
}
-// stepFinalizing recovers a finalize whose outcome was not recorded.
+// stepFinalizing recovers a finalize whose outcome was not recorded. An
+// invoice that is still a draft when the run resumes is validated line by
+// line again before the finalize is replayed or renewed: what the draft
+// holds may have changed since the journal entry (a line's metadata code
+// among it), and a finalize must not be sent over lines that would not
+// pass today.
func (r *billingInvoiceRun) stepFinalizing(ctx context.Context) error {
if _, err := r.boundIdentity(); err != nil {
return err
@@ -1170,6 +1213,17 @@ func (r *billingInvoiceRun) stepFinalizing(ctx context.Context) error {
if _, ok := r.ops["finalize"]; !ok {
return errBillingConflict
}
+ inv, err := r.b.client.GetInvoice(ctx, r.rec.InvoiceID)
+ if err != nil {
+ return err
+ }
+ if inv.Status == "draft" {
+ // The invoice as just read, not read again: a finalize landing
+ // between two reads must not look like one made outside.
+ if err := r.validateDraftLinesOf(ctx, inv); err != nil {
+ return err
+ }
+ }
return r.perform(ctx, providerOp{
kind: "finalize", hash: billingRequestHash("finalize", r.rec.InvoiceID), renewable: true,
post: func(ctx context.Context, key string) (string, string, error) {
diff --git a/internal/console/billinginvoice_test.go b/internal/console/billinginvoice_test.go
index 2581f52db..894592816 100644
--- a/internal/console/billinginvoice_test.go
+++ b/internal/console/billinginvoice_test.go
@@ -44,9 +44,20 @@ type fakeInvoiceStripe struct {
dropRequest map[string]bool // do not apply, answer 502 once
refuse map[string]int // definitive HTTP status once
extraLine bool
- listDuplicate bool
- hasMoreLines bool
- seq int
+ // stripItemMetaTax drops metadata[tax_code] from the answer to a POST;
+ // stripLineMetaTax drops it from the listed lines: a provider that did
+ // not keep what it was sent.
+ stripItemMetaTax, stripLineMetaTax bool
+ // swapItemMetaTax and swapLineMetaTax report the other code in
+ // metadata[tax_code]: a provider that kept the wrong one.
+ swapItemMetaTax, swapLineMetaTax bool
+ // openAfterRead makes the next read of a draft invoice answer "draft"
+ // and then finalize it: a finalize sent by a dead process landing
+ // right after a resumed run has read the invoice.
+ openAfterRead bool
+ listDuplicate bool
+ hasMoreLines bool
+ seq int
// The fake is in whatever currency each request named, as the
// provider is. currencies records what each kind of request named.
// The report* fields make it answer in another currency than it was
@@ -79,6 +90,26 @@ type fakeLine struct {
currency string
id, item, meter, digest string
amount int64
+ // taxCode is the item's tax_code as posted; metaTax its
+ // metadata[tax_code], set only by a line that carries its own code.
+ taxCode, metaTax string
+}
+
+// lineMetadata is what the provider reports as a line's metadata: the
+// meter and the digest always, the tax code when the request carried it.
+func (l fakeLine) lineMetadata(strip, swap bool) map[string]string {
+ m := map[string]string{"meter": l.meter, "calculation_digest": l.digest}
+ if l.metaTax != "" && !strip {
+ m["tax_code"] = l.metaTax
+ if swap {
+ if l.metaTax == "txcd_personal" {
+ m["tax_code"] = "txcd_business"
+ } else {
+ m["tax_code"] = "txcd_personal"
+ }
+ }
+ }
+ return m
}
func newFakeInvoiceStripe(sqid string, now func() time.Time) *fakeInvoiceStripe {
@@ -121,7 +152,7 @@ func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[str
continue
}
lines = append(lines, map[string]any{"id": l.id, "invoice_item": l.item, "type": "invoiceitem", "amount": l.amount, "currency": either(f.reportLineCurrency, l.currency), "discountable": false,
- "metadata": map[string]string{"meter": l.meter, "calculation_digest": l.digest}, "period": map[string]int64{"start": 1, "end": 2}})
+ "metadata": l.lineMetadata(f.stripLineMetaTax, f.swapLineMetaTax), "period": map[string]int64{"start": 1, "end": 2}})
}
if f.extraLine && inv.status == "draft" {
lines = append(lines, map[string]any{"id": "il_extra", "invoice_item": "ii_extra", "type": "invoiceitem", "amount": 100, "currency": inv.currency, "discountable": false, "metadata": map[string]string{}})
@@ -190,6 +221,9 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
switch {
case path == "/v1/invoiceitems":
kind = "line-" + r.PostForm.Get("metadata[meter]")
+ if tc := r.PostForm.Get("metadata[tax_code]"); tc != "" {
+ kind += "-" + tc
+ }
case strings.HasSuffix(path, "/finalize"):
kind = "finalize"
case strings.HasSuffix(path, "/pay"):
@@ -228,9 +262,10 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
f.fault(kind, w, func() any {
f.seq++
amount, _ := strconv.ParseInt(r.PostForm.Get("amount"), 10, 64)
- line := fakeLine{currency: r.PostForm.Get("currency"), id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount}
+ line := fakeLine{currency: r.PostForm.Get("currency"), id: "il_" + strconv.Itoa(f.seq), item: "ii_" + strconv.Itoa(f.seq), meter: r.PostForm.Get("metadata[meter]"), digest: r.PostForm.Get("metadata[calculation_digest]"), amount: amount,
+ taxCode: r.PostForm.Get("tax_code"), metaTax: r.PostForm.Get("metadata[tax_code]")}
inv.lines = append(inv.lines, line)
- return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": either(f.reportItemCurrency, line.currency), "discountable": r.PostForm.Get("discountable") != "false", "metadata": map[string]string{"meter": line.meter, "calculation_digest": line.digest}}
+ return map[string]any{"id": line.item, "invoice": inv.id, "customer": inv.customer, "livemode": false, "amount": amount, "currency": either(f.reportItemCurrency, line.currency), "discountable": r.PostForm.Get("discountable") != "false", "metadata": line.lineMetadata(f.stripItemMetaTax, f.swapItemMetaTax)}
}, key)
case strings.HasSuffix(path, "/finalize"):
inv := f.invoices[strings.TrimSuffix(strings.TrimPrefix(path, "/v1/invoices/"), "/finalize")]
@@ -304,6 +339,10 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
return
}
out = f.invoiceJSON(inv, false)
+ if f.openAfterRead && inv.status == "draft" {
+ inv.status = "open"
+ f.openAfterRead = false
+ }
default:
w.WriteHeader(http.StatusNotFound)
return
diff --git a/internal/console/billingtaxlifecycle_test.go b/internal/console/billingtaxlifecycle_test.go
new file mode 100644
index 000000000..166c512b6
--- /dev/null
+++ b/internal/console/billingtaxlifecycle_test.go
@@ -0,0 +1,474 @@
+package console
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// storeTwoCodeMonth freezes, by hand, the calculation a two-code month
+// would carry once decision 16 is taken: the fixture's own month, priced
+// as today, with its one awake line divided between two codes and the
+// calculation's own code empty. It is the seam the lifecycle tests use;
+// freeze itself still holds such a month.
+func storeTwoCodeMonth(t *testing.T, ix *invoiceFixture, personalShare func(total int64) int64) *billingCalculation {
+ t.Helper()
+ calc := ix.expectedCalc(t)
+ if len(calc.Lines) != 1 || calc.Lines[0].Meter != "awake_seconds" {
+ t.Fatalf("the fixture's lines: %+v", calc.Lines)
+ }
+ total := calc.Lines[0].AmountCents
+ personal := personalShare(total)
+ calc.TaxCode = ""
+ calc.Lines = []plans.OverageLine{
+ {Meter: "awake_seconds", AmountCents: total - personal, TaxCode: "txcd_business"},
+ {Meter: "awake_seconds", AmountCents: personal, TaxCode: "txcd_personal"},
+ }
+ if !calc.taxShapeOK() {
+ t.Fatal("the hand-made record is not in the two-code shape")
+ }
+ raw, err := json.Marshal(calc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := ix.st.freezeBillingMonth(ix.acct.ID, invoiceFixtureMonth, raw); err != nil {
+ t.Fatal(err)
+ }
+ return calc
+}
+
+func taxLifecycleFixture(t *testing.T) *invoiceFixture {
+ t.Helper()
+ ix := newInvoiceFixture(t)
+ ix.srv.billing.cfg.TaxCode = ""
+ ix.srv.billing.cfg.TaxCodes = map[string]string{plans.PlanPersonal: "txcd_personal", plans.PlanCommunity: "txcd_personal", plans.PlanBusiness: "txcd_business"}
+ return ix
+}
+
+// Two lines of one meter are two operations, two items, each carrying its
+// own code in tax_code and in metadata, and the draft validates against
+// both; with equal and with unequal amounts.
+func TestATwoCodeMonthInvoicesOneLinePerCode(t *testing.T) {
+ for name, share := range map[string]func(int64) int64{
+ "unequal amounts": func(total int64) int64 { return total * 6 / 10 },
+ "equal amounts": func(total int64) int64 { return total / 2 },
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ calc := storeTwoCodeMonth(t, ix, share)
+ if err := ix.run(t); err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if rec := ix.month(t); rec.State != "complete" {
+ t.Fatalf("month: %+v", rec)
+ }
+ ops := ix.ops(t)
+ for _, line := range calc.Lines {
+ op, ok := ops[lineKind(line)]
+ if !ok || op.State != billingOpAcked {
+ t.Fatalf("operation %s: %+v", lineKind(line), op)
+ }
+ }
+ if _, dup := ops["line-awake_seconds"]; dup {
+ t.Fatal("an operation by meter alone, beside the two by code")
+ }
+ ix.assertMutations(t, map[string]int{"draft": 1, "line-awake_seconds-txcd_business": 1, "line-awake_seconds-txcd_personal": 1, "finalize": 1, "pay": 1})
+ inv := ix.fake.invoices[ix.fake.order[0]]
+ if len(inv.lines) != 2 {
+ t.Fatalf("lines at the provider: %+v", inv.lines)
+ }
+ for _, l := range inv.lines {
+ if l.taxCode != l.metaTax || (l.metaTax != "txcd_business" && l.metaTax != "txcd_personal") {
+ t.Fatalf("a line's codes: %+v", l)
+ }
+ }
+ })
+ }
+}
+
+// A lost answer to one of the two lines is replayed under its key inside
+// the idempotency window and, past it, recovered by the listing, where the
+// line is told from its sibling (same meter, same digest, same amount) by
+// its metadata code; through a paginated listing too; nothing is sent
+// twice.
+func TestATwoCodeLinesLostAnswerIsRecoveredByItsCode(t *testing.T) {
+ for name, tc := range map[string]struct {
+ paged bool
+ later time.Duration
+ lost string
+ }{
+ "inside the window, one page": {false, 2 * time.Hour, "line-awake_seconds-txcd_personal"},
+ "past the window, one page": {false, 25 * time.Hour, "line-awake_seconds-txcd_personal"},
+ "past the window, paged listing": {true, 25 * time.Hour, "line-awake_seconds-txcd_personal"},
+ "past the window, the first line, no sibling yet": {false, 25 * time.Hour, "line-awake_seconds-txcd_business"},
+ "inside the window, the first line, no sibling yet": {false, 2 * time.Hour, "line-awake_seconds-txcd_business"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ ix.fake.hasMoreLines = tc.paged
+ lost := tc.lost
+ ix.fake.loseResponse[lost] = true
+ if err := ix.run(t); !errors.Is(err, errBillingPending) {
+ t.Fatalf("lost line answer: %v", err)
+ }
+ ix.now = ix.now.Add(tc.later)
+ arrivalsBefore, _, _ := ix.fake.counts()
+ if err := ix.run(t); err != nil {
+ t.Fatalf("after the loss: %v", err)
+ }
+ arrivalsAfter, _, _ := ix.fake.counts()
+ if tc.later > billingIdempotencyWindow && arrivalsAfter[lost] != arrivalsBefore[lost] {
+ t.Fatalf("%s resent past the window: %d -> %d", lost, arrivalsBefore[lost], arrivalsAfter[lost])
+ }
+ if tc.later < billingIdempotencyWindow && arrivalsAfter[lost] != arrivalsBefore[lost]+1 {
+ t.Fatalf("%s not replayed under its key inside the window: %d -> %d", lost, arrivalsBefore[lost], arrivalsAfter[lost])
+ }
+ if rec := ix.month(t); rec.State != "complete" {
+ t.Fatalf("month: %+v", rec)
+ }
+ ix.assertMutations(t, map[string]int{"draft": 1, "line-awake_seconds-txcd_business": 1, "line-awake_seconds-txcd_personal": 1, "finalize": 1, "pay": 1})
+ })
+ }
+}
+
+// A provider that reports the other code is caught wherever the code is
+// read: the answer to the POST, the listing a recovery reads, and the
+// draft; equal amounts, so only the code tells the lines apart. The month
+// holds and nothing is finalized.
+func TestAWrongMetadataCodeHoldsTheMonth(t *testing.T) {
+ for name, setup := range map[string]func(*fakeInvoiceStripe){
+ "on the answer to the POST": func(f *fakeInvoiceStripe) { f.swapItemMetaTax = true },
+ "on the listed lines": func(f *fakeInvoiceStripe) { f.swapLineMetaTax = true },
+ "on the listing a recovery reads": func(f *fakeInvoiceStripe) {
+ f.swapLineMetaTax = true
+ f.loseResponse["line-awake_seconds-txcd_business"] = true
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ setup(ix.fake)
+ err := ix.run(t)
+ if errors.Is(err, errBillingPending) {
+ ix.now = ix.now.Add(25 * time.Hour)
+ err = ix.run(t)
+ }
+ if !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("over a wrong code: %v", err)
+ }
+ if rec := ix.month(t); rec.State == "complete" || rec.State == "open" {
+ t.Fatalf("month: %+v", rec)
+ }
+ _, mutations, _ := ix.fake.counts()
+ if mutations["finalize"] != 0 || mutations["pay"] != 0 {
+ t.Fatalf("finalized or paid over a wrong code: %v", mutations)
+ }
+ if name == "on the listing a recovery reads" {
+ // The recovery did not take the wrong-coded item as its own:
+ // the first operation stays unacknowledged, the sibling was
+ // never created, and the review names the mismatch.
+ if op := ix.ops(t)["line-awake_seconds-txcd_business"]; op.State == billingOpAcked {
+ t.Fatalf("the lost line was recovered over a wrong code: %+v", op)
+ }
+ if mutations["line-awake_seconds-txcd_personal"] != 0 {
+ t.Fatalf("the sibling was created after a wrong-coded recovery: %v", mutations)
+ }
+ if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_unrecovered:"+invoiceFixtureMonth {
+ t.Fatalf("reviews: %v; want the unrecovered review alone", reviews)
+ }
+ }
+ })
+ }
+}
+
+// A finalize journaled and then lost (the process died before its request
+// left) is not replayed or renewed over a draft whose lines would not pass
+// today: resumed inside the window or past it, a line whose metadata code
+// went missing or wrong meanwhile holds the month.
+func TestAResumedFinalizeValidatesTheDraftFirst(t *testing.T) {
+ for name, tc := range map[string]struct {
+ later time.Duration
+ corrupt func(*fakeInvoiceStripe)
+ }{
+ "inside the window, a code missing": {2 * time.Hour, func(f *fakeInvoiceStripe) { f.stripLineMetaTax = true }},
+ "past the window, a code missing": {25 * time.Hour, func(f *fakeInvoiceStripe) { f.stripLineMetaTax = true }},
+ "inside the window, a code wrong": {2 * time.Hour, func(f *fakeInvoiceStripe) { f.swapLineMetaTax = true }},
+ "past the window, a code wrong": {25 * time.Hour, func(f *fakeInvoiceStripe) { f.swapLineMetaTax = true }},
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ ix.fake.dropRequest["finalize"] = true
+ if err := ix.run(t); !errors.Is(err, errBillingPending) {
+ t.Fatalf("a finalize that never left: %v", err)
+ }
+ if op := ix.ops(t)["finalize"]; op.State != billingOpSent {
+ t.Fatalf("finalize operation: %+v", op)
+ }
+ delete(ix.fake.dropRequest, "finalize")
+ tc.corrupt(ix.fake)
+ ix.now = ix.now.Add(tc.later)
+ if err := ix.run(t); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("resumed over a corrupted draft: %v", err)
+ }
+ if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_lines_mismatch:"+invoiceFixtureMonth {
+ t.Fatalf("reviews: %v", reviews)
+ }
+ _, mutations, _ := ix.fake.counts()
+ if mutations["finalize"] != 0 || mutations["pay"] != 0 {
+ t.Fatalf("finalized or paid over a corrupted draft: %v", mutations)
+ }
+ })
+ }
+}
+
+// A provider that drops the metadata code is caught at the answer to the
+// POST and again at the draft, equal amounts included: the line cannot be
+// told from its sibling, so the month holds for a person and nothing is
+// finalized.
+func TestAMissingMetadataCodeHoldsTheMonth(t *testing.T) {
+ for name, setup := range map[string]func(*fakeInvoiceStripe){
+ "on the answer to the POST": func(f *fakeInvoiceStripe) { f.stripItemMetaTax = true },
+ "on the listed lines": func(f *fakeInvoiceStripe) { f.stripLineMetaTax = true },
+ } {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ setup(ix.fake)
+ err := ix.run(t)
+ if !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("run: %v", err)
+ }
+ reviews := ix.reviews(t)
+ if len(reviews) != 1 || !strings.HasSuffix(reviews[0], ":"+invoiceFixtureMonth) || !strings.Contains(reviews[0], "mismatch") {
+ t.Fatalf("reviews: %v", reviews)
+ }
+ _, mutations, _ := ix.fake.counts()
+ if mutations["finalize"] != 0 || mutations["pay"] != 0 {
+ t.Fatalf("finalized or paid under a hold: %v", mutations)
+ }
+ })
+ }
+}
+
+// olderReaderStep is the entry of the invoice run as the build before this
+// branch had it (29ea6676, billinginvoice.go:326-337), copied: a terminal
+// record is skipped; the stored calculation is read; a record whose
+// account or month is not this one's, that is not well formed, or whose
+// top-level code is not a tax code is the record-mismatch review, filed
+// by the run's own review method. It is what an older invoice worker
+// does with a record this branch writes.
+func olderReaderStep(ix *invoiceFixture) error {
+ rec, err := ix.st.billingMonth(ix.acct.ID, invoiceFixtureMonth)
+ if err != nil {
+ return err
+ }
+ r := &billingInvoiceRun{s: ix.srv, b: ix.srv.billing, acct: ix.acct, month: invoiceFixtureMonth, now: ix.now, rec: rec}
+ if billingMonthTerminal(r.rec.State) {
+ return nil
+ }
+ if err := json.Unmarshal(rec.Calculation, &r.calc); err != nil {
+ return err
+ }
+ if r.calc.Statement.Account != ix.acct.Sqid || r.calc.Statement.Month != invoiceFixtureMonth || !r.calc.wellFormed() || !strings.HasPrefix(r.calc.TaxCode, "txcd_") || len(r.calc.TaxCode) <= 5 {
+ return r.review("billing_invoice_record_mismatch")
+ }
+ return nil
+}
+
+// An older binary's entry guard, as it was before lines carried codes,
+// refuses a nonterminal two-code record and files the record-mismatch
+// review; that review holds the account for the new reader, the record's
+// bytes stay, the provider is not written; resolving it frees nothing
+// while an unrelated review is open, and the month completes once both
+// are resolved. A terminal record it skips without a review.
+func TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ if err := ix.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ before := ix.month(t)
+ if err := olderReaderStep(ix); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("the older reader on a two-code record: %v", err)
+ }
+ if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_record_mismatch:"+invoiceFixtureMonth {
+ t.Fatalf("reviews after the older reader: %v", reviews)
+ }
+ if err := ix.st.fileBillingInvoiceReview(ix.acct.ID, "sub_hist", "billing_invoice_tax_code_mixed:2026-08", ix.now); err != nil {
+ t.Fatal(err)
+ }
+ if err := ix.run(t); !errors.Is(err, errBillingReview) {
+ t.Fatalf("the new reader under the older one's review: %v", err)
+ }
+ if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 {
+ t.Fatalf("the provider was written under a hold: %v", mutations)
+ }
+ if after := ix.month(t); string(after.Calculation) != string(before.Calculation) || after.Digest != before.Digest {
+ t.Fatal("the frozen record changed")
+ }
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_record_mismatch:" + invoiceFixtureMonth, Occurrence: 1, Actor: "paul@keymail.dev", Note: "older_build"}, ix.now); err != nil {
+ t.Fatal(err)
+ }
+ if err := ix.run(t); !errors.Is(err, errBillingReview) {
+ t.Fatalf("with an unrelated review still open: %v", err)
+ }
+ // The older reader runs again meanwhile and reopens its review.
+ if err := olderReaderStep(ix); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("the older reader again: %v", err)
+ }
+ for _, reason := range []string{"billing_invoice_record_mismatch:" + invoiceFixtureMonth, "billing_invoice_tax_code_mixed:2026-08"} {
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: reason, Occurrence: 2, Actor: "paul@keymail.dev", Note: "decided"}, ix.now); err != nil && !errors.Is(err, errBillingReviewResolved) {
+ // The second review was filed once: its occurrence is 1.
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: reason, Occurrence: 1, Actor: "paul@keymail.dev", Note: "decided"}, ix.now); err != nil {
+ t.Fatalf("resolve %s: %v", reason, err)
+ }
+ }
+ }
+ if err := ix.run(t); err != nil {
+ t.Fatalf("after both reviews: %v", err)
+ }
+ if rec := ix.month(t); rec.State != "complete" {
+ t.Fatalf("month: %+v", rec)
+ }
+ // Terminal now: the older reader skips it and files nothing.
+ if err := olderReaderStep(ix); err != nil || len(ix.reviews(t)) != 0 {
+ t.Fatalf("the older reader on a terminal record: %v %v", err, ix.reviews(t))
+ }
+}
+
+// Through both readers: a mixed history that is waived (the two-code
+// waived shape: no code, no lines) and a two-code record emitting one
+// code only. The older reader refuses both; the new one waives the first
+// and invoices the second.
+func TestWaivedAndOneEmittedCodeRecordsThroughBothReaders(t *testing.T) {
+ t.Run("waived", func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ // A month that used nothing prices as waived; with the code left
+ // empty it is the two-code waived shape (no code, no lines).
+ ix.state.Meters = map[string]float64{}
+ calc := ix.expectedCalc(t)
+ if !calc.waived() || calc.TaxCode != "" || len(calc.Lines) != 0 || !calc.taxShapeOK() || !calc.wellFormed() {
+ t.Fatalf("the waived two-code shape: %+v", calc)
+ }
+ raw, _ := json.Marshal(calc)
+ if _, err := ix.st.freezeBillingMonth(ix.acct.ID, invoiceFixtureMonth, raw); err != nil {
+ t.Fatal(err)
+ }
+ if err := olderReaderStep(ix); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("the older reader: %v", err)
+ }
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_record_mismatch:" + invoiceFixtureMonth, Occurrence: 1, Actor: "paul@keymail.dev", Note: "older_build"}, ix.now); err != nil {
+ if err := ix.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_record_mismatch:" + invoiceFixtureMonth, Occurrence: 1, Actor: "paul@keymail.dev", Note: "older_build"}, ix.now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := ix.run(t); err != nil {
+ t.Fatalf("the new reader: %v", err)
+ }
+ if rec := ix.month(t); rec.State != "waived" {
+ t.Fatalf("month: %+v", rec)
+ }
+ if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 {
+ t.Fatalf("a waived month wrote to the provider: %v", mutations)
+ }
+ })
+ t.Run("one emitted code", func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ if err := ix.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ // A zero share is omitted by apportion: the record the glue would
+ // write has the one line, frozen as any record is.
+ calc := ix.expectedCalc(t)
+ calc.TaxCode = ""
+ calc.Lines = []plans.OverageLine{{Meter: "awake_seconds", AmountCents: calc.Lines[0].AmountCents, TaxCode: "txcd_business"}}
+ if !calc.taxShapeOK() {
+ t.Fatal("the one-emitted-code shape")
+ }
+ raw, _ := json.Marshal(calc)
+ if _, err := ix.st.freezeBillingMonth(ix.acct.ID, invoiceFixtureMonth, raw); err != nil {
+ t.Fatal(err)
+ }
+ if err := olderReaderStep(ix); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("the older reader: %v", err)
+ }
+ if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_record_mismatch:" + invoiceFixtureMonth, Occurrence: 1, Actor: "paul@keymail.dev", Note: "older_build"}, ix.now); err != nil {
+ t.Fatal(err)
+ }
+ if err := ix.run(t); err != nil {
+ t.Fatalf("the new reader: %v", err)
+ }
+ if rec := ix.month(t); rec.State != "complete" {
+ t.Fatalf("month: %+v", rec)
+ }
+ ix.assertMutations(t, map[string]int{"draft": 1, "line-awake_seconds-txcd_business": 1, "finalize": 1, "pay": 1})
+ })
+}
+
+// The review for a charge with no attributed usage is filed by the run,
+// through the preparer seam, without activation: the month is not frozen,
+// nothing reaches the provider, and the hold names the mismatch. (The
+// attribution that produces the error is the activation change's to test
+// through freeze.)
+func TestAnUnattributedChargeHoldsTheMonthForAPerson(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ ix.srv.billing.invoice.prepare = func(context.Context, release.Store, usage.Statement, []plans.Assignment, time.Time) (*billingCalculation, error) {
+ return nil, errBillingTaxApportion
+ }
+ if err := ix.run(t); !errors.Is(err, errBillingInvoiceHeld) {
+ t.Fatalf("run: %v", err)
+ }
+ if reviews := ix.reviews(t); len(reviews) != 1 || reviews[0] != "billing_invoice_tax_code_mismatch:"+invoiceFixtureMonth {
+ t.Fatalf("reviews: %v", reviews)
+ }
+ if _, err := ix.st.billingMonth(ix.acct.ID, invoiceFixtureMonth); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("a record was frozen: %v", err)
+ }
+ if _, mutations, _ := ix.fake.counts(); len(mutations) != 0 {
+ t.Fatalf("the provider was written: %v", mutations)
+ }
+}
+
+// The resumed finalize validates the invoice it read, not a second
+// reading: a finalize from the dead process that lands between the read
+// and the validation must not look like one made outside this engine. The
+// draft is sound, the read says draft, the invoice is open by the time
+// anything else looks; the month completes without a
+// billing_invoice_finalized_externally review.
+func TestAResumedFinalizeReadsTheInvoiceOnce(t *testing.T) {
+ for name, later := range map[string]time.Duration{"inside the window": 2 * time.Hour, "past the window": 25 * time.Hour} {
+ t.Run(name, func(t *testing.T) {
+ ix := taxLifecycleFixture(t)
+ storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
+ ix.fake.dropRequest["finalize"] = true
+ if err := ix.run(t); !errors.Is(err, errBillingPending) {
+ t.Fatalf("a finalize that never left: %v", err)
+ }
+ delete(ix.fake.dropRequest, "finalize")
+ ix.fake.openAfterRead = true
+ ix.now = ix.now.Add(later)
+ if err := ix.run(t); err != nil {
+ t.Fatalf("resumed as the dead process's finalize landed: %v", err)
+ }
+ if reviews := ix.reviews(t); len(reviews) != 0 {
+ t.Fatalf("reviews: %v", reviews)
+ }
+ if rec := ix.month(t); rec.State != "complete" && rec.State != "open" {
+ t.Fatalf("month: %+v", rec)
+ }
+ })
+ }
+}
diff --git a/internal/console/billingtaxshape_fixtures_test.go b/internal/console/billingtaxshape_fixtures_test.go
new file mode 100644
index 000000000..a5173fd23
--- /dev/null
+++ b/internal/console/billingtaxshape_fixtures_test.go
@@ -0,0 +1,209 @@
+package console
+
+// Today's pricing output, pinned before per-line tax codes exist (design
+// docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md, plan
+// Task 0). Each entry is the frozen calculation a month prices to, its
+// digest, and the request hash and operation kind of each of its lines,
+// recorded as literal text from the unchanged code at 29ea6676 and never
+// regenerated: a change that moves one byte of a one-code month, or one
+// request hash, fails here.
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+type taxShapeLine struct{ kind, hash string }
+
+type taxShapeFixture struct {
+ json, digest string
+ lines []taxShapeLine
+}
+
+var taxShapeFixtures = map[string]taxShapeFixture{
+ "legacy_one_plan": {json: `{"statement_object":{"key":""},"v":1,"arithmetic":"base_units_floor_v1","currency":"eur","statement":{"v":1,"account":"acme","month":"2026-09","meters":{"durability_writes_1k":1000},"covered_days":0,"days_in_month":30,"updated_at":"2026-10-04T00:00:00Z","closed":true},"statement_sha256":"714145745690a96ba8f7a64310bf336169c705c3bf9a539072691fa98a63bea3","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-09-01T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-09-01T00:00:00Z"}]},"usage":{"awake_seconds":0,"storage_byte_months":0,"transfer_bytes":0,"writes":1000000},"overage":{"AwakeMicroEUR":0,"StorageMicroEUR":0,"TransferMicroEUR":0,"WritesMicroEUR":23000000,"TotalMicroEUR":23000000},"invoice":{"SubscriptionMicroEUR":0,"OverageMicroEUR":23000000,"TotalMicroEUR":23000000,"TotalCents":2300,"Waived":false},"lines":[{"Meter":"writes","AmountCents":2300}],"tax_code":""}`, digest: "4986f5c1b3526718c62aaf49f54fd76815630008078a1a9f4a193d44be41a626", lines: []taxShapeLine{{"line-writes", "d02c8fb1a3117fde66754fe63eb093d62cc00ce0b04d10ed36b9581eaf6fa1f5"}}},
+ "legacy_two_codes": {json: `{"statement_object":{"key":""},"v":1,"arithmetic":"base_units_floor_v1","currency":"eur","statement":{"v":1,"account":"acme","month":"2026-09","meters":{"store_egress_gb":20},"covered_days":0,"days_in_month":30,"updated_at":"2026-10-04T00:00:00Z","closed":true},"statement_sha256":"fdb612ab4f304cb3fa0359fd7a94eb2ed54cfcea128ffdd202245585ae0accfe","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":3406800,"storage_byte_months":27600000000,"transfer_bytes":16933333334,"writes":160000},"assignments":[{"effective_at":"2026-09-01T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-09-01T00:00:00Z"},{"effective_at":"2026-09-15T00:00:00Z","revision":2,"terms_version":"2026-09-07-v1","plan":"business","band":"A","quantity":10,"house":false,"status":"active","paid_through":"2027-09-01T00:00:00Z"}]},"usage":{"awake_seconds":0,"storage_byte_months":0,"transfer_bytes":20000000000,"writes":0},"overage":{"AwakeMicroEUR":0,"StorageMicroEUR":0,"TransferMicroEUR":1269599,"WritesMicroEUR":0,"TotalMicroEUR":1269599},"invoice":{"SubscriptionMicroEUR":0,"OverageMicroEUR":1269599,"TotalMicroEUR":1269599,"TotalCents":127,"Waived":false},"lines":[{"Meter":"transfer_bytes","AmountCents":127}],"tax_code":""}`, digest: "acd02a8f85bdb9c8d750aad2a1091b74e87565eda719ac8dbcb90b3fa10d3997", lines: []taxShapeLine{{"line-transfer_bytes", "b7200c5384f931acd57fe376ac761ce422de95c8bc4a68f3f353b7c2982aa1e9"}}},
+ "book_one_plan_no_days": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":1098},{"Meter":"storage_byte_months","AmountCents":483}],"tax_code":"","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":10980000,"storage_micro":4833600,"transfer_micro":0,"writes_micro":0,"total_micro":15813600},"total":{"currency":"usd","total_micro":15813600,"total_minor":1581,"waived":false}}`, digest: "1fc712ad08a79488c2d449bd933b4afe321b1848da6cc4b8fdcb4f43b2c4d4d2", lines: []taxShapeLine{{"line-awake_seconds", "2a5f582df8e30c4e609b2281891eeccc8b90cd8df0bc9fc50725d3de8fe60f7e"}, {"line-storage_byte_months", "45733fc5ce305ec3082ce7be2c367d116cef32a176544693fc7656528f065f92"}}},
+ "book_two_plans_one_code": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":469200,"storage_byte_months":3600000000,"transfer_bytes":3066666667,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"},{"effective_at":"2026-09-15T00:00:00Z","revision":2,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":10,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":1044},{"Meter":"storage_byte_months","AmountCents":463}],"tax_code":"","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":10436000,"storage_micro":4630080,"transfer_micro":0,"writes_micro":0,"total_micro":15066080},"total":{"currency":"usd","total_micro":15066080,"total_minor":1507,"waived":false}}`, digest: "62fa092d9bdfe7f4480349fda58c981a768c51cbb5fa61303994ecb446689c80", lines: []taxShapeLine{{"line-awake_seconds", "974eb9715ad4b0363243771a04646cc96bfcbd348e4152edd8d106690816293b"}, {"line-storage_byte_months", "f977333ee32b78812d6c8e60565e7271e0d37fc3378735ef78d2c9e04563595e"}}},
+ "book_quantity_change": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":677280,"storage_byte_months":5533333334,"transfer_bytes":4426666667,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":10,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"},{"effective_at":"2026-09-15T00:00:00Z","revision":2,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":12,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":974},{"Meter":"storage_byte_months","AmountCents":439}],"tax_code":"","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":9742400,"storage_micro":4384159,"transfer_micro":0,"writes_micro":0,"total_micro":14126559},"total":{"currency":"usd","total_micro":14126559,"total_minor":1413,"waived":false}}`, digest: "1ca5a47697007e49fe53de3cd75dc1c3ca873d30fde7c76bad91d4254104ddd8", lines: []taxShapeLine{{"line-awake_seconds", "e6f1730fdc7521f9d6cd890dbf00482846bcfbd29814b2cfaf6776c19b01ca4f"}, {"line-storage_byte_months", "88e4580a0cec931b85cd0a7b87e7a832430af4d17073610d5a66c52144ab2e2a"}}},
+ "book_partial": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_whole_paid_days_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":300},"covered_days":0,"days":{"2026-09-01":{"instance_hours":10},"2026-09-02":{"instance_hours":10},"2026-09-03":{"instance_hours":10},"2026-09-04":{"instance_hours":10},"2026-09-05":{"instance_hours":10},"2026-09-06":{"instance_hours":10},"2026-09-07":{"instance_hours":10},"2026-09-08":{"instance_hours":10},"2026-09-09":{"instance_hours":10},"2026-09-10":{"instance_hours":10},"2026-09-11":{"instance_hours":10},"2026-09-12":{"instance_hours":10},"2026-09-13":{"instance_hours":10},"2026-09-14":{"instance_hours":10},"2026-09-15":{"instance_hours":10},"2026-09-16":{"instance_hours":10},"2026-09-17":{"instance_hours":10},"2026-09-18":{"instance_hours":10},"2026-09-19":{"instance_hours":10},"2026-09-20":{"instance_hours":10},"2026-09-21":{"instance_hours":10},"2026-09-22":{"instance_hours":10},"2026-09-23":{"instance_hours":10},"2026-09-24":{"instance_hours":10},"2026-09-25":{"instance_hours":10},"2026-09-26":{"instance_hours":10},"2026-09-27":{"instance_hours":10},"2026-09-28":{"instance_hours":10},"2026-09-29":{"instance_hours":10},"2026-09-30":{"instance_hours":10}},"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"ac46545c8f8032543ead4033f16c4b2d80f855b4b2a6e1fe8b7ffe0c753cc956","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":214200,"storage_byte_months":1400000000,"transfer_bytes":1400000000,"writes":0},"assignments":[{"effective_at":"2026-09-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"partial":true,"covered":[{"from":"2026-09-10T00:00:00Z","to":"2026-10-01T00:00:00Z"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":756000,"storage_byte_months":0,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":181}],"tax_code":"","counted_days":["2026-09-10","2026-09-11","2026-09-12","2026-09-13","2026-09-14","2026-09-15","2026-09-16","2026-09-17","2026-09-18","2026-09-19","2026-09-20","2026-09-21","2026-09-22","2026-09-23","2026-09-24","2026-09-25","2026-09-26","2026-09-27","2026-09-28","2026-09-29","2026-09-30"],"price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":1806000,"storage_micro":0,"transfer_micro":0,"writes_micro":0,"total_micro":1806000},"total":{"currency":"usd","total_micro":1806000,"total_minor":181,"waived":false}}`, digest: "4a25f2e4805ff9df143aa4c0a460fe0dbe28ecb9b800ce01fbe086984c820686", lines: []taxShapeLine{{"line-awake_seconds", "4fcc5f950368ea34aef655ad954d9e26dc608d64ca42e576e1b8cce1794fe1d0"}}},
+ "book_waived": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"84d005a2404d9e73775118aa37f4e19dbb9bf795799d197f0c36187447479c45","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":0,"storage_byte_months":0,"transfer_bytes":0,"writes":0},"lines":null,"tax_code":"","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":0,"storage_micro":0,"transfer_micro":0,"writes_micro":0,"total_micro":0},"total":{"currency":"usd","total_micro":0,"total_minor":0,"waived":true}}`, digest: "dd9a0f359bd1b0d2852909f90ee333112a7f546b9d6d0462ce7b06a453a96acd", lines: []taxShapeLine{}},
+}
+
+// taxShapeInputs are the statements and histories the fixtures were
+// recorded from; the same construction as the generator that recorded
+// them.
+func taxShapeInputs() map[string]struct {
+ st usage.Statement
+ h []plans.Assignment
+} {
+ type cs = struct {
+ st usage.Statement
+ h []plans.Assignment
+ }
+ terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: plans.USD}
+ sep15 := time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC)
+ out := map[string]cs{}
+ st, h := calculationFixture(plans.PlanPersonal, 1)
+ st.Meters[usage.MeterDurabilityWrites1k] = 1000
+ out["legacy_one_plan"] = cs{st, h}
+ st2, h2 := calculationFixture(plans.PlanPersonal, 1)
+ n := h2[0]
+ n.Revision, n.EffectiveAt, n.Plan, n.Quantity = 2, sep15, plans.PlanBusiness, 10
+ st2.Meters[usage.MeterStoreEgressGB] = 20
+ out["legacy_two_codes"] = cs{st2, append(h2, n)}
+ book := func() usage.Statement {
+ return usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 1000, usage.MeterStorageGBMonth: 40}, DaysInMonth: 30, Closed: true}
+ }
+ out["book_one_plan_no_days"] = cs{book(), monthHistory(terms, false)}
+ hh := monthHistory(terms, false)
+ m := hh[0]
+ m.Revision, m.EffectiveAt, m.Plan, m.Quantity = 2, sep15, plans.PlanCommunity, 10
+ out["book_two_plans_one_code"] = cs{book(), append(hh, m)}
+ hq := monthHistory(terms, false)
+ hq[0].Plan, hq[0].Quantity = plans.PlanCommunity, 10
+ q := hq[0]
+ q.Revision, q.EffectiveAt, q.Quantity = 2, sep15, 12
+ out["book_quantity_change"] = cs{book(), append(hq, q)}
+ pst := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 300}, Days: map[string]map[string]float64{}, DaysInMonth: 30, Closed: true}
+ for d := 1; d <= 30; d++ {
+ pst.Days[fmt.Sprintf("2026-09-%02d", d)] = map[string]float64{usage.MeterInstanceHours: 10}
+ }
+ ph := monthHistory(terms, false)
+ ph[0].EffectiveAt = time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
+ out["book_partial"] = cs{pst, ph}
+ w := book()
+ w.Meters = map[string]float64{}
+ out["book_waived"] = cs{w, monthHistory(terms, false)}
+ return out
+}
+
+// The code a fixture's first plan maps to, as the approved configuration
+// has it; the fixtures' request hashes were recorded with these.
+var taxShapeCodes = map[string]string{plans.PlanPersonal: "txcd_10102001", plans.PlanCommunity: "txcd_10102001", plans.PlanBusiness: "txcd_10102000"}
+
+func TestTodaysCalculationBytesArePinned(t *testing.T) {
+ start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ inputs := taxShapeInputs()
+ if len(inputs) != len(taxShapeFixtures) {
+ t.Fatalf("%d inputs, %d fixtures", len(inputs), len(taxShapeFixtures))
+ }
+ for name, want := range taxShapeFixtures {
+ in, ok := inputs[name]
+ if !ok {
+ t.Fatalf("%s: no input", name)
+ }
+ calc, err := priceBillingCalculation(in.st, in.h)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ raw, err := json.Marshal(calc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != want.json {
+ t.Errorf("%s: the calculation's bytes changed:\n got %s\nwant %s", name, raw, want.json)
+ }
+ sum := sha256.Sum256(raw)
+ if digest := hex.EncodeToString(sum[:]); digest != want.digest {
+ t.Errorf("%s: digest %s, want %s", name, digest, want.digest)
+ }
+ if len(calc.Lines) != len(want.lines) {
+ t.Fatalf("%s: %d lines, want %d", name, len(calc.Lines), len(want.lines))
+ }
+ code := taxShapeCodes[in.h[0].Plan]
+ for i, line := range calc.Lines {
+ p := billingstripe.InvoiceItemParams{Customer: "cus_fixture", Invoice: "in_fixture", Meter: line.Meter, TaxCode: code, Description: billingInvoiceLineDescription(line.Meter, "2026-09"), CalculationDigest: want.digest, AmountCents: line.AmountCents, PeriodStart: start.Unix(), PeriodEnd: start.AddDate(0, 1, 0).Unix(), Currency: calc.Currency}
+ kind := "line-" + line.Meter
+ if kind != want.lines[i].kind || billingRequestHash(kind, p) != want.lines[i].hash {
+ t.Errorf("%s line %d: kind %s hash %s, want %s %s", name, i, kind, billingRequestHash(kind, p), want.lines[i].kind, want.lines[i].hash)
+ }
+ }
+ }
+}
+
+// The frozen record and the engine's own requests, as the unchanged code
+// at 29ea6676 wrote them: the calculation after freeze set its code, its
+// digest, the draft request's hash and each line's request hash through
+// requestHash (which strips a legacy calculation's currency), recorded as
+// literal text there and compared here through the engine's functions
+// (lineParams, lineKind, draftParams, requestHash), not hand-built
+// structs. legacy_two_codes has no frozen record: freeze holds it.
+type frozenFixture struct {
+ json, digest, draft string
+ lines []taxShapeLine
+}
+
+var frozenFixtures = map[string]frozenFixture{
+ "book_one_plan_no_days": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":1098},{"Meter":"storage_byte_months","AmountCents":483}],"tax_code":"txcd_10102001","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":10980000,"storage_micro":4833600,"transfer_micro":0,"writes_micro":0,"total_micro":15813600},"total":{"currency":"usd","total_micro":15813600,"total_minor":1581,"waived":false}}`, digest: "c89fdd058dd3f87635827b2b430f68c3bb81aeb8532a2e45707849661c500e93", draft: "0bebee0c5dfb4933a0740ab0f5bc37761df5cafb03ea87df9467c71e8d2eb494", lines: []taxShapeLine{{"line-awake_seconds", "cca284e35ce2d4404d411f43d649d7e7dbf19ef7c878c794117b04e6964ea1c5"}, {"line-storage_byte_months", "64e4069ac15e4cf414ff13c40c30ae2aca62da9bf78afe64e19924806624f9c6"}}},
+ "book_two_plans_one_code": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":469200,"storage_byte_months":3600000000,"transfer_bytes":3066666667,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"},{"effective_at":"2026-09-15T00:00:00Z","revision":2,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":10,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":1044},{"Meter":"storage_byte_months","AmountCents":463}],"tax_code":"txcd_10102001","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":10436000,"storage_micro":4630080,"transfer_micro":0,"writes_micro":0,"total_micro":15066080},"total":{"currency":"usd","total_micro":15066080,"total_minor":1507,"waived":false}}`, digest: "0e375fc96fd20011c2bbca8882b03a5d5bd9914e3eb4baa348f3fa3728d9e915", draft: "e6d135c4f5fcf838b91d19baa652fdc26c73379bf97aa775015feac7f8cdfaaf", lines: []taxShapeLine{{"line-awake_seconds", "647defc00e6a6ad9d084504eff19d6cb56de925a45581b88ee440ca73e06c8b8"}, {"line-storage_byte_months", "eff8b1923cd917dc30033bacf459128cb5aef62f241c2e72ffd563c61630c524"}}},
+ "book_quantity_change": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":1000,"storage_gb_month":40},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"83d2a01200b4377ced8c93f2fdd398de7ae238c02074b2ed2642cd466a35b260","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":677280,"storage_byte_months":5533333334,"transfer_bytes":4426666667,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":10,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"},{"effective_at":"2026-09-15T00:00:00Z","revision":2,"terms_version":"2026-09-07-v1","plan":"community","band":"A","quantity":12,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":3600000,"storage_byte_months":40000000000,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":974},{"Meter":"storage_byte_months","AmountCents":439}],"tax_code":"txcd_10102001","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":9742400,"storage_micro":4384159,"transfer_micro":0,"writes_micro":0,"total_micro":14126559},"total":{"currency":"usd","total_micro":14126559,"total_minor":1413,"waived":false}}`, digest: "f2a1c19589868bb89e9adefdfd8e67d8d9d336b604f9d5e855a62d7103e59fd0", draft: "df8ee03da9a95c755b7869a5b8899b81bae340e2b7b5c83e15541a5fad754134", lines: []taxShapeLine{{"line-awake_seconds", "958a0bb9ca325a1b202b4a2dbf4356b253ed55a74e30e4fb23a9017d27242dd6"}, {"line-storage_byte_months", "722b01f51918bb4ed11a6651d70bed0a12d23003157f12534e671f3a51a12744"}}},
+ "book_partial": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_whole_paid_days_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{"instance_hours":300},"covered_days":0,"days":{"2026-09-01":{"instance_hours":10},"2026-09-02":{"instance_hours":10},"2026-09-03":{"instance_hours":10},"2026-09-04":{"instance_hours":10},"2026-09-05":{"instance_hours":10},"2026-09-06":{"instance_hours":10},"2026-09-07":{"instance_hours":10},"2026-09-08":{"instance_hours":10},"2026-09-09":{"instance_hours":10},"2026-09-10":{"instance_hours":10},"2026-09-11":{"instance_hours":10},"2026-09-12":{"instance_hours":10},"2026-09-13":{"instance_hours":10},"2026-09-14":{"instance_hours":10},"2026-09-15":{"instance_hours":10},"2026-09-16":{"instance_hours":10},"2026-09-17":{"instance_hours":10},"2026-09-18":{"instance_hours":10},"2026-09-19":{"instance_hours":10},"2026-09-20":{"instance_hours":10},"2026-09-21":{"instance_hours":10},"2026-09-22":{"instance_hours":10},"2026-09-23":{"instance_hours":10},"2026-09-24":{"instance_hours":10},"2026-09-25":{"instance_hours":10},"2026-09-26":{"instance_hours":10},"2026-09-27":{"instance_hours":10},"2026-09-28":{"instance_hours":10},"2026-09-29":{"instance_hours":10},"2026-09-30":{"instance_hours":10}},"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"ac46545c8f8032543ead4033f16c4b2d80f855b4b2a6e1fe8b7ffe0c753cc956","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":214200,"storage_byte_months":1400000000,"transfer_bytes":1400000000,"writes":0},"assignments":[{"effective_at":"2026-09-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"partial":true,"covered":[{"from":"2026-09-10T00:00:00Z","to":"2026-10-01T00:00:00Z"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":756000,"storage_byte_months":0,"transfer_bytes":0,"writes":0},"lines":[{"Meter":"awake_seconds","AmountCents":181}],"tax_code":"txcd_10102001","counted_days":["2026-09-10","2026-09-11","2026-09-12","2026-09-13","2026-09-14","2026-09-15","2026-09-16","2026-09-17","2026-09-18","2026-09-19","2026-09-20","2026-09-21","2026-09-22","2026-09-23","2026-09-24","2026-09-25","2026-09-26","2026-09-27","2026-09-28","2026-09-29","2026-09-30"],"price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":1806000,"storage_micro":0,"transfer_micro":0,"writes_micro":0,"total_micro":1806000},"total":{"currency":"usd","total_micro":1806000,"total_minor":181,"waived":false}}`, digest: "e6c8cb159995d13ab1356e74dbb2518a1b1be04ac849a0e9c8fe786601bfad52", draft: "4a50c2faf8546244d5df5b1329e0d5f99a3565c84e4fb15bc3ca930763dc0fc7", lines: []taxShapeLine{{"line-awake_seconds", "847b759419a8abe21d475a46c9916a92644ea07af3094d4dfa8ed5b0f532868e"}}},
+ "book_waived": {json: `{"statement_object":{"key":""},"v":2,"arithmetic":"base_units_floor_v1","currency":"usd","statement":{"v":1,"account":"bab","month":"2026-09","meters":{},"covered_days":0,"days_in_month":30,"updated_at":"0001-01-01T00:00:00Z","closed":true},"statement_sha256":"84d005a2404d9e73775118aa37f4e19dbb9bf795799d197f0c36187447479c45","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-08-10T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-08-10T00:00:00Z","price_book":"2026-10-01-draft1","currency":"usd"}],"price_book":"2026-10-01-draft1","currency":"usd"},"usage":{"awake_seconds":0,"storage_byte_months":0,"transfer_bytes":0,"writes":0},"lines":null,"tax_code":"txcd_10102001","price_book":"2026-10-01-draft1","charges":{"currency":"usd","awake_micro":0,"storage_micro":0,"transfer_micro":0,"writes_micro":0,"total_micro":0},"total":{"currency":"usd","total_micro":0,"total_minor":0,"waived":true}}`, digest: "a1c8ccd7b7c422df39495c4b38f1abbebd29d46b9f21386c126386885ac915c4", draft: "313e05c1113517034bf3e1e1c15a75712343146c90b849308f1ba20ff1d5c4d7", lines: []taxShapeLine{}},
+ "legacy_one_plan": {json: `{"statement_object":{"key":""},"v":1,"arithmetic":"base_units_floor_v1","currency":"eur","statement":{"v":1,"account":"acme","month":"2026-09","meters":{"durability_writes_1k":1000},"covered_days":0,"days_in_month":30,"updated_at":"2026-10-04T00:00:00Z","closed":true},"statement_sha256":"714145745690a96ba8f7a64310bf336169c705c3bf9a539072691fa98a63bea3","allowance":{"month":"2026-09","terms_version":"2026-09-07-v1","house":false,"resources":{"awake_seconds":306000,"storage_byte_months":2000000000,"transfer_bytes":2000000000,"writes":0},"assignments":[{"effective_at":"2026-09-01T00:00:00Z","revision":1,"terms_version":"2026-09-07-v1","plan":"personal","band":"A","quantity":1,"house":false,"status":"active","paid_through":"2027-09-01T00:00:00Z"}]},"usage":{"awake_seconds":0,"storage_byte_months":0,"transfer_bytes":0,"writes":1000000},"overage":{"AwakeMicroEUR":0,"StorageMicroEUR":0,"TransferMicroEUR":0,"WritesMicroEUR":23000000,"TotalMicroEUR":23000000},"invoice":{"SubscriptionMicroEUR":0,"OverageMicroEUR":23000000,"TotalMicroEUR":23000000,"TotalCents":2300,"Waived":false},"lines":[{"Meter":"writes","AmountCents":2300}],"tax_code":"txcd_10102001"}`, digest: "3491b0b5dae22513adf37fc74c8b14d863c4013d285b3dac9cdf94f9e59840df", draft: "d52115726228deaa80a37b1cdaa7a2d2f3235ac825f2081e7b133091f00577fe", lines: []taxShapeLine{{"line-writes", "faaf4799bcd7c24ebc1e945c10f6ca9076db1b8e8c44896dd6cb401d7ac402c1"}}},
+}
+
+func TestTodaysFrozenRecordsAndRequestsArePinned(t *testing.T) {
+ start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ inputs := taxShapeInputs()
+ if len(frozenFixtures) != len(inputs)-1 {
+ t.Fatalf("%d frozen fixtures for %d inputs", len(frozenFixtures), len(inputs))
+ }
+ for name, want := range frozenFixtures {
+ in := inputs[name]
+ calc, err := priceBillingCalculation(in.st, in.h)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ // As freeze does: the one code the spans map to, then the bytes.
+ codes := make([]string, 0, len(calc.Allowance.Assignments))
+ for _, a := range calc.Allowance.Assignments {
+ codes = append(codes, taxShapeCodes[a.Plan])
+ }
+ code, err := taxCodesForLines(codes)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ calc.TaxCode = code
+ raw, err := json.Marshal(calc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != want.json {
+ t.Errorf("%s: the frozen bytes changed:\n got %s\nwant %s", name, raw, want.json)
+ }
+ sum := sha256.Sum256(raw)
+ digest := hex.EncodeToString(sum[:])
+ if digest != want.digest {
+ t.Errorf("%s: digest %s, want %s", name, digest, want.digest)
+ }
+ var frozen billingCalculation
+ if err := json.Unmarshal(raw, &frozen); err != nil || !frozen.wellFormed() || !frozen.taxShapeOK() {
+ t.Errorf("%s: the frozen record read back: %v shape %v", name, err, frozen.taxShapeOK())
+ }
+ r := &billingInvoiceRun{acct: &Account{Sqid: in.st.Account}, month: "2026-09", start: start, end: start.AddDate(0, 1, 0), rec: billingMonthRecord{Customer: "cus_fixture", InvoiceID: "in_fixture", Subscription: "sub_fixture", Digest: digest}, calc: frozen}
+ if got := r.requestHash("draft", r.draftParams()); got != want.draft {
+ t.Errorf("%s: the draft request's hash %s, want %s", name, got, want.draft)
+ }
+ if len(frozen.Lines) != len(want.lines) {
+ t.Fatalf("%s: %d lines, want %d", name, len(frozen.Lines), len(want.lines))
+ }
+ for i, line := range frozen.Lines {
+ p, err := r.lineParams(line)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if kind := lineKind(line); kind != want.lines[i].kind || r.requestHash(kind, p) != want.lines[i].hash || p.TaxCodeMetadata != "" {
+ t.Errorf("%s line %d: kind %s hash %s metadata %q, want %s %s and no metadata code", name, i, kind, r.requestHash(kind, p), p.TaxCodeMetadata, want.lines[i].kind, want.lines[i].hash)
+ }
+ }
+ }
+}
diff --git a/internal/console/billingtaxshape_test.go b/internal/console/billingtaxshape_test.go
new file mode 100644
index 000000000..f98907a63
--- /dev/null
+++ b/internal/console/billingtaxshape_test.go
@@ -0,0 +1,78 @@
+package console
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// A calculation carries its tax codes in one of two shapes: one code on
+// the calculation and none on any line, or none on the calculation and one
+// on every line. Anything else is a record to look at, not to invoice.
+func TestACalculationCarriesItsTaxCodesInOneOfTwoShapes(t *testing.T) {
+ waived := func(c *billingCalculation) *billingCalculation { c.Total = &plans.Invoice{Waived: true}; return c }
+ v2 := func(top string, lines ...plans.OverageLine) *billingCalculation {
+ return &billingCalculation{V: 2, TaxCode: top, Lines: lines, Total: &plans.Invoice{TotalMinor: 1}}
+ }
+ for name, tc := range map[string]struct {
+ calc *billingCalculation
+ ok bool
+ }{
+ "one code, untagged lines": {v2("txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5}), true},
+ "one code, waived, no lines": {waived(v2("txcd_10102001")), true},
+ "two codes, every line tagged": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}, plans.OverageLine{Meter: "writes", AmountCents: 3, TaxCode: "txcd_10102000"}), true},
+ "two-code shape with one code emitted": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}), true},
+ "two-code shape, waived, no lines": {waived(v2("")), true},
+ "waived with lines": {waived(v2("txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5})), false},
+ "a code at both levels": {v2("txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}), false},
+ "a code at neither level": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5}), false},
+ "two codes with one line lacking its code": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102001"}, plans.OverageLine{Meter: "awake_seconds", AmountCents: 3}), false},
+ "a malformed code on the calculation": {v2("txcd_", plans.OverageLine{Meter: "writes", AmountCents: 5}), false},
+ "a malformed code on a line": {v2("", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "10102001"}), false},
+ "not waived and no lines, one code": {v2("txcd_10102001"), false},
+ "not waived and no lines, no code": {v2(""), false},
+ } {
+ if got := tc.calc.taxShapeOK(); got != tc.ok {
+ t.Errorf("%s: taxShapeOK %v, want %v", name, got, tc.ok)
+ }
+ }
+}
+
+// The request for a line takes the line's own code when it carries one,
+// and then names it in metadata too; else the calculation's; never both,
+// never neither. A request without the metadata field encodes exactly as
+// before the field existed, so an old journal's hashes still match.
+func TestALinesRequestCarriesItsOwnCodeOrTheCalculationsNotBoth(t *testing.T) {
+ run := func(top string) *billingInvoiceRun {
+ return &billingInvoiceRun{month: "2026-09", rec: billingMonthRecord{Customer: "cus_x", InvoiceID: "in_x", Digest: "d"}, calc: billingCalculation{TaxCode: top, Currency: "usd"}}
+ }
+ for name, tc := range map[string]struct {
+ top string
+ line plans.OverageLine
+ code string
+ meta string
+ err bool
+ }{
+ "the calculation's code": {"txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5}, "txcd_10102001", "", false},
+ "the line's own code": {"", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}, "txcd_10102000", "txcd_10102000", false},
+ "a code at both levels": {"txcd_10102001", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_10102000"}, "", "", true},
+ "a code at neither": {"", plans.OverageLine{Meter: "writes", AmountCents: 5}, "", "", true},
+ "a malformed line code": {"", plans.OverageLine{Meter: "writes", AmountCents: 5, TaxCode: "txcd_"}, "", "", true},
+ "a malformed top-level code": {"txcd", plans.OverageLine{Meter: "writes", AmountCents: 5}, "", "", true},
+ } {
+ p, err := run(tc.top).lineParams(tc.line)
+ if (err != nil) != tc.err || p.TaxCode != tc.code || p.TaxCodeMetadata != tc.meta {
+ t.Errorf("%s: %+v %v", name, p, err)
+ }
+ }
+ raw, _ := json.Marshal(billingstripe.InvoiceItemParams{Customer: "cus_x", TaxCode: "txcd_10102001"})
+ if strings.Contains(string(raw), "TaxCodeMetadata") {
+ t.Fatalf("a request without the metadata code encodes it: %s", raw)
+ }
+ if lineKind(plans.OverageLine{Meter: "writes"}) != "line-writes" || lineKind(plans.OverageLine{Meter: "writes", TaxCode: "txcd_10102000"}) != "line-writes-txcd_10102000" {
+ t.Fatal("the operation kinds")
+ }
+}
diff --git a/internal/console/billingtaxsplit.go b/internal/console/billingtaxsplit.go
new file mode 100644
index 000000000..90080214b
--- /dev/null
+++ b/internal/console/billingtaxsplit.go
@@ -0,0 +1,360 @@
+package console
+
+import (
+ "errors"
+ "math/big"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+// Per-line tax codes for a month in which the plan changed
+// (docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md).
+//
+// A month whose paid spans carry two product tax codes is not invoiced
+// today: taxCodesForLines answers errBillingTaxCodeMixed and the month
+// holds for a person. Which code each overage line should then carry is
+// decision 16 (and 16a to 16d) in DECISIONS-FOR-PAUL.md, the owner's. The
+// mechanics either answer needs are here, built and tested behind that
+// error: the attribution of eligible days to the plan in force, the
+// per-code weights in base units, and the apportionment of each axis's
+// unchanged amount. Nothing in this file changes an amount or a one-code
+// month's bytes.
+
+// errBillingTaxApportion means an axis has a positive amount and no
+// attributed usage to divide it by: the usage that produced the charge
+// is not attributed, and the month holds rather than guess.
+var errBillingTaxApportion = errors.New("billing_invoice_tax_code_mismatch")
+
+// taxCodesForLines decides the tax codes of a calculation's lines from the
+// codes of its paid spans: one code, and the calculation carries it as it
+// always has; two or more, and the month holds. The bodies that would
+// answer a two-code month (splitLinesByGrossUsage, lastSpansCodeLines)
+// exist and are tested, and are not called from here until decision 16.
+func taxCodesForLines(codes []string) (string, error) {
+ code := ""
+ for _, c := range codes {
+ if code != "" && c != code {
+ return "", errBillingTaxCodeMixed
+ }
+ code = c
+ }
+ return code, nil
+}
+
+// eligibleIntervals are the intervals of the month whose whole days are
+// billed: the paid coverage of a partly covered month, exactly as
+// billingUsageOnPaidDays reads it, and the whole month otherwise (a fully
+// covered month's Covered is nil by design, which is not "no days").
+func eligibleIntervals(month string, allowance plans.MonthAllowance) ([]plans.CoveredInterval, error) {
+ if allowance.Partial {
+ return allowance.Covered, nil
+ }
+ start, err := time.Parse("2006-01", month)
+ if err != nil {
+ return nil, errBillingDaysMismatch
+ }
+ return []plans.CoveredInterval{{From: start, To: start.AddDate(0, 1, 0)}}, nil
+}
+
+// usageByCode attributes the eligible days' usage to the plan in force and
+// returns, per tax code, the four axes in base units: the allocation
+// weights of a two-code month. The rule for the day is 16a's proposal, the
+// one body there is: a UTC day belongs to the span that contains its first
+// instant. Eligibility comes first and is today's (a day counts only when
+// the whole of it lies inside the eligible intervals), so a lapse at 15:00
+// excludes that day as it does today, and a continuous change at 15:00
+// keeps it. The arithmetic is the month's own order: the days' values
+// are summed per (code, meter) in date order as the statement's floating
+// values, each sum is floored to base units once, then the two transfer
+// meters are added with the overflow check. These are weights, not the
+// month's billed usage, which comes from the whole month's meters as
+// before. A statement without Days is usable only when every billed meter
+// is zero, as billingUsageOnPaidDays has it; such a month has no weights
+// and nothing to divide.
+func usageByCode(statement usage.Statement, intervals []plans.CoveredInterval, spans []plans.PlanSpan, codeOf func(plan string) (string, bool)) (map[string]plans.Resources, error) {
+ start, end, err := checkedStatementDays(statement)
+ if err != nil {
+ return nil, err
+ }
+ if statement.Days == nil {
+ // Nothing was used (the check above), so nothing is attributed.
+ return map[string]plans.Resources{}, nil
+ }
+ sums := map[string]map[string]float64{} // code -> meter -> value
+ for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
+ next := day.AddDate(0, 0, 1)
+ inside := false
+ for _, iv := range intervals {
+ if !iv.From.After(day) && !iv.To.Before(next) {
+ inside = true
+ break
+ }
+ }
+ if !inside {
+ continue
+ }
+ code, found := "", false
+ for _, sp := range spans {
+ if !sp.From.After(day) && day.Before(sp.To) {
+ c, ok := codeOf(sp.Plan)
+ if !ok {
+ return nil, errBillingUnavailable
+ }
+ code, found = c, true
+ break
+ }
+ }
+ if !found {
+ // An eligible day lies inside the merged coverage, which is
+ // the union of the spans, so its first instant is in one of
+ // them; a day that is not is a history this code cannot read.
+ return nil, errBillingDaysMismatch
+ }
+ if sums[code] == nil {
+ sums[code] = map[string]float64{}
+ }
+ for _, meter := range monthlyBillingMeters {
+ sums[code][meter] += statement.Days[usage.DayOf(day)][meter]
+ }
+ }
+ out := map[string]plans.Resources{}
+ for code, byMeter := range sums {
+ var units [5]int64
+ for i, meter := range monthlyBillingMeters {
+ if units[i], err = billingBaseUnits(byMeter[meter], billingMeterScale(meter)); err != nil {
+ return nil, err
+ }
+ }
+ if units[2] > (1<<63-1)-units[3] {
+ return nil, plans.ErrOverflow
+ }
+ out[code] = plans.Resources{AwakeSeconds: units[0], StorageByteMonths: units[1], TransferBytes: units[2] + units[3], Writes: units[4]}
+ }
+ return out, nil
+}
+
+// axisWeight reads the weight of one axis, named as the lines name it.
+func axisWeight(r plans.Resources, axis string) int64 {
+ switch axis {
+ case "awake_seconds":
+ return r.AwakeSeconds
+ case "storage_byte_months":
+ return r.StorageByteMonths
+ case "transfer_bytes":
+ return r.TransferBytes
+ case "writes":
+ return r.Writes
+ }
+ return 0
+}
+
+// taxShare is one code's share of one axis's amount.
+type taxShare struct {
+ Code string
+ Amount int64
+}
+
+// apportion divides one axis's amount, in minor units, among codes in
+// proportion to their weights, without rounding beyond the axis: each
+// code's share is floored, and the minor units still owed to reach the
+// amount go one at a time to the codes with the largest remainder, ties
+// broken by the code's byte order. A share of zero is omitted. An amount
+// with no weight to divide it by is an error, not a guess; a zero amount
+// has no shares. Two calls with the same inputs give the same shares in
+// the same order whatever order the map was built in.
+func apportion(amount int64, weights map[string]int64) ([]taxShare, error) {
+ if amount < 0 {
+ return nil, errBillingTaxApportion
+ }
+ codes := make([]string, 0, len(weights))
+ var sum int64
+ for code, w := range weights {
+ if w < 0 {
+ return nil, errBillingTaxApportion
+ }
+ if w > 0 {
+ codes = append(codes, code)
+ sum += w
+ if sum < 0 {
+ return nil, plans.ErrOverflow
+ }
+ }
+ }
+ sort.Strings(codes)
+ if amount == 0 {
+ return nil, nil
+ }
+ if sum == 0 {
+ return nil, errBillingTaxApportion
+ }
+ type part struct {
+ code string
+ share int64
+ remainder *big.Int
+ }
+ parts := make([]part, 0, len(codes))
+ var given int64
+ bigAmount, bigSum := big.NewInt(amount), big.NewInt(sum)
+ for _, code := range codes {
+ product := new(big.Int).Mul(bigAmount, big.NewInt(weights[code]))
+ var share, remainder big.Int
+ share.QuoRem(product, bigSum, &remainder)
+ if !share.IsInt64() {
+ return nil, plans.ErrOverflow
+ }
+ parts = append(parts, part{code, share.Int64(), &remainder})
+ given += share.Int64()
+ }
+ extra := amount - given
+ if extra < 0 || extra > int64(len(parts)) {
+ return nil, errBillingTaxApportion
+ }
+ order := make([]int, len(parts))
+ for i := range order {
+ order[i] = i
+ }
+ sort.SliceStable(order, func(i, j int) bool { // parts are in code byte order already; a stable sort keeps ties so
+ return parts[order[i]].remainder.Cmp(parts[order[j]].remainder) > 0
+ })
+ for k := int64(0); k < extra; k++ {
+ parts[order[k]].share++
+ }
+ out := make([]taxShare, 0, len(parts))
+ for _, p := range parts {
+ if p.share > 0 {
+ out = append(out, taxShare{Code: p.code, Amount: p.share})
+ }
+ }
+ return out, nil
+}
+
+// splitLinesByGrossUsage is the body for 16b's proposal: each axis's line,
+// with the amount it has today, is divided among the codes by their
+// attributed usage of that axis, and the result is one line per (axis,
+// code) in axis order then code byte order, each carrying its code. The
+// subtotal is unchanged. Not called from freeze until decision 16.
+func splitLinesByGrossUsage(lines []plans.OverageLine, weights map[string]plans.Resources) ([]plans.OverageLine, error) {
+ out := make([]plans.OverageLine, 0, len(lines))
+ for _, line := range lines {
+ if line.TaxCode != "" {
+ return nil, errBillingTaxApportion
+ }
+ w := map[string]int64{}
+ for code, r := range weights {
+ w[code] = axisWeight(r, line.Meter)
+ }
+ shares, err := apportion(line.AmountCents, w)
+ if err != nil {
+ return nil, err
+ }
+ for _, s := range shares {
+ out = append(out, plans.OverageLine{Meter: line.Meter, AmountCents: s.Amount, TaxCode: s.Code})
+ }
+ }
+ return out, nil
+}
+
+// lastSpansCodeLines is the body for the month-end answer if 16c takes the
+// last paid span's code: every line carries the one code given, nothing is
+// divided. (If 16c holds a month whose last paid span ends before the
+// month does, the glue, which has the spans and the month, refuses before
+// calling a body; that branch is written when 16c is.) Not called from
+// freeze until decision 16.
+func lastSpansCodeLines(lines []plans.OverageLine, code string) ([]plans.OverageLine, error) {
+ if !validTaxCode(code) {
+ return nil, errBillingTaxApportion
+ }
+ out := make([]plans.OverageLine, 0, len(lines))
+ for _, line := range lines {
+ if line.TaxCode != "" {
+ return nil, errBillingTaxApportion
+ }
+ out = append(out, plans.OverageLine{Meter: line.Meter, AmountCents: line.AmountCents, TaxCode: code})
+ }
+ return out, nil
+}
+
+// twoCodeBody is one answer to decision 16 in the shape of the two
+// proposals: given a calculation's lines as priced today, a way to the
+// per-code weights of the month, and the last paid span's code, the lines
+// each carrying its code. splitLinesByGrossUsage is 16b's proposal;
+// lastSpansCodeLines is the month-end answer if 16c takes the last paid
+// span's code. 16b's alternatives (a span's own excess; the pooled
+// allowance consumed in time order) would need each span's share of the
+// allowance as well, which the glue can compute from the spans it holds;
+// the shape grows when the answer needs it, and nothing here is that
+// answer.
+type twoCodeBody func(lines []plans.OverageLine, weights func() (map[string]plans.Resources, error), lastCode string) ([]plans.OverageLine, error)
+
+// twoCodeCalculation is the glue activation needs and nothing calls: it
+// takes a month priced as today (amounts, allowance and lines unchanged)
+// whose paid spans carry two codes, asks the allowance for its spans again
+// (priceBillingCalculation drops them), attributes the eligible days and
+// hands the lines to the chosen body, which asks for the per-code weights
+// only if it divides by them (the month-end answer does not, so it needs
+// no Days). The result is the two-code shape: no code on the calculation,
+// one on every line, the spans kept, the subtotal as it was. A statement
+// without Days and with usage cannot be split (errBillingDays); a charge
+// with no attributed usage holds (errBillingTaxApportion). Until decision
+// 16, freeze never reaches here.
+func twoCodeCalculation(calc *billingCalculation, history []plans.Assignment, codeOf func(plan string) (string, bool), body twoCodeBody) (*billingCalculation, error) {
+ if calc == nil || calc.TaxCode != "" {
+ return nil, errBillingTaxApportion
+ }
+ allowance, err := plans.AllowanceForMonth(calc.Statement.Month, history)
+ if err != nil {
+ return nil, err
+ }
+ if len(allowance.Spans) == 0 {
+ return nil, errBillingDaysMismatch
+ }
+ intervals, err := eligibleIntervals(calc.Statement.Month, *allowance)
+ if err != nil {
+ return nil, err
+ }
+ weights := func() (map[string]plans.Resources, error) {
+ return usageByCode(calc.Statement, intervals, allowance.Spans, codeOf)
+ }
+ lastCode, ok := codeOf(allowance.Spans[len(allowance.Spans)-1].Plan)
+ if !ok {
+ return nil, errBillingUnavailable
+ }
+ for _, line := range calc.Lines {
+ if line.TaxCode != "" {
+ return nil, errBillingTaxApportion
+ }
+ }
+ lines, err := body(calc.Lines, weights, lastCode)
+ if err != nil {
+ return nil, err
+ }
+ out := *calc
+ out.Lines = lines
+ out.TaxCode = ""
+ out.Allowance.Spans = allowance.Spans
+ if !out.taxShapeOK() {
+ return nil, errBillingTaxApportion
+ }
+ return &out, nil
+}
+
+// The two bodies in twoCodeBody's shape.
+func bodySplitByGrossUsage(lines []plans.OverageLine, weights func() (map[string]plans.Resources, error), _ string) ([]plans.OverageLine, error) {
+ if len(lines) == 0 {
+ // Nothing to divide (a waived month): the weights, and the Days
+ // they need, are not asked for.
+ return nil, nil
+ }
+ w, err := weights()
+ if err != nil {
+ return nil, err
+ }
+ return splitLinesByGrossUsage(lines, w)
+}
+
+func bodyLastSpansCode(lines []plans.OverageLine, _ func() (map[string]plans.Resources, error), lastCode string) ([]plans.OverageLine, error) {
+ return lastSpansCodeLines(lines, lastCode)
+}
diff --git a/internal/console/billingtaxsplit_test.go b/internal/console/billingtaxsplit_test.go
new file mode 100644
index 000000000..f930fc7ca
--- /dev/null
+++ b/internal/console/billingtaxsplit_test.go
@@ -0,0 +1,362 @@
+package console
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/usage"
+)
+
+func taxSplitCodeOf(plan string) (string, bool) {
+ code, ok := taxShapeCodes[plan]
+ return code, ok
+}
+
+func sep(day, hour int) time.Time { return time.Date(2026, 9, day, hour, 0, 0, 0, time.UTC) }
+
+func span(from, to time.Time, plan string) plans.PlanSpan {
+ return plans.PlanSpan{From: from, To: to, Plan: plan}
+}
+
+func iv(from, to time.Time) plans.CoveredInterval { return plans.CoveredInterval{From: from, To: to} }
+
+// A statement with ten awake hours on every day of September.
+func dailyStatement() usage.Statement {
+ st := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 300}, Days: map[string]map[string]float64{}, DaysInMonth: 30, Closed: true}
+ for d := 1; d <= 30; d++ {
+ st.Days[fmt.Sprintf("2026-09-%02d", d)] = map[string]float64{usage.MeterInstanceHours: 10}
+ }
+ return st
+}
+
+// Two codes still hold; one code is carried as it always was.
+func TestTwoTaxCodesStillHoldTheMonth(t *testing.T) {
+ if code, err := taxCodesForLines([]string{"txcd_10102001", "txcd_10102001"}); err != nil || code != "txcd_10102001" {
+ t.Fatalf("one code: %q %v", code, err)
+ }
+ if _, err := taxCodesForLines([]string{"txcd_10102001", "txcd_10102000"}); !errors.Is(err, errBillingTaxCodeMixed) {
+ t.Fatalf("two codes: %v", err)
+ }
+}
+
+// Eligible days first, as today, then each day to the span that holds its
+// first instant.
+func TestADaysUsageFollowsThePlanInForceAtItsStart(t *testing.T) {
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ whole := []plans.CoveredInterval{{From: sep(1, 0), To: oct1}}
+ hours := func(h int64) int64 { return h * 3600 }
+ for name, tc := range map[string]struct {
+ intervals []plans.CoveredInterval
+ spans []plans.PlanSpan
+ want map[string]plans.Resources
+ err error
+ }{
+ "a change at 00:00": {whole, []plans.PlanSpan{span(sep(1, 0), sep(16, 0), plans.PlanPersonal), span(sep(16, 0), oct1, plans.PlanBusiness)},
+ map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(150)}, "txcd_10102000": {AwakeSeconds: hours(150)}}, nil},
+ "a continuous change at 15:00 (the day eligible, under the earlier plan)": {whole, []plans.PlanSpan{span(sep(1, 0), sep(16, 15), plans.PlanPersonal), span(sep(16, 15), oct1, plans.PlanBusiness)},
+ map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(160)}, "txcd_10102000": {AwakeSeconds: hours(140)}}, nil},
+ "a lapse at 15:00 (the day excluded, as today)": {[]plans.CoveredInterval{iv(sep(1, 0), sep(16, 15)), iv(sep(17, 0), oct1)}, []plans.PlanSpan{span(sep(1, 0), sep(16, 15), plans.PlanPersonal), span(sep(17, 0), oct1, plans.PlanBusiness)},
+ map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(150)}, "txcd_10102000": {AwakeSeconds: hours(140)}}, nil},
+ "a change at the month's first instant": {whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)},
+ map[string]plans.Resources{"txcd_10102000": {AwakeSeconds: hours(300)}}, nil},
+ "two spans of one code aggregate": {whole, []plans.PlanSpan{span(sep(1, 0), sep(10, 0), plans.PlanPersonal), span(sep(10, 0), sep(20, 0), plans.PlanCommunity), span(sep(20, 0), oct1, plans.PlanBusiness)},
+ map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: hours(190)}, "txcd_10102000": {AwakeSeconds: hours(110)}}, nil},
+ "an eligible day no span holds": {whole, []plans.PlanSpan{span(sep(2, 0), oct1, plans.PlanBusiness)}, nil, errBillingDaysMismatch},
+ "a plan with no code": {whole, []plans.PlanSpan{span(sep(1, 0), oct1, "mystery")}, nil, errBillingUnavailable},
+ } {
+ got, err := usageByCode(dailyStatement(), tc.intervals, tc.spans, taxSplitCodeOf)
+ if !errors.Is(err, tc.err) || (tc.err == nil && !reflect.DeepEqual(got, tc.want)) {
+ t.Errorf("%s: %+v %v", name, got, err)
+ }
+ }
+ // A fully covered month's Covered is nil by design: the whole month is
+ // eligible, not nothing.
+ if ivs, err := eligibleIntervals("2026-09", plans.MonthAllowance{}); err != nil || !reflect.DeepEqual(ivs, whole) {
+ t.Fatalf("a whole month: %+v %v", ivs, err)
+ }
+ if ivs, _ := eligibleIntervals("2026-09", plans.MonthAllowance{Partial: true, Covered: whole[:1]}); !reflect.DeepEqual(ivs, whole[:1]) {
+ t.Fatal("a partly covered month keeps its coverage")
+ }
+ // No days: nothing to attribute when nothing was used, a hold otherwise.
+ empty := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{}, DaysInMonth: 30, Closed: true}
+ if got, err := usageByCode(empty, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); err != nil || len(got) != 0 {
+ t.Fatalf("no days and no usage: %+v %v", got, err)
+ }
+ empty.Meters[usage.MeterInstanceHours] = 5
+ if _, err := usageByCode(empty, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDays) {
+ t.Fatalf("no days with usage: %v", err)
+ }
+ // Malformed days hold as they do today.
+ bad := dailyStatement()
+ bad.Days["2026-10-01"] = map[string]float64{usage.MeterInstanceHours: 0}
+ if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDaysMismatch) {
+ t.Fatalf("a date outside the month: %v", err)
+ }
+ bad = dailyStatement()
+ bad.Days["2026-09-03"][usage.MeterInstanceHours] = math.NaN()
+ if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingMeter) {
+ t.Fatalf("a value that is not a number: %v", err)
+ }
+ bad = dailyStatement()
+ bad.Meters[usage.MeterInstanceHours] = 299
+ if _, err := usageByCode(bad, whole, []plans.PlanSpan{span(sep(1, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf); !errors.Is(err, errBillingDaysMismatch) {
+ t.Fatalf("days that do not sum to the month: %v", err)
+ }
+}
+
+// The two transfer meters are floored to bytes each and then added, as the
+// month's own usage is: a fraction in each does not make a byte together.
+// Ordinary egress under one plan and store egress under the other land in
+// the one transfer axis.
+func TestTransferWeightsFloorEachMeterThenAdd(t *testing.T) {
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ tiny := math.Ldexp(1, -30) // 2^-30 GB: 0.93 bytes, exact in binary
+ st := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterEgressGB: tiny + 1, usage.MeterStoreEgressGB: tiny}, DaysInMonth: 30, Closed: true,
+ Days: map[string]map[string]float64{"2026-09-01": {usage.MeterEgressGB: tiny, usage.MeterStoreEgressGB: tiny}, "2026-09-20": {usage.MeterEgressGB: 1}}}
+ got, err := usageByCode(st, []plans.CoveredInterval{{From: sep(1, 0), To: oct1}}, []plans.PlanSpan{span(sep(1, 0), sep(16, 0), plans.PlanPersonal), span(sep(16, 0), oct1, plans.PlanBusiness)}, taxSplitCodeOf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got["txcd_10102001"].TransferBytes != 0 || got["txcd_10102000"].TransferBytes != 1_000_000_000 {
+ t.Fatalf("transfer weights: %+v (two fractions of a byte must not make a byte)", got)
+ }
+}
+
+// An axis's amount is divided by weight: floors, then the largest
+// remainders, ties by the code's byte order; the shares sum to the amount
+// whatever order the weights were given in.
+func TestAnAxisIsApportionedWithoutRoundingBeyondIt(t *testing.T) {
+ for name, tc := range map[string]struct {
+ amount int64
+ weights map[string]int64
+ want []taxShare
+ err error
+ }{
+ "two thirds and a third": {1000, map[string]int64{"txcd_10102001": 576000, "txcd_10102000": 504000}, []taxShare{{"txcd_10102000", 467}, {"txcd_10102001", 533}}, nil},
+ "equal remainders, byte order": {3, map[string]int64{"txcd_b": 1, "txcd_a": 1}, []taxShare{{"txcd_a", 2}, {"txcd_b", 1}}, nil},
+ "all under one code": {7, map[string]int64{"txcd_a": 0, "txcd_b": 9}, []taxShare{{"txcd_b", 7}}, nil},
+ "a tiny share rounds to nothing": {1, map[string]int64{"txcd_a": 1, "txcd_b": 1_000_000}, []taxShare{{"txcd_b", 1}}, nil},
+ "nothing to divide": {0, map[string]int64{"txcd_a": 1}, nil, nil},
+ "an amount with no weight": {5, map[string]int64{"txcd_a": 0}, nil, errBillingTaxApportion},
+ "a negative weight": {5, map[string]int64{"txcd_a": -1}, nil, errBillingTaxApportion},
+ "weights that overflow": {5, map[string]int64{"txcd_a": math.MaxInt64, "txcd_b": 1}, nil, plans.ErrOverflow},
+ "a negative amount": {-1, map[string]int64{"txcd_a": 1}, nil, errBillingTaxApportion},
+ } {
+ got, err := apportion(tc.amount, tc.weights)
+ if !errors.Is(err, tc.err) || !reflect.DeepEqual(got, tc.want) {
+ t.Errorf("%s: %+v %v", name, got, err)
+ }
+ }
+ // The same shares whatever the map's insertion order.
+ a := map[string]int64{}
+ b := map[string]int64{}
+ for i := 0; i < 50; i++ {
+ a[fmt.Sprintf("txcd_%02d", i)] = int64(i + 1)
+ }
+ for i := 49; i >= 0; i-- {
+ b[fmt.Sprintf("txcd_%02d", i)] = int64(i + 1)
+ }
+ x, _ := apportion(12345, a)
+ y, _ := apportion(12345, b)
+ var sum int64
+ for _, s := range x {
+ sum += s.Amount
+ }
+ if !reflect.DeepEqual(x, y) || sum != 12345 {
+ t.Fatalf("order-dependent or not conserving: %v %v %d", x, y, sum)
+ }
+}
+
+// The two bodies behind the hold produce the two-code shape with the
+// subtotal unchanged: one line per (axis, code) under the split, every
+// line under one code for the month-end answer. Neither is called from
+// freeze.
+func TestTheTwoCodeBodiesKeepTheSubtotalAndTheShape(t *testing.T) {
+ lines := []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 1000}, {Meter: "writes", AmountCents: 7}}
+ weights := map[string]plans.Resources{"txcd_10102001": {AwakeSeconds: 576000}, "txcd_10102000": {AwakeSeconds: 504000, Writes: 9}}
+ split, err := splitLinesByGrossUsage(lines, weights)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 467, TaxCode: "txcd_10102000"}, {Meter: "awake_seconds", AmountCents: 533, TaxCode: "txcd_10102001"}, {Meter: "writes", AmountCents: 7, TaxCode: "txcd_10102000"}}
+ if !reflect.DeepEqual(split, want) {
+ t.Fatalf("split: %+v", split)
+ }
+ last, err := lastSpansCodeLines(lines, "txcd_10102000")
+ if err != nil || !reflect.DeepEqual(last, []plans.OverageLine{{Meter: "awake_seconds", AmountCents: 1000, TaxCode: "txcd_10102000"}, {Meter: "writes", AmountCents: 7, TaxCode: "txcd_10102000"}}) {
+ t.Fatalf("last: %+v %v", last, err)
+ }
+ for _, body := range [][]plans.OverageLine{split, last} {
+ var sum int64
+ for _, l := range body {
+ sum += l.AmountCents
+ }
+ calc := billingCalculation{V: 2, Lines: body, Total: &plans.Invoice{TotalMinor: 1007}}
+ if sum != 1007 || !calc.taxShapeOK() {
+ t.Fatalf("a body: sum %d shape %v", sum, calc.taxShapeOK())
+ }
+ raw, _ := json.Marshal(calc)
+ var back billingCalculation
+ if json.Unmarshal(raw, &back) != nil || !reflect.DeepEqual(back.Lines, body) {
+ t.Fatalf("round trip: %s", raw)
+ }
+ }
+ // An axis with an amount and no weight holds rather than guesses; a
+ // line that already carries a code is not divided again.
+ if _, err := splitLinesByGrossUsage([]plans.OverageLine{{Meter: "storage_byte_months", AmountCents: 3}}, weights); !errors.Is(err, errBillingTaxApportion) {
+ t.Fatalf("no weight: %v", err)
+ }
+ if _, err := splitLinesByGrossUsage(want, weights); !errors.Is(err, errBillingTaxApportion) {
+ t.Fatalf("already tagged: %v", err)
+ }
+ if _, err := lastSpansCodeLines(lines, "txcd_"); !errors.Is(err, errBillingTaxApportion) {
+ t.Fatalf("a malformed code: %v", err)
+ }
+}
+
+// The glue activation would call, end to end and uncalled today: a month
+// priced as today whose plans carry two codes becomes the two-code shape,
+// with its spans kept, its subtotal unchanged, its digest the same on
+// every run; without Days it cannot be split but can take the last
+// span's code on a whole month, and a partly covered month without Days
+// is refused under either body because it cannot be priced at all.
+func TestTheActivationGlueProducesATwoCodeMonth(t *testing.T) {
+ terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: plans.USD}
+ history := monthHistory(terms, false)
+ later := history[0]
+ later.Revision, later.EffectiveAt, later.Plan, later.Quantity = 2, sep(16, 15), plans.PlanBusiness, 10
+ history = append(history, later)
+ st := dailyStatement()
+ st.Meters[usage.MeterInstanceHours] = 3000
+ for d := range st.Days {
+ st.Days[d][usage.MeterInstanceHours] = 100
+ }
+ priced, err := priceBillingCalculation(st, history)
+ if err != nil || len(priced.Lines) != 1 || priced.TaxCode != "" {
+ t.Fatalf("priced: %+v %v", priced, err)
+ }
+ split, err := twoCodeCalculation(priced, history, taxSplitCodeOf, bodySplitByGrossUsage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ last, err := twoCodeCalculation(priced, history, taxSplitCodeOf, bodyLastSpansCode)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, calc := range map[string]*billingCalculation{"split": split, "last span": last} {
+ if calc.TaxCode != "" || !calc.taxShapeOK() || len(calc.Allowance.Spans) != 2 {
+ t.Fatalf("%s: shape %+v", name, calc)
+ }
+ var sum int64
+ for _, l := range calc.Lines {
+ sum += l.AmountCents
+ }
+ if sum != priced.Lines[0].AmountCents || calc.totalMinor() != priced.totalMinor() {
+ t.Fatalf("%s: the subtotal moved: %d vs %d", name, sum, priced.Lines[0].AmountCents)
+ }
+ a, _ := json.Marshal(calc)
+ again, _ := twoCodeCalculation(priced, history, taxSplitCodeOf, map[string]twoCodeBody{"split": bodySplitByGrossUsage, "last span": bodyLastSpansCode}[name])
+ b, _ := json.Marshal(again)
+ if string(a) != string(b) || !strings.Contains(string(a), `"spans":[`) || !strings.Contains(string(a), `"tax_code":"txcd_`) {
+ t.Fatalf("%s: not the same bytes twice, or spans and codes missing: %s", name, a)
+ }
+ }
+ if len(split.Lines) != 2 || split.Lines[0].TaxCode != "txcd_10102000" || split.Lines[1].TaxCode != "txcd_10102001" {
+ t.Fatalf("split lines: %+v", split.Lines)
+ }
+ // Sixteen days of thirty under Personal (the change at 15:00 on the
+ // 16th leaves that day under the earlier plan): Personal's share is
+ // the floor of sixteen thirtieths, the odd minor unit going to the
+ // larger remainder, which is Business's.
+ total := priced.Lines[0].AmountCents
+ if p, b := split.Lines[1].AmountCents, split.Lines[0].AmountCents; p != total*16/30 || b != total-p {
+ t.Fatalf("split not sixteen to fourteen of %d: personal %d business %d", total, p, b)
+ }
+ if len(last.Lines) != 1 || last.Lines[0].TaxCode != "txcd_10102000" || last.Lines[0].AmountCents != priced.Lines[0].AmountCents {
+ t.Fatalf("last-span lines: %+v", last.Lines)
+ }
+ // The priced calculation itself is untouched.
+ if priced.TaxCode != "" || priced.Lines[0].TaxCode != "" || priced.Allowance.Spans != nil {
+ t.Fatal("the input was changed")
+ }
+ // No Days on a whole month: no split, but the last span's code.
+ noDays := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 3000}, DaysInMonth: 30, Closed: true}
+ pricedNoDays, err := priceBillingCalculation(noDays, history)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := twoCodeCalculation(pricedNoDays, history, taxSplitCodeOf, bodySplitByGrossUsage); !errors.Is(err, errBillingDays) {
+ t.Fatalf("split without days: %v", err)
+ }
+ if calc, err := twoCodeCalculation(pricedNoDays, history, taxSplitCodeOf, bodyLastSpansCode); err != nil || !calc.taxShapeOK() {
+ t.Fatalf("last span without days: %v", err)
+ }
+ // A partly covered month without Days is not priced at all, under either.
+ partial := append([]plans.Assignment(nil), history...)
+ partial[0].EffectiveAt = sep(10, 0)
+ if _, err := priceBillingCalculation(noDays, partial); !errors.Is(err, errBillingDays) {
+ t.Fatalf("a partly covered month without days: %v", err)
+ }
+ // Mixed codes, waived: nothing to divide, the two-code waived shape.
+ zero := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{}, DaysInMonth: 30, Closed: true}
+ pricedZero, err := priceBillingCalculation(zero, history)
+ if err != nil || !pricedZero.waived() {
+ t.Fatalf("waived: %+v %v", pricedZero, err)
+ }
+ if calc, err := twoCodeCalculation(pricedZero, history, taxSplitCodeOf, bodySplitByGrossUsage); err != nil || len(calc.Lines) != 0 || calc.TaxCode != "" || !calc.taxShapeOK() {
+ t.Fatalf("a waived two-code month: %+v %v", calc, err)
+ }
+ // An already two-code calculation is not split again.
+ if _, err := twoCodeCalculation(split, history, taxSplitCodeOf, bodySplitByGrossUsage); !errors.Is(err, errBillingTaxApportion) {
+ t.Fatalf("split twice: %v", err)
+ }
+}
+
+// Two guards of the glue and the weights: a one-code calculation handed
+// to the glue is refused, not converted; two transfer meters whose base
+// units together pass the integer's reach are refused as overflow; and a
+// waived two-code month with usage but no Days is waived under the split
+// answer too, since nothing is divided and the Days are not asked for.
+func TestTheGluesGuards(t *testing.T) {
+ terms := plans.PriceTerms{TermsVersion: plans.CurrentTermsVersion, PriceBook: plans.CurrentPriceBook, Currency: plans.USD}
+ history := monthHistory(terms, false)
+ later := history[0]
+ later.Revision, later.EffectiveAt, later.Plan, later.Quantity = 2, sep(16, 0), plans.PlanBusiness, 10
+ history = append(history, later)
+ priced, err := priceBillingCalculation(dailyStatement(), history)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oneCode := *priced
+ oneCode.TaxCode = "txcd_10102001"
+ if _, err := twoCodeCalculation(&oneCode, history, taxSplitCodeOf, bodySplitByGrossUsage); !errors.Is(err, errBillingTaxApportion) {
+ t.Fatalf("a one-code calculation through the glue: %v", err)
+ }
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ huge := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterEgressGB: 5e9, usage.MeterStoreEgressGB: 5e9}, DaysInMonth: 30, Closed: true,
+ Days: map[string]map[string]float64{"2026-09-02": {usage.MeterEgressGB: 5e9, usage.MeterStoreEgressGB: 5e9}}}
+ if _, err := usageByCode(huge, []plans.CoveredInterval{{From: sep(1, 0), To: oct1}}, []plans.PlanSpan{{From: sep(1, 0), To: oct1, Plan: plans.PlanBusiness}}, taxSplitCodeOf); !errors.Is(err, plans.ErrOverflow) {
+ t.Fatalf("transfer weights past the integer's reach: %v", err)
+ }
+ // Waived, with usage on the statement but no Days: a house account's
+ // month, say. Nothing to divide, so nothing asked.
+ house := monthHistory(terms, true)
+ h2 := house[0]
+ h2.Revision, h2.EffectiveAt, h2.Plan, h2.Quantity = 2, sep(16, 0), plans.PlanBusiness, 10
+ house = append(house, h2)
+ noDays := usage.Statement{V: 1, Account: "bab", Month: "2026-09", Meters: map[string]float64{usage.MeterInstanceHours: 3000}, DaysInMonth: 30, Closed: true}
+ waived, err := priceBillingCalculation(noDays, house)
+ if err != nil || !waived.waived() {
+ t.Fatalf("a house month: %+v %v", waived, err)
+ }
+ if calc, err := twoCodeCalculation(waived, house, taxSplitCodeOf, bodySplitByGrossUsage); err != nil || len(calc.Lines) != 0 || !calc.taxShapeOK() {
+ t.Fatalf("a waived two-code month without days under the split: %+v %v", calc, err)
+ }
+}
diff --git a/internal/plans/lines.go b/internal/plans/lines.go
index 825d5407f..f53fad414 100644
--- a/internal/plans/lines.go
+++ b/internal/plans/lines.go
@@ -22,6 +22,12 @@ var meterOrder = [4]string{"awake_seconds", "storage_byte_months", "transfer_byt
type OverageLine struct {
Meter string
AmountCents int64
+ // TaxCode is the product tax code of this line alone, set only in a
+ // calculation whose month's paid spans carry more than one code (the
+ // calculation's own tax code is then empty); absent, and omitted from
+ // the encoding, in every calculation with one code, whose bytes are
+ // exactly what they were before the field existed.
+ TaxCode string `json:"tax_code,omitempty"`
}
// InvoiceOverageLines prices a standalone overage invoice exactly as
diff --git a/internal/plans/month.go b/internal/plans/month.go
index ee5b516c9..bcd58be1e 100644
--- a/internal/plans/month.go
+++ b/internal/plans/month.go
@@ -47,6 +47,16 @@ type CoveredInterval struct {
To time.Time `json:"to"`
}
+// PlanSpan is one paid assignment's interval of a month, half-open
+// [From, To), with the plan in force over it. Unlike Covered, adjacent
+// spans are not joined: a quantity change gives two spans of one plan, a
+// plan change two spans of two.
+type PlanSpan struct {
+ From time.Time `json:"from"`
+ To time.Time `json:"to"`
+ Plan string `json:"plan"`
+}
+
type MonthAllowance struct {
Month string `json:"month"`
TermsVersion string `json:"terms_version"`
@@ -68,6 +78,13 @@ type MonthAllowance struct {
// one book. Empty for a month of legacy assignments.
PriceBook string `json:"price_book,omitempty"`
Currency Currency `json:"currency,omitempty"`
+ // Spans are the paid intervals the allowance counted, one per
+ // assignment, unjoined and in order, for whoever has to know which
+ // plan was in force when (the tax code of an overage line, when the
+ // month's plans carry more than one). AllowanceForMonth fills them;
+ // a calculation keeps them only when it needs them, and a month with
+ // one tax code drops them, so its bytes are what they always were.
+ Spans []PlanSpan `json:"spans,omitempty"`
}
// PriceTerms is the commercial identity the month's usage is priced under.
@@ -194,6 +211,7 @@ func AllowanceForMonth(month string, history []Assignment) (*MonthAllowance, err
totals[n].Add(&totals[n], weighted)
}
out.Assignments = append(out.Assignments, a)
+ out.Spans = append(out.Spans, PlanSpan{From: lo, To: paidHi, Plan: a.Plan})
// Adjacent paid spans join, so a quantity change mid-month does not
// split a day between two intervals.
if n := len(out.Covered); n > 0 && out.Covered[n-1].To.Equal(lo) {
diff --git a/internal/plans/month_test.go b/internal/plans/month_test.go
index 47aa86129..0230d69c6 100644
--- a/internal/plans/month_test.go
+++ b/internal/plans/month_test.go
@@ -1,8 +1,10 @@
package plans
import (
+ "encoding/json"
"errors"
"reflect"
+ "strings"
"testing"
"time"
)
@@ -200,3 +202,63 @@ func TestMonthAllowanceRoundsFractionalBaseUnitsForCustomer(t *testing.T) {
t.Fatalf("house history: %+v %v", got, err)
}
}
+
+// The allowance names the paid intervals it counted, one per assignment,
+// half-open and unjoined, with the plan in force: a quantity change gives
+// two spans of one plan, a plan change two spans of two, a change at the
+// month's first instant one span, and a lapse a gap between spans. The
+// joined Covered list is as it was.
+func TestMonthAllowanceNamesItsSpans(t *testing.T) {
+ sep := func(day, hour int) time.Time { return time.Date(2026, 9, day, hour, 0, 0, 0, time.UTC) }
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ first := monthAssignment("2026-08-01T00:00:00Z", 1, 10)
+ second := monthAssignment("2026-09-16T15:00:00Z", 2, 20)
+ withPlan := func(a Assignment, plan string) Assignment {
+ a.Plan = plan
+ if plan == PlanPersonal {
+ a.Quantity = 1
+ }
+ return a
+ }
+ cases := map[string]struct {
+ history []Assignment
+ spans []PlanSpan
+ covered []CoveredInterval
+ }{
+ "one plan all month": {[]Assignment{first}, []PlanSpan{{sep(1, 0), oct1, first.Plan}}, nil},
+ "a quantity change at 15:00": {[]Assignment{first, second},
+ []PlanSpan{{sep(1, 0), sep(16, 15), first.Plan}, {sep(16, 15), oct1, second.Plan}}, nil},
+ "a plan change at 15:00": {[]Assignment{withPlan(first, PlanPersonal), withPlan(second, PlanBusiness)},
+ []PlanSpan{{sep(1, 0), sep(16, 15), PlanPersonal}, {sep(16, 15), oct1, PlanBusiness}}, nil},
+ "a change at the month's first instant": {[]Assignment{withPlan(first, PlanPersonal), withPlan(monthAssignment("2026-09-01T00:00:00Z", 2, 20), PlanBusiness)},
+ []PlanSpan{{sep(1, 0), oct1, PlanBusiness}}, nil},
+ }
+ lapsed := first
+ lapsed.PaidThrough = sep(10, 0)
+ cases["a lapse, then a new plan"] = struct {
+ history []Assignment
+ spans []PlanSpan
+ covered []CoveredInterval
+ }{[]Assignment{lapsed, withPlan(second, PlanBusiness)}, []PlanSpan{{sep(1, 0), sep(10, 0), lapsed.Plan}, {sep(16, 15), oct1, PlanBusiness}}, []CoveredInterval{{sep(1, 0), sep(10, 0)}, {sep(16, 15), oct1}}}
+ for name, tc := range cases {
+ got, err := AllowanceForMonth("2026-09", tc.history)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if !reflect.DeepEqual(got.Spans, tc.spans) {
+ t.Errorf("%s: spans %+v, want %+v", name, got.Spans, tc.spans)
+ }
+ if !reflect.DeepEqual(got.Covered, tc.covered) {
+ t.Errorf("%s: covered %+v, want %+v", name, got.Covered, tc.covered)
+ }
+ }
+ // A span is not in the encoding of an allowance that has none, and a
+ // line without a code encodes as it always did.
+ raw, _ := json.Marshal(MonthAllowance{})
+ if strings.Contains(string(raw), "spans") {
+ t.Fatalf("an empty allowance encodes spans: %s", raw)
+ }
+ if raw, _ := json.Marshal(OverageLine{Meter: "writes", AmountCents: 5}); string(raw) != `{"Meter":"writes","AmountCents":5}` {
+ t.Fatalf("a line without a code: %s", raw)
+ }
+}