carlos / platform Public

Per-line tax codes: the first code review answered (a resumed finalize validates the draft; the base commit's frozen records and engine hashes pinned; the activation glue, uncalled; wrong codes, the first line's loss, the older reader as a harness; the shape's waived branch; the mutants rerun)

Astra 1 (P2): stepFinalizing reads the invoice and, while it is a draft,
validates its lines before replaying or renewing the finalize. Astra 2
and Opus P3-1: a second fixture table recorded on 29ea6676 in a detached
worktree pins the frozen record after freeze sets its code, its digest,
the draft request's hash and each line's hash through lineParams,
lineKind, draftParams and requestHash; the first table is relabelled as
the pricing output. Astra 3 and Opus P3-2: the first line's loss with no
sibling yet; a wrong code on the POST answer, the listing a recovery
reads and the draft; the older reader is a harness copying the entry of
29ea6676 and filing through the run's own review method, run again to
reopen its review; a waived mixed history and a one-emitted-code record
through both readers. Opus P2-1: twoCodeCalculation, the glue activation
needs, uncalled, with the two bodies in its shape and the weights asked
for only by the body that divides; errBillingTaxApportion is the
billing_invoice_tax_code_mismatch review; the design and decision 16 say
activation is a reviewed change of its own. Opus P3-3: taxShapeOK
requires a waived calculation to have no lines. Opus P3-5 and Astra 4:
M3 and M15 rewritten to compile, a negative amount tested, M14 relabelled.
Twenty-nine mutants (taxcodes-mutants-round2.txt): all die but M14
(equivalent) and M27 (unreachable until activation, named as its
obligation). Dispositions and both reports in the tree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8715e4cd827a0f5a70bcf754cbf372511e61076c parent 61313be
12 files changed, +753 −44
  • docs/superpowers/specs/2026-10-03-per-line-tax-codes-design.md +4 −3
  • docs/superpowers/specs/CODEX-FINDINGS-per-line-tax-codes-review1.md +82 −0
  • docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md +18 −1
  • docs/superpowers/specs/OPUS-REVIEW-per-line-tax-codes-review1.md +149 −0
  • internal/console/billingcalculation.go +4 −3
  • internal/console/billinginvoice.go +21 −1
  • internal/console/billinginvoice_test.go +16 −6
  • internal/console/billingtaxlifecycle_test.go +207 −29
  • internal/console/billingtaxshape_fixtures_test.go +79 −1
  • internal/console/billingtaxshape_test.go +1 −0
  • internal/console/billingtaxsplit.go +72 −0
  • internal/console/billingtaxsplit_test.go +100 −0

CI passed — run details

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
index b86681a27..64f10befa 100644
--- 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
@@ -1,6 +1,6 @@
# Per-line tax codes for a month in which the plan changed
-Date: 2026-10-03. Status: design, revision 4, 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`.
+Date: 2026-10-03. Status: design, revision 5, as built (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
@@ -36,13 +36,14 @@ It builds the mechanics every answer needs, keeps the hold exactly where it is u
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. Any map is iterated in sorted order; two runs over the same inputs produce identical bytes.
+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; its answer is one function, `taxCodesForLines`, which today returns the error, with the bodies for the answers written and tested behind it. Activation is a reviewed one-line change plus the roll rule and 16d below.
+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` (among them one that drives `freeze` to `errBillingTaxApportion` and sees the `billing_invoice_tax_code_mismatch` review, which no test can reach today), 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: every console that can run the invoice pass is on a build with this change, and stopped if older, before `taxCodesForLines` stops returning the error; 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 and, while it is a draft, validates its lines (the metadata code among them) first.
- 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.
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/DISPOSITIONS-per-line-tax-codes.md b/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md
index b9fef7b99..406bfccb6 100644
--- a/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md
+++ b/docs/superpowers/specs/DISPOSITIONS-per-line-tax-codes.md
@@ -33,8 +33,25 @@ Branch `design-per-line-tax-codes`, off the launch branch at `29ea6676`. Design
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.
+
## 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 a git-less tree copy, each with its killing tests logged (`taxcodes-mutants-round1.txt`); twenty-two die, M14 is equivalent (reasoned in the log). Every one-code month's bytes, digest, request hashes and operation kinds are the ones pinned in Task 0 from the unchanged code.
+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 and twenty-nine in the second, in git-less tree copies, each with its killing tests logged (`taxcodes-mutants-round{1,2}.txt`); all die but two: M14, equivalent under the production invariant (spans and coverage from one `AllowanceForMonth`; not for arbitrary inconsistent helper inputs), and M27, the review mapping of `errBillingTaxApportion`, which nothing reaches until decision 16 switches `freeze` to the glue; the test that kills it is an obligation of the activation change, named in the design. 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 `taxCodesForLines` is switched, after the roll rule; nothing on this branch changes a one-code month, an amount, the floor, or the price book.
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/internal/console/billingcalculation.go b/internal/console/billingcalculation.go
index 66fa45662..1ffe2760e 100644
--- a/internal/console/billingcalculation.go
+++ b/internal/console/billingcalculation.go
@@ -88,8 +88,8 @@ func validTaxCode(code string) bool { return strings.HasPrefix(code, "txcd_") &&
// 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 every code present
-// has the shape of one. A calculation in neither shape is not invoiced and
+// 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
@@ -101,7 +101,8 @@ func (c *billingCalculation) taxShapeOK() bool {
tagged++
}
}
- if len(c.Lines) == 0 && !c.waived() {
+ 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 != "" {
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go
index bcfd0b54a..2d5743533 100644
--- a/internal/console/billinginvoice.go
+++ b/internal/console/billinginvoice.go
@@ -313,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 taxCodesForLines) 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.
@@ -1188,7 +1194,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
@@ -1196,6 +1207,15 @@ 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" {
+ if err := r.validateDraftLines(ctx); 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 44813ab0c..d2a925059 100644
--- a/internal/console/billinginvoice_test.go
+++ b/internal/console/billinginvoice_test.go
@@ -48,9 +48,12 @@ type fakeInvoiceStripe struct {
// stripLineMetaTax drops it from the listed lines: a provider that did
// not keep what it was sent.
stripItemMetaTax, stripLineMetaTax bool
- listDuplicate bool
- hasMoreLines bool
- seq int
+ // swapItemMetaTax and swapLineMetaTax report the other code in
+ // metadata[tax_code]: a provider that kept the wrong one.
+ swapItemMetaTax, swapLineMetaTax 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
@@ -90,10 +93,17 @@ type fakeLine struct {
// 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 bool) map[string]string {
+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
}
@@ -138,7 +148,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": l.lineMetadata(f.stripLineMetaTax), "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{}})
@@ -251,7 +261,7 @@ func (f *fakeInvoiceStripe) serve(w http.ResponseWriter, r *http.Request) {
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": line.lineMetadata(f.stripItemMetaTax)}
+ 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")]
diff --git a/internal/console/billingtaxlifecycle_test.go b/internal/console/billingtaxlifecycle_test.go
index 035b6f255..642344a95 100644
--- a/internal/console/billingtaxlifecycle_test.go
+++ b/internal/console/billingtaxlifecycle_test.go
@@ -99,16 +99,18 @@ 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},
- "past the window, one page": {false, 25 * time.Hour},
- "past the window, paged listing": {true, 25 * time.Hour},
+ "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"},
} {
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 := "line-awake_seconds-txcd_personal"
+ lost := tc.lost
ix.fake.loseResponse[lost] = true
if err := ix.run(t); !errors.Is(err, errBillingPending) {
t.Fatalf("lost line answer: %v", err)
@@ -125,7 +127,84 @@ func TestATwoCodeLinesLostAnswerIsRecoveredByItsCode(t *testing.T) {
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, lost: 1, "finalize": 1, "pay": 1})
+ 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 err == nil {
+ t.Fatal("the month completed over a wrong code")
+ }
+ 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)
+ }
+ })
+ }
+}
+
+// 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)
+ }
})
}
}
@@ -159,34 +238,49 @@ func TestAMissingMetadataCodeHoldsTheMonth(t *testing.T) {
}
}
-// An older binary's entry guard, copied as it was before lines carried
-// codes (29ea6676: wellFormed and a top-level txcd_ check), refuses a
-// two-code record and files the record-mismatch review; that review
-// blocks the new reader until it is resolved, and an unrelated review
-// keeps the month held after that. The record's bytes are untouched and
-// nothing reaches the provider meanwhile.
-func TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount(t *testing.T) {
- olderGuardAccepts := func(c *billingCalculation) bool {
- return c.wellFormed() && strings.HasPrefix(c.TaxCode, "txcd_") && len(c.TaxCode) > 5
+// 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
}
- ix := taxLifecycleFixture(t)
- oneCode := ix.expectedCalc(t)
- oneCode.TaxCode = "txcd_personal"
- if !olderGuardAccepts(oneCode) {
- t.Fatal("the older guard refuses a one-code record")
+ 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
}
- storeTwoCodeMonth(t, ix, func(total int64) int64 { return total / 2 })
- before := ix.month(t)
- var frozen billingCalculation
- if err := json.Unmarshal(before.Calculation, &frozen); err != nil || olderGuardAccepts(&frozen) {
- t.Fatalf("the older guard accepts a two-code record: %v", err)
+ 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)
}
- // What the older binary then does: the review, by the run's own filing.
- if err := ix.st.fileBillingInvoiceReview(ix.acct.ID, "sub_hist", "billing_invoice_record_mismatch:"+invoiceFixtureMonth, ix.now); 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)
@@ -206,8 +300,17 @@ func TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount(t *testing.T) {
if err := ix.run(t); !errors.Is(err, errBillingReview) {
t.Fatalf("with an unrelated review still open: %v", err)
}
- if err := ix.st.resolveBillingReview(billingReviewResolution{AccountID: ix.acct.ID, Subscription: "sub_hist", Reason: "billing_invoice_tax_code_mixed:2026-08", Occurrence: 1, Actor: "paul@keymail.dev", Note: "decided"}, ix.now); err != nil {
- t.Fatal(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)
@@ -215,4 +318,79 @@ func TestAnOlderReaderRefusesATwoCodeRecordAndHoldsTheAccount(t *testing.T) {
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)
+ }
+ calc := storeTwoCodeMonth(t, ix, func(total int64) int64 { return 0 })
+ if len(calc.Lines) != 2 {
+ t.Fatal("two lines were stored")
+ }
+ // A zero share is omitted by apportion; store the record as the
+ // glue would, with the one line.
+ calc.Lines = calc.Lines[:1]
+ raw, _ := json.Marshal(calc)
+ if _, err := ix.st.db.Exec(`UPDATE billing_months SET calculation=? WHERE account_id=? AND month=?`, raw, ix.acct.ID, invoiceFixtureMonth); 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})
+ })
}
diff --git a/internal/console/billingtaxshape_fixtures_test.go b/internal/console/billingtaxshape_fixtures_test.go
index d31fd424b..a5173fd23 100644
--- a/internal/console/billingtaxshape_fixtures_test.go
+++ b/internal/console/billingtaxshape_fixtures_test.go
@@ -1,6 +1,6 @@
package console
-// Today's bytes, pinned before per-line tax codes exist (design
+// 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,
@@ -129,3 +129,81 @@ func TestTodaysCalculationBytesArePinned(t *testing.T) {
}
}
}
+
+// 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
index df0263ea2..f98907a63 100644
--- a/internal/console/billingtaxshape_test.go
+++ b/internal/console/billingtaxshape_test.go
@@ -26,6 +26,7 @@ func TestACalculationCarriesItsTaxCodesInOneOfTwoShapes(t *testing.T) {
"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},
diff --git a/internal/console/billingtaxsplit.go b/internal/console/billingtaxsplit.go
index 71300c7a3..b27a0a3c5 100644
--- a/internal/console/billingtaxsplit.go
+++ b/internal/console/billingtaxsplit.go
@@ -273,3 +273,75 @@ func lastSpansCodeLines(lines []plans.OverageLine, code string) ([]plans.Overage
}
return out, nil
}
+
+// twoCodeBody is one answer to decision 16: given a calculation's lines
+// as priced today and the per-code weights of the month, the lines each
+// carrying its code. splitLinesByGrossUsage is 16b's proposal;
+// lastSpansCodeLines, bound to the last span's code, is the month-end
+// answer as 16c would settle it.
+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) {
+ 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
index 91941f33d..854308801 100644
--- a/internal/console/billingtaxsplit_test.go
+++ b/internal/console/billingtaxsplit_test.go
@@ -6,6 +6,7 @@ import (
"fmt"
"math"
"reflect"
+ "strings"
"testing"
"time"
@@ -146,6 +147,7 @@ func TestAnAxisIsApportionedWithoutRoundingBeyondIt(t *testing.T) {
"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) {
@@ -218,3 +220,101 @@ func TestTheTwoCodeBodiesKeepTheSubtotalAndTheShape(t *testing.T) {
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)
+ }
+}