The lookup past the replay window does not void; what it finds is recorded and reviewed; a void needs the invoice's whole identity and a real subtotal
From the first review of this branch: - recoverFinalize is read-only again. It ran with no journal entry, no authorization and no send deadline, and could send a void. It now records an invoice found open or paid with a wrong total as that outcome, and perform acknowledges it and files a review instead of returning a bare error on every visit. - finalizedOrVoided voids only an invoice that passes every identity check and carries a subtotal the provider sent. A missing subtotal decodes as zero and is no longer read as a wrong total. - The plan's delivery bound is marked withdrawn: an idle-connection limit bounds when a request can begin to be written, not when a whole request is received. Its other overstated sentences are corrected. Still open on this branch: the void's fixed key, the sandbox probe, and three transport tests (DISPOSITIONS-late-request-fencing.md). Nothing on the launch branch calls this code. Billing is off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+142
−22
docs/superpowers/plans/2026-10-01-late-request-fencing.md+11 −8docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md+4 −4internal/billingstripe/client.go+1 −1internal/billingstripe/invoices.go+12 −2internal/console/billinginvoice.go+41 −6internal/console/billinginvoice_test.go+73 −1
diff --git a/docs/superpowers/plans/2026-10-01-late-request-fencing.md b/docs/superpowers/plans/2026-10-01-late-request-fencing.md| index d3a11b679..331e49b88 100644 |
| --- a/docs/superpowers/plans/2026-10-01-late-request-fencing.md |
| +++ b/docs/superpowers/plans/2026-10-01-late-request-fencing.md |
| @@ -34,9 +34,11 @@ The client must be built so that this is the only way a request can leave: |
| 4. **No transport retry.** A write that fails is an error. The transport never re-dials and re-sends on its own, which it would otherwise do for any request carrying an `Idempotency-Key` header. |
| 5. **HTTP/1.1.** One request per connection is then literally true. |
| -With that, a mutation reaches Stripe no later than its check plus Stripe's idle limit, `T_idle`. |
| +**Withdrawn after the first review (2026-10-01):** the sentence this section ended with, that a mutation reaches Stripe no later than its check plus Stripe's idle limit `T_idle`, is not established. What the five rules give is narrower: a request cannot BEGIN to be written after its send deadline, and a sender frozen with an established connection and nothing yet written loses that connection to the provider's idle limit. They do not bound when a complete request is received. A sender frozen after the request line or headers and before the body is in a partially sent request, and an idle-connection limit says nothing about that. Bytes already handed to the kernel are sent, or retransmitted, without the process, and a suspended machine sends them when it resumes. Several pauses with progress between them are not bounded by one idle interval. |
| -## Why that closes both defects |
| +## Why that was thought to close both defects |
| + |
| +The argument below is kept as written, for the record. It depends on the withdrawn sentence above, so it does not hold as it stands. The invoice half has a second fault: it counts the key's memory from another sender's copy at `t2 ≥ t1`, but a key may have been first used long before `t1` (a retry at hour 21 leaves about two hours, not the provider's whole idle limit); the bound has to be counted from the key's first possible use. |
| Let a process pass its check at `t1`. Its request arrives no later than `t1 + T_idle`, or never. |
| @@ -57,9 +59,9 @@ If `T_idle` turns out shorter than the lease margin (five minutes), the key fenc |
| ## What is built with it |
| - `internal/billingstripe`: the transport above for every POST; `WithSendBy(ctx, t)` carries the deadline; a test with a server that closes idle connections shows a request held after its check is never processed and never re-sent. |
| -- The quantity worker and the invoice engine pass their lease ends as the send deadline. |
| +- The quantity worker and the invoice engine each fix a send deadline as one absolute instant before the lease or authorization is taken: the lease's margin (quantity) or the pass's end (invoice), capped by the operation's replay cutoff. (At the first review the quantity deadline was rebuilt from a later clock reading and the invoice engine passed none.) |
| - `TestSandboxQuantityFence` gains the idle measurement. |
| -- The invoice engine voids an invoice whose finalized total fails verification, in the pass that finds it. With the mechanism above that state should be unreachable; the void is there so that if it is ever reached, the invoice is not left payable. |
| +- The invoice engine voids an invoice whose finalized total fails verification, in the pass that finds it. The mechanism above does not make that state unreachable. The void shortens how long such an invoice is payable; it does not ensure it is not left payable (see "What the void does not do"). |
| - The simulation's fake provider forgets keys after 24 hours, so a schedule that relied on a permanent key fails. |
| ## Built so far on the branch (2026-10-01, not gated, not reviewed) |
| @@ -80,14 +82,15 @@ A wrong-total invoice that has been finalized is open at the provider from that |
| | Case | How long the wrong invoice is payable | What the engine does | Test | |
| | --- | --- | --- | --- | |
| | The void follows the finalize in the same visit | one round trip | voids, records `billing_invoice_voided_wrong_total`, never charges | `TestInvoiceEngineVoidsAFinalizedInvoiceWithAWrongTotal` | |
| -| The process dies between the finalize and the void | until the next visit to that account | voids then | `TestInvoiceWrongTotalIsPayableUntilTheNextVisitWhenTheProcessDiesBeforeTheVoid` | |
| +| The process dies between the finalize and the void, and the next visit is within the replay window (22 hours) | until that visit | voids then | `TestInvoiceWrongTotalIsPayableUntilTheNextVisitWhenTheProcessDiesBeforeTheVoid` | |
| | The customer pays in that window | it was paid | cannot void; records `billing_invoice_paid_wrong_total`; the month is not marked complete; a person refunds | `TestInvoiceWrongTotalPaidByTheCustomerBeforeTheVoidIsHeldNotCompleted` | |
| -| The void request fails or times out | until a later visit's void lands | retries the void on the next visit | `TestInvoiceWrongTotalWhenTheVoidFails`, first two cases | |
| +| The next visit comes after the replay window | until a person voids it | the read-only lookup records `open_wrong_total` (or `paid_wrong_total`) and files a review; it does not void | `TestInvoiceWrongTotalFoundPastTheReplayWindowIsRecordedAndReviewed` | |
| +| The void request fails or times out | until a later visit's void lands; if the provider remembers a 5xx for the void's key, that is up to 24 hours, because the key is fixed (open) | retries the void on the next visit | `TestInvoiceWrongTotalWhenTheVoidFails`, first two cases | |
| | The provider refuses the void | until a person acts | records a review, never charges, month not complete | `TestInvoiceWrongTotalWhenTheVoidFails`, third case | |
| -So after the void there is still customer-payable exposure in four of five cases, and a customer who pays is owed a refund. Only prevention closes the defect: no line may land on the draft between the line check and the finalize. That rests on the transport and on the provider's unmeasured idle limit. |
| +So after the void there is still customer-payable exposure in five of six cases, and a customer who pays is owed a refund. A void is sent only for the invoice this month is bound to in every checked field, with a subtotal the provider actually sent. Only prevention closes the defect: no line may land on the draft between the line check and the finalize. As designed here that is not achieved: see the withdrawn sentence above. |
| -Not reviewed. This branch has had no independent review at all. |
| +Reviewed once, at `1772e840`, by both reviewers: rejected. `docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md` has what was fixed and what is open. |
| ## What stays true |
diff --git a/docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md b/docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md| index e80f986db..c79613c08 100644 |
| --- a/docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md |
| +++ b/docs/superpowers/specs/DISPOSITIONS-late-request-fencing.md |
| @@ -14,14 +14,14 @@ Both found the transport itself sound in what it does: no redirect, retry, conne |
| | Both, P1: no invoice request carried a send deadline at all | Fixed. Every request the invoice engine sends goes through one function, which fixes an absolute deadline before it reads the authorization: the earlier of the pass's end and the operation's replay cutoff. | `console/billinginvoice.go` `send`, `invoiceSendBy` | `TestInvoiceSendDeadlineDoesNotMoveWhileTheSenderIsSuspended`, `TestInvoiceSendByIsTheEarlierOfThePassEndAndTheReplayCutoff` | |
| | Astra P1, Opus P2: the premise. See below. | Accepted, not fixed. It is a limit of the design, not of the code. | plan document | none | |
| | Astra P1: the plan's argument for the invoice path assumes the key was first used after this sender's check; a retry at hour 21 leaves about two hours, not the provider's whole idle limit | Accepted, not fixed. The bound must be counted from the key's first possible use. Open. | plan document | none | |
| -| Both, P2: a void made on the recovery path past 22 hours is returned as an error before it is acknowledged, so it is neither recorded nor reviewed, and the visit repeats | Accepted, not fixed. Open. | `console/billinginvoice.go` `recoverFinalize`, `finalizedOrVoided` | none | |
| -| Astra P2: an invoice response with a missing subtotal, or failing on mode, currency or metadata, can authorize voiding a correct invoice | Accepted, not fixed. Open. The void must require complete monetary fields and the full billing identity. | `console/billinginvoice.go`; `billingstripe/invoices.go` | none | |
| +| Both, P2: a void made on the recovery path past 22 hours is an unjournaled, unauthorized write, and its outcome is returned as an error before it is acknowledged, so it is neither recorded nor reviewed, and the visit repeats | Fixed in the commit after `a64517ba`. The lookup past the replay window is read-only again: it does not void. What it finds (open or paid with a wrong total) is acknowledged on the operation and filed as a review. An invoice found open that way stays payable until a person voids it. | `console/billinginvoice.go` `perform`, `recoverFinalize`, `finalizedOrVoided` | `TestInvoiceWrongTotalFoundPastTheReplayWindowIsRecordedAndReviewed` | |
| +| Astra P2: an invoice response with a missing subtotal, or failing on mode, currency or metadata, can authorize voiding a correct invoice | Fixed in the same commit. A void needs the invoice to pass every identity check `verifyInvoice` makes and to carry a subtotal the provider actually sent. | `console/billinginvoice.go` `finalizedOrVoided`; `billingstripe/invoices.go` `HasSubtotal` | `TestInvoiceWithNoSubtotalInTheAnswerIsNotVoided` | |
| | Opus P2: the void uses a fixed key, so a remembered 5xx replays for 24 hours | Accepted, not fixed. Open. | `console/billinginvoice.go` | none | |
| | Both, P2: the sandbox idle probe can log VERIFIED without having held a connection, and has no positive control | Accepted, not fixed. Open. It has never been run. | `billingstripe/sandbox_fence_test.go` | none | |
| | Astra P3, Opus P3: transport tests that do not exercise what their names say (the hold placed before the check; the proxy never exercised; a response-timeout test that can pass on a dial timeout) | Accepted, not fixed. Open. | `billingstripe/transport_test.go` | none | |
| -| Both, claims: the plan says both workers pass their lease ends; that the void ensures the invoice is not left payable; `client.go` says status 0 means the request never reached Stripe | The first is now true. The other two are wrong and not yet corrected in the text. Open. | plan document, `client.go` | none | |
| +| Both, claims: the plan says both workers pass their lease ends; that the void ensures the invoice is not left payable; that a mutation arrives within the idle limit; `client.go` says status 0 means the request never reached Stripe | Corrected. The plan's delivery bound is marked withdrawn and its argument kept only as a record. | plan document, `client.go` | none | |
| -Mutation checks in a scratch copy, each failing its test: the quantity deadline rebuilt from the clock after the claim; the invoice request sent without its deadline. |
| +Mutation checks in a scratch copy, each failing its test: the quantity deadline rebuilt from the clock after the claim; the invoice request sent without its deadline; the recovered outcome not acknowledged; the lookup voiding; the missing-subtotal check off. |
| ## The premise |
diff --git a/internal/billingstripe/client.go b/internal/billingstripe/client.go| index 8fdebcb0e..935081c26 100644 |
| --- a/internal/billingstripe/client.go |
| +++ b/internal/billingstripe/client.go |
| @@ -65,7 +65,7 @@ const ( |
| // stable and safe to log: no response body, no request body, no secret key. |
| type Error struct { |
| Code string |
| - HTTPStatus int // 0 when the request never reached Stripe |
| + HTTPStatus int // 0 when no response was read: the request may or may not have reached Stripe |
| StripeType string // Stripe's error "type" field, present only for CodeAPIError |
| StripeCode string // Stripe's error "code" field, present only for CodeAPIError |
| // StripeParam is Stripe's error "param" field: the request parameter |
diff --git a/internal/billingstripe/invoices.go b/internal/billingstripe/invoices.go| index 1b745757d..93de71737 100644 |
| --- a/internal/billingstripe/invoices.go |
| +++ b/internal/billingstripe/invoices.go |
| @@ -25,8 +25,11 @@ type Invoice struct { |
| AmountPaid int64 `json:"amount_paid"` |
| // Discounts is left raw: the engine only ever needs to prove it is empty |
| // (an overage invoice inherits no coupon), never to interpret one. |
| - Discounts json.RawMessage `json:"discounts"` |
| - Metadata map[string]string `json:"metadata"` |
| + Discounts json.RawMessage `json:"discounts"` |
| + Metadata map[string]string `json:"metadata"` |
| + // HasSubtotal says the response carried a subtotal. It is not on the |
| + // wire; the decoder sets it. |
| + HasSubtotal bool `json:"-"` |
| AutomaticTax struct { |
| Enabled bool `json:"enabled"` |
| Status string `json:"status"` |
| @@ -71,11 +74,18 @@ func (x *Invoice) UnmarshalJSON(data []byte) error { |
| if err := json.Unmarshal(data, &value); err != nil { |
| return &Error{Code: CodeDecodeError} |
| } |
| + // Whether the response carried a subtotal at all. A missing one decodes |
| + // as zero, and zero is not a subtotal anybody may act on. |
| + var present struct { |
| + Subtotal *int64 `json:"subtotal"` |
| + } |
| + _ = json.Unmarshal(data, &present) |
| if value.LiveMode == nil || !validStripeObject(value.ID, "in_") || !validStripeObject(value.Customer, "cus_") || value.Status == "" { |
| return &Error{Code: CodeDecodeError} |
| } |
| *x = Invoice(value.wire) |
| x.LiveMode = *value.LiveMode |
| + x.HasSubtotal = present.Subtotal != nil |
| return nil |
| } |
diff --git a/internal/console/billinginvoice.go b/internal/console/billinginvoice.go| index 27addc7f6..565d9a850 100644 |
| --- a/internal/console/billinginvoice.go |
| +++ b/internal/console/billinginvoice.go |
| @@ -731,6 +731,17 @@ func (r *billingInvoiceRun) perform(ctx context.Context, p providerOp) error { |
| if errors.Is(err, errBillingDuplicate) { |
| return r.review("billing_invoice_duplicate") |
| } |
| + if err != nil && objectID != "" && errors.Is(err, errBillingInvoiceMismatch) { |
| + // The lookup found the object, and it is not what was asked for. |
| + // That is an outcome, and it goes on record with a review, as it |
| + // does when the answer comes straight back from the request |
| + // (send). Returned as a bare error it would be looked up again on |
| + // every visit and reach nobody. |
| + if aerr := r.acknowledge(p, op, objectID, outcome); aerr != nil { |
| + return aerr |
| + } |
| + return r.review("billing_invoice_mismatch") |
| + } |
| if err != nil { |
| return err |
| } |
| @@ -1009,7 +1020,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| if err != nil { |
| return "", "", err |
| } |
| - return r.finalizedOrVoided(ctx, inv) |
| + return r.finalizedOrVoided(ctx, inv, true) |
| }, |
| recover: r.recoverFinalize, |
| ack: r.ackFinalized, |
| @@ -1021,7 +1032,7 @@ func (r *billingInvoiceRun) stepDraft(ctx context.Context) error { |
| return "", "", "", "", err |
| } |
| if inv.Status != "draft" { |
| - id, status, verr := r.finalizedOrVoided(ctx, inv) |
| + id, status, verr := r.finalizedOrVoided(ctx, inv, true) |
| return id, status, "", "", verr |
| } |
| return "", "", "", "billing_invoice_refused", nil |
| @@ -1113,7 +1124,11 @@ func (r *billingInvoiceRun) recoverFinalize(ctx context.Context, op billingOpera |
| } |
| return "", "", nil |
| } |
| - return r.finalizedOrVoided(ctx, inv) |
| + // The lookup past the replay window is read-only, as every recover is: |
| + // it runs with no journal entry, no authorization and no send deadline, |
| + // so it writes nothing. A wrong-total invoice it finds still open is |
| + // recorded as that and put in front of a person. |
| + return r.finalizedOrVoided(ctx, inv, false) |
| } |
| // finalizedOrVoided verifies an invoice the provider reports as finalized. |
| @@ -1140,7 +1155,7 @@ func (r *billingInvoiceRun) recoverFinalize(ctx context.Context, op billingOpera |
| // that may succeed later leaves the finalize unacknowledged, and the next |
| // visit comes back here. A void the provider refuses outright is followed |
| // by a read, so what is recorded is what the invoice is now. |
| -func (r *billingInvoiceRun) finalizedOrVoided(ctx context.Context, inv *billingstripe.Invoice) (string, string, error) { |
| +func (r *billingInvoiceRun) finalizedOrVoided(ctx context.Context, inv *billingstripe.Invoice, mayVoid bool) (string, string, error) { |
| verr := r.verifyFinalized(inv) |
| if verr == nil { |
| return inv.ID, inv.Status, nil |
| @@ -1148,13 +1163,23 @@ func (r *billingInvoiceRun) finalizedOrVoided(ctx context.Context, inv *billings |
| if inv == nil { |
| return "", "", verr |
| } |
| - if inv.ID != r.rec.InvoiceID || inv.Customer != r.rec.Customer || inv.Subtotal == r.calc.Invoice.TotalCents { |
| + // "Wrong total" is a finding about THIS month's invoice, and it needs |
| + // the whole of that identity and a subtotal the provider actually |
| + // sent: the invoice is the one this month is bound to in every field |
| + // verifyInvoice checks (customer, mode, currency, metadata, digest), |
| + // its status is open or paid, and its subtotal is present and is not |
| + // the frozen total. A response that fails on anything else, or carries |
| + // no subtotal, is a mismatch for a person and is never grounds to void. |
| + if r.verifyInvoice(inv, "open", "paid") != nil || !inv.HasSubtotal || inv.Subtotal == r.calc.Invoice.TotalCents { |
| return inv.ID, inv.Status, verr |
| } |
| switch inv.Status { |
| case "paid": |
| return inv.ID, billingInvoicePaidWrongTotal, verr |
| case "open": |
| + if !mayVoid { |
| + return inv.ID, billingInvoiceOpenWrongTotal, verr |
| + } |
| default: |
| return inv.ID, inv.Status, verr |
| } |
| @@ -1182,6 +1207,12 @@ func (r *billingInvoiceRun) finalizedOrVoided(ctx context.Context, inv *billings |
| // invoice was paid with a subtotal that is not the frozen total. |
| const billingInvoicePaidWrongTotal = "paid_wrong_total" |
| +// billingInvoiceOpenWrongTotal is the outcome recorded when a lookup past |
| +// the replay window finds the invoice finalized, open, and with a subtotal |
| +// that is not the frozen total. The lookup does not void it: the invoice |
| +// stays payable until a person voids it, and the review says so. |
| +const billingInvoiceOpenWrongTotal = "open_wrong_total" |
| + |
| func (r *billingInvoiceRun) ackFinalized(_, outcome string) (string, string, string) { |
| switch outcome { |
| case "open": |
| @@ -1196,6 +1227,10 @@ func (r *billingInvoiceRun) ackFinalized(_, outcome string) (string, string, str |
| // The customer paid an invoice whose total was wrong. The month is |
| // not complete: a person has to refund and reissue. |
| return "", "", "billing_invoice_paid_wrong_total" |
| + case billingInvoiceOpenWrongTotal: |
| + // Found open with a wrong total by a read-only lookup. It is still |
| + // payable: a person has to void it. |
| + return "", "", "billing_invoice_open_wrong_total" |
| } |
| return "", "", "billing_invoice_mismatch" |
| } |
| @@ -1215,7 +1250,7 @@ func (r *billingInvoiceRun) stepFinalizing(ctx context.Context) error { |
| if err != nil { |
| return "", "", err |
| } |
| - return r.finalizedOrVoided(ctx, inv) |
| + return r.finalizedOrVoided(ctx, inv, true) |
| }, |
| recover: r.recoverFinalize, |
| ack: r.ackFinalized, |
diff --git a/internal/console/billinginvoice_test.go b/internal/console/billinginvoice_test.go| index e6cb826bb..b7505b780 100644 |
| --- a/internal/console/billinginvoice_test.go |
| +++ b/internal/console/billinginvoice_test.go |
| @@ -44,6 +44,7 @@ type fakeInvoiceStripe struct { |
| dropRequest map[string]bool // do not apply, answer 502 once |
| refuse map[string]int // definitive HTTP status once |
| extraLine bool |
| + omitSubtotal bool // finalized invoices are answered with no subtotal field |
| listDuplicate bool |
| hasMoreLines bool |
| seq int |
| @@ -98,9 +99,13 @@ func (f *fakeInvoiceStripe) invoiceJSON(inv *fakeInvoice, allLines bool) map[str |
| if inv.subscription != "" { |
| sub = inv.subscription |
| } |
| - return map[string]any{"id": inv.id, "customer": inv.customer, "subscription": sub, "livemode": false, "status": inv.status, "currency": "eur", "auto_advance": false, |
| + out := map[string]any{"id": inv.id, "customer": inv.customer, "subscription": sub, "livemode": false, "status": inv.status, "currency": "eur", "auto_advance": false, |
| "collection_method": "charge_automatically", "created": inv.created, "subtotal": subtotal, "total": total, "amount_due": due, "amount_paid": paid, "discounts": []any{}, |
| "metadata": inv.metadata, "automatic_tax": map[string]any{"enabled": true, "status": taxStatus}, "lines": map[string]any{"data": lines, "has_more": f.hasMoreLines && !allLines && len(inv.lines) > 1}} |
| + if f.omitSubtotal && inv.status != "draft" { |
| + delete(out, "subtotal") |
| + } |
| + return out |
| } |
| func (f *fakeInvoiceStripe) fault(kind string, w http.ResponseWriter, apply func() any, key string) { |
| @@ -1870,3 +1875,70 @@ func TestInvoiceSendByIsTheEarlierOfThePassEndAndTheReplayCutoff(t *testing.T) { |
| t.Fatalf("an operation thirty seconds from its replay cutoff: %s, want %s", got, want) |
| } |
| } |
| + |
| +// The finalize answer is lost and the engine comes back after the replay |
| +// window, when only a read-only lookup may settle what happened. It finds |
| +// the invoice finalized with a wrong total. It does not void it there (that |
| +// lookup has no journal entry, no authorization and no send deadline), and |
| +// it does not quietly look again on every visit either: the outcome goes on |
| +// record and a review puts it in front of a person. Open, the invoice is |
| +// still payable, and the review says which it is. |
| +func TestInvoiceWrongTotalFoundPastTheReplayWindowIsRecordedAndReviewed(t *testing.T) { |
| + for _, paid := range []bool{false, true} { |
| + ix := wrongTotalFixture(t) |
| + ix.fake.loseResponse["finalize"] = true |
| + if err := ix.run(t); err == nil { |
| + t.Fatalf("paid %v: the visit whose finalize answer was lost reported success", paid) |
| + } |
| + if got := ix.invoiceStatus(t); got != "open" { |
| + t.Fatalf("paid %v: fixture: the invoice is %q, want open", paid, got) |
| + } |
| + if paid { |
| + ix.fake.mu.Lock() |
| + for _, inv := range ix.fake.invoices { |
| + inv.status = "paid" |
| + } |
| + ix.fake.mu.Unlock() |
| + } |
| + ix.now = ix.now.Add(25 * time.Hour) |
| + if err := ix.run(t); err == nil { |
| + t.Fatalf("paid %v: the recovery of a wrong-total invoice reported success", paid) |
| + } |
| + if _, mutations, _ := ix.fake.counts(); mutations["void"] != 0 { |
| + t.Fatalf("paid %v: mutations %v: the read-only lookup sent a void", paid, mutations) |
| + } |
| + want, outcome := "billing_invoice_open_wrong_total", billingInvoiceOpenWrongTotal |
| + if paid { |
| + want, outcome = "billing_invoice_paid_wrong_total", billingInvoicePaidWrongTotal |
| + } |
| + if !ix.hasReview(t, want) { |
| + t.Fatalf("paid %v: no review %s: %v", paid, want, ix.reviews(t)) |
| + } |
| + if op := ix.ops(t)["finalize"]; op.State != billingOpAcked || op.Outcome != outcome { |
| + t.Fatalf("paid %v: the finalize is not on record as %s: %+v", paid, outcome, op) |
| + } |
| + if rec := ix.month(t); rec.State == "complete" || rec.State == "open" { |
| + t.Fatalf("paid %v: the month moved on: %+v", paid, rec) |
| + } |
| + } |
| +} |
| + |
| +// A finalized invoice whose answer carries no subtotal is not "an invoice |
| +// with a wrong total": zero is what a missing field decodes to. It is a |
| +// mismatch for a person, and it is never voided on that. |
| +func TestInvoiceWithNoSubtotalInTheAnswerIsNotVoided(t *testing.T) { |
| + ix := newInvoiceFixture(t) |
| + ix.fake.omitSubtotal = true |
| + if err := ix.run(t); err == nil { |
| + t.Fatal("a finalize answered with no subtotal reported success") |
| + } |
| + if _, mutations, _ := ix.fake.counts(); mutations["void"] != 0 || mutations["pay"] != 0 { |
| + t.Fatalf("mutations %v: a correct invoice was voided, or one with an unverified total charged", mutations) |
| + } |
| + if got := ix.invoiceStatus(t); got != "open" { |
| + t.Fatalf("the invoice is %q", got) |
| + } |
| + if !ix.hasReview(t, "billing_invoice_mismatch") { |
| + t.Fatalf("no review: %v", ix.reviews(t)) |
| + } |
| +} |