carlos / platform Public

Multi-currency: the eighth review's findings

The binder judges ownership first, then what the session says about the
purchase, so an owned completed session whose terms version changed with
its currency files the one review instead of being refused in silence.
The startup sweep clears a quantity-history gap it marked under an
earlier list of outcomes when every processed event carries an outcome
the list admits now, so a binary rolled back across a resolved refusal
and forward again heals; the roll note says no earlier binary may start
after one. The refused session's id is carried through the review reader
and the operator API, and a review from an earlier build learns it when
the session is met again. The comments, the fixtures and the
dispositions say what the code does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f8a23ef3b1a7ca8c6a3efad41269c8059e1531a4 parent 3e941f0
10 files changed, +462 −31
  • docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md +1 −1
  • docs/superpowers/specs/CODEX-FINDINGS-multi-currency-review8.md +123 −0
  • docs/superpowers/specs/DISPOSITIONS-multi-currency.md +21 −3
  • docs/superpowers/specs/OPUS-REVIEW-multi-currency-review8.md +104 −0
  • internal/console/billingcurrency_test.go +163 −4
  • internal/console/billinghandlers.go +4 −2
  • internal/console/billingoperator.go +5 −3
  • internal/console/billingquantityaudit.go +12 −0
  • internal/console/billingservice.go +14 −11
  • internal/console/billingstore.go +15 −7

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md
index dc3a71370..c182d4055 100644
--- a/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md
+++ b/docs/superpowers/specs/2026-10-01-multi-currency-price-book-design.md
@@ -146,7 +146,7 @@ func PublicPriceBook(book string) ([]byte, error) // the export below, byte-stab
New columns, both `TEXT NOT NULL DEFAULT ''`, on `billing_checkout_intents` and `billing_accounts`: `currency`, `price_book`. Empty in both means a legacy row. Assignments of a purchase priced from a book go in a new table, `billing_book_assignments`, with the same columns plus `currency` and `price_book` required non-empty; `billing_assignments` keeps only legacy assignments. The split is for rollback: a build from before books reads `billing_assignments` alone, so it finds no history for a book-priced purchase. For an account with both kinds of history it would find the legacy half, so a database trigger (`billing_months_no_legacy_calc_over_paid_book_history`; the earlier, wider `..._over_book_history` is dropped at migration) refuses to freeze a calculation whose top-level `v` is not 2 (read with `json_extract`) for any month into which an active book-priced assignment of the account was priced: its span, from its effective instant to the earliest of the next assignment's effective instant (by time then revision, over both tables), its paid-through instant and the month's end, has length inside the month, which is `AllowanceForMonth`'s own rule. A book assignment that never counted for a month (cancelled before its first reconciliation, or superseded in the same second) is left out, so this build's own version 1 for a legacy-only month after it still goes in. The environment and the binary change together, and no binary from before books runs against a database with a book-priced row (roll note in the dispositions).
-- **Checkout** (`POST /a/{sqid}/billing/checkout`): one form, with the country and currency controls, the plan, the postal code and the consent. The form carries what the page priced (`quoted_country`, `quoted_currency`) and whether the currency shown was a choice (`currency_chosen`). A post whose country or currency is not what the page priced, judged on what would be bought (the chosen currency, else the posted country's own), or whose quote the page did not sign (`quote_sig`: an HMAC over the session token, the account, the book, the quoted country and its band at render time, the quoted currency, the choice flag and the member count the page priced at, keyed by a secret derived from the billing secret key, which only the server holds), is not a purchase: it is answered with a redirect to the page quoting the posted country (and the posted currency when it is a choice), so nothing is bought at a price a page did not show this session. The member count the page priced at is compared again under the membership lock when the door charges; a count that moved is a conflict (`?error=conflict`, the existing banner), not a higher charge. A currency shown as the country's default and not chosen is given to the door as no currency, and the billing country's own currency is bought in; a chosen one (the URL's, the pricing page's, the account's, or changed on the form) is the purchase's. A currency field that is present and is not exactly `usd`, `eur` or `gbp` (no trimming, no case folding, not empty): 400, nothing is created, before the quote is looked at; on the page, a `?currency=` that names such a thing is said (`billing.currency.unknown`, unreviewed system copy like `billing.quote.unknown`) and the default shown is not a choice. The quote comparison is made on what would be bought (the chosen currency, else the posted country's own), so a form whose fields disagree with each other, a choice flag erased, or a form with no currency control against a page that priced a choice, is answered with the quote for what it would buy. The intent stores the chosen currency and book. A retry with a different currency while an intent is open is a different intent, exactly as a different plan is today. `POST /a/{sqid}/billing/quote` is the form's "Show prices for this country" button: the same form, answered with the same redirect, buying nothing; it asks for the session and the form's token, no step-up.
+- **Checkout** (`POST /a/{sqid}/billing/checkout`): one form, with the country and currency controls, the plan, the postal code and the consent. The form carries what the page priced (`quoted_country`, `quoted_currency`) and whether the currency shown was a choice (`currency_chosen`). A post whose country or currency is not what the page priced, judged on what would be bought (the chosen currency, else the posted country's own), or whose quote the page did not sign (`quote_sig`: an HMAC over the session token, the account, the book, the quoted country and its band at render time, the quoted currency, the choice flag and the member count the page priced at, keyed by a secret derived from the billing secret key, which only the server holds), is not a purchase: it is answered with a redirect to the page quoting the posted country (and the posted currency when it is a choice), so nothing is bought at a price a page did not show this session. The member count the page priced at is compared again when the door charges (under the billing lock; the intent snapshot's membership-locked check then holds that count against the account); a count that moved is a conflict (`?error=conflict`, the existing banner), not a higher charge. A currency shown as the country's default and not chosen is given to the door as no currency, and the billing country's own currency is bought in; a chosen one (the URL's, the pricing page's, the account's, or changed on the form) is the purchase's. A currency field that is present and is not exactly `usd`, `eur` or `gbp` (no trimming, no case folding, not empty): 400, nothing is created, before the quote is looked at; on the page, a `?currency=` that names such a thing is said (`billing.currency.unknown`, unreviewed system copy like `billing.quote.unknown`) and the default shown is not a choice. The quote comparison is made on what would be bought (the chosen currency, else the posted country's own), so a form whose fields disagree with each other, a choice flag erased, or a form with no currency control against a page that priced a choice, is answered with the quote for what it would buy. The intent stores the chosen currency and book. A retry with a different currency while an intent is open is a different intent, exactly as a different plan is today. `POST /a/{sqid}/billing/quote` is the form's "Show prices for this country" button: the same form, answered with the same redirect, buying nothing; it asks for the session and the form's token, no step-up.
- **A currency already held**: checkout is refused while the account's subscription has not ended (`canceled` or `incomplete_expired`), and after it has ended a checkout in any currency but the one bought in is refused, the billing country's default included; the page offers that currency alone, as a choice, for a purchase from before price books too (EUR). A change of currency waits on what Stripe permits for a customer whose subscription ended (test-mode item 4) and is then an operator's door, not the form's.
- **Stripe price identity**: the configured price for (book, plan, band, currency) must have that `currency`, `unit_amount` equal to the book's unit price, no `currency_options` but, at most, an entry under its own currency at its own amount and tax behaviour (which an expanded read of a single-currency price may carry), and metadata `price_book`, `currency`, `terms_version`, `plan`, `band` that all match. The checks that exist today (per-unit, yearly, licensed, exclusive tax, active, mode) stay.
- **Checkout Session**: created with `currency` and `adaptive_pricing[enabled]=false`, and with `currency`, `price_book` in the session and subscription metadata. A purchase with no book (only a console with no book configured opens one, and an intent from before books is replayed as that build sent it) is asked for exactly as before: no currency, Adaptive Pricing on. Every session the console creates, replays, lists, reads back or binds passes one check against its intent; one that is this intent's and not its purchase (another currency, a conversion, Adaptive Pricing) is expired and the intent retired, and one already paid is left for a person with a review filed once. A subscription whose price is not the configured one for its stored (book, plan, band, currency), or that the provider reports in another currency, is an identity mismatch.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-multi-currency-review8.md b/docs/superpowers/specs/CODEX-FINDINGS-multi-currency-review8.md
new file mode 100644
index 000000000..6cc105246
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-multi-currency-review8.md
@@ -0,0 +1,123 @@
+| Seventh-review item | Closed at `3e941f00`? | Would its test pass without its fix? |
+|---|---|---|
+| **Opus P2-1 / Astra 1 — resolved quantity events still hold the account** | **Yes.** `quantityOutcomesSQL` supplies the guard, startup audit and obligation check: `internal/console/billingquantityaudit.go:22`, `:43`, `:54`, `:58`. No remaining independent outcome whitelist found. | **No.** `billingcurrency_test.go:2856` checks the obligation, `:2861` freezes a month, and `:2864` reruns migration and checks for history gaps. |
+| **Opus P3-1 / Astra 2 — binder’s second read switches accounts** | **Yes.** Both pass paths carry the original intent into binding. Its account, client reference, customer, intent and session identity are checked before deriving an account from the response: `billingservice.go:929`, `billingpass.go:200`, `:259`. | **No.** The two-response case at `billingcurrency_test.go:2973` distinguishes removing that protection. |
+| **Opus P3-2 / Astra 3 — unbound refused session identity is lost** | **Partly.** Newly filed reviews and their retirement records preserve it. Existing open reviews and operator visibility remain incomplete: findings 2–3 below. | **No** for removing either new persistence step: `billingcurrency_test.go:2929`, `:2942`. **Yes** for the remaining defects: the test uses a fresh review and reads SQL directly. |
+| **Opus P3-3 — signature omits the country’s band** | **Yes.** Rendered band is authenticated; checkout recomputes the current band: `billingpage.go:271`, `:454`, `billinghandlers.go:96`. | **No.** Changing India’s band between render and POST at `billingcurrency_test.go:2075` distinguishes the omission. |
+| **Opus’s claims** | **Partly corrected.** Outcome handling, the trigger’s source comment, unused session parameter and repeated-resolution test comment are corrected. Operator access, blanket test claims and membership-lock wording still need qualification below. | Comment corrections have no behavioral test. The account-wide-delete mutation still survives the repeated-resolution assertion. |
+| **Astra’s claims** | **Partly corrected.** Quote secrecy, authenticated membership and late-filing protection hold. Several dispositions claims remain inaccurate, and the common refusal handling remains incomplete. | The signature/member-count regressions distinguish their fixes. Migration ordering, provisioning behavior and inclusion-assertion removal retain the limitations below. |
+
+**Astra’s P2 reading of the second-read substitution was better supported.** The old behavior could bind account B while processing account A’s intent. Opus correctly identified that inconsistent provider responses were required, but the consequence crossed account ownership; it was not merely a diagnostic defect. Both agreed on the quantity-outcome P2 and lost-reference P3.
+
+All platform references below are at `3e941f00`; pricing-site references are at `9427c22`. The requested `git diff 3e941f00 3e941f00` is empty. Test counterfactuals are source judgments: no tests, writes or network commands were run.
+
+**Findings, most severe first**
+
+1. **P2 — A completed wrong-currency session still bypasses its account review when `terms_version` also changes.**
+
+ **Where:** `internal/console/billingservice.go:957`, before the ownership and refusal handling at `:962` and `:965`.
+
+ **Scenario:** A bound GBP checkout completes. The provider response retains its session ID, mode, customer, account reference and `checkout_intent`, but reports EUR and a different—or missing—`metadata.terms_version`.
+
+ The pass’s `ownSession` check succeeds. Binding then returns at line 957, before filing the refusal review. Repeated passes, direct bindings and completion webhooks leave the paid intent standing without the account review that enables its settlement and retirement. The webhook records an identity rejection with account ID zero (`billinghandlers.go:312`); that does not provide the refused-intent resolution workflow.
+
+ Interactive resume handles the same response differently: `intentSession` fails and `refuseSession` files the review.
+
+ **Smallest acceptable fix:** Establish ownership first, then route the terms-version mismatch through the existing owned-completion refusal branch. Add independent pass and fresh-webhook cases asserting one review, no activation and no expiry.
+
+ **Test gap:** `billingcurrency_test.go:2341` changes metadata currency, not terms version. It passes with this defect. This remains a variant of review four’s Astra finding 6.
+
+2. **P3 — The recorded refused-session reference is unavailable through the operator API.**
+
+ **Where:** `internal/console/billingoperator.go:61`, `:69`, `:171`, `:196`; `internal/console/billingstore.go:771`.
+
+ **Scenario:** CreateCheckout’s response is lost. Listing recovery finds its paid, wrong-currency session and stores the session ID in `billing_reviews.reference`. The operator’s review response still contains only the intent ID in `subscription`: neither the store review model nor the API response includes `reference`.
+
+ The operator must still search provider metadata or inspect the database to identify the payment. The new persistence fixes the underlying audit record but does not deliver the promised operational result.
+
+ **Smallest acceptable fix:** Carry `Reference` through the review readers and API response, and expose it in the corresponding CLI output. Assert the operator API’s response in the unbound-refusal test.
+
+ **Test gap:** `billingcurrency_test.go:2929` queries the database directly. Its comment at `:2910` claims an operator benefit it does not test.
+
+3. **P3 — An already-open unbound refusal never acquires its session reference after migration.**
+
+ **Where:** `internal/console/billingstore.go:242`, `:973`, `:982`, `:927`.
+
+ **Scenario:** A database from the seventh build contains an open refusal review for an unbound intent. Migration adds `reference=''`. A subsequent pass observes the actual session again, but `fileCheckoutRefusedReview` returns immediately because the review is already open. Resolution consequently writes another retirement record with an empty session ID.
+
+ **Smallest acceptable fix:** Within the filing transaction, fill an existing open review’s empty reference before returning, without increasing its occurrence count. Add a migration/reobservation/resolution case.
+
+ **Test gap:** `TestUnboundRefusedSessionIsRecordedByItsId` starts with no review, so it cannot distinguish this defect.
+
+**Other requested paths**
+
+- **A — GBP purchase end to end.** `GET ?country=IN&currency=gbp` produces band D, chosen GBP and Personal at £7.70 (`internal/console/billingpage.go:265`). The one form carries that quote; checkout verifies its effective currency and signature (`billinghandlers.go:81`, `:102`). `startBillingCheckoutQuoted` prices and stores GBP (`billingservice.go:417`, `:428`, `:522`). `checkoutParams:221` supplies it, and `internal/billingstripe/client.go:622` sends GBP with Adaptive Pricing disabled.
+
+ Binding checks the session’s price terms and fixed currency (`billingservice.go:965`). Reconciliation checks subscription/item currencies, intent terms and the completed session (`:748`, `:768`, `:789`). Account and assignment writes retain the identity (`billingstore.go:594`, `:725`); history reads it (`billinginvoicestore.go:530`); `AllowanceForMonth` preserves one paid commercial identity (`internal/plans/month.go:167`). Version-2 calculation uses its GBP rates and floor (`billingcalculation.go:174`). Invoice parameters, recovery, verification and hashes use the frozen currency (`billinginvoice.go:859`, `:886`, `:901`, `:961`, `:1024`). Quantity reads and acknowledgements check the stored price/currency (`billingquantity.go:1014`, `:1105`). **I found no ordinary conversion or historical repricing path.**
+
+- **B/M/Q/U/Z — One form and authenticated quote.** Changed country/currency, fabricated agreeing quote fields, and forged choice flags cannot authorize an unseen quote. A present currency field must contain exactly one supported lowercase value; empty, padded, uppercase and repeated values receive 400 on both doors after their preceding authentication/form gates (`billinghandlers.go:174`). Missing currency means no explicit choice: a book console compares the resulting country default with the authenticated quote; a legacy console buys EUR.
+
+ The signature derives its key from the billing secret under `carloku/billing/quote/v1` and covers session token, account, book, country, band, currency, choice flag and member count (`billingpage.go:446`). Disclosed form fields do not permit forgery. Reuse for the identical tuple is allowed, including an old tab after another quote; changing a covered value invalidates it. A changed session token fails CSRF or signature verification.
+
+ Plan selects among displayed rows. Quantity follows from authenticated membership and the selected plan’s minimum. Postal code affects tax; the page quotes before VAT. The member count is compared under the billing lock, then the resulting intent snapshot is rechecked under the membership write lock (`billingservice.go:413`, `billingstore.go:348`, `billingmembership.go:542`). Growth produces a conflict rather than a higher unseen charge.
+
+ The quote endpoint checks session, token and membership, and builds only the escaped account billing URL (`billinghandlers.go:135`, `:163`, `:195`). It creates no checkout.
+
+- **B/C/O — Defaults, cookies and held currency.** Present-empty clears; absent falls back; invalid changes nothing (`billingcurrency.go:43`). Thus an older valid cookie can reappear after an invalid parameter followed by a parameter-absent request, but not after a successfully processed clear. Checkout POSTs do not read these cookies.
+
+ A held currency overrides suggestions and is offered alone, including EUR for a pre-book purchase (`billingpage.go:301`). Checkout refuses another currency (`billingservice.go:509`). With no choice, the posted country’s default is bought only after a matching quote; unknown-country currency defaults do not bypass billing-country validation.
+
+- **D/L/R/V/W/Y — Ownership.** The predicate checks nonempty session ID, mode, intent metadata, client reference, known customer and bound session ID (`billingservice.go:259`). Both create sites, resume paths and listing recovery apply it before consequential action. Expected-intent binding now checks both `account_sqid` and client reference against the original account before response-based account lookup (`:929`).
+
+ Webhooks have no expected intent: they derive the account, require matching client reference, and check that account’s actual intent. The already-recorded-subscription shortcut at `:950` remains before `ownSession`; it returns an existing identity for independently checked reconciliation and creates no new binding. Expiry responses use equivalent ID, mode, intent, account and customer checks before retirement commits (`billingstore.go:442`, `:465`), rather than literally calling `ownSession` again. Finding 1 is a review-routing gap, not acceptance of foreign ownership.
+
+- **S/V/X — Retirement and quantity outcomes.** Filing requires the exact intent still to exist inside the transaction (`billingstore.go:965`). Resolution selects, closes events for, records and deletes that exact intent (`:918`, `:936`, `:939`, `:943`). A late filing cannot recreate a retired intent’s review. Repeated resolution returns before touching a newer intent. The processing guard is replaced when its SQL differs, and all three outcome validators now share one list (`billingquantityaudit.go:37`, `:54`). References retain findings 2–3’s limitations.
+
+- **E/H/N/T — Barrier and rollout.** For valid writer-produced history, the trigger and `AllowanceForMonth` agree on a positive paid span:
+
+ ```text
+ max(effective_at, month_start)
+ < min(next_assignment_effective_at, period_end, month_end)
+ ```
+
+ See `billingstore.go:208` and `internal/plans/month.go:146`. Successors include both tables; same-second higher revisions make the earlier span empty. Free/inactive successors still end preceding paid spans. Renewal and quantity dating can change `effective_at`; the assignment writer copies the provider’s `period_end`, read as `PaidThrough` (`billingstore.go:612`, `:628`, `:720`; `billinginvoicestore.go:542`). I found neither requested span counterexample.
+
+ Startup creates the new trigger before transactionally confirming it and dropping the old one (`billingstore.go:208`, `:793`). Missing-new failure preserves the old trigger; a second start is idempotent. Interruption can leave both, conservatively refusing freezes; this sequence does not remove the last existing barrier. The new binary migrates an old database before using its columns. The barrier does not make arbitrary pre-book downgrades safe; coordinated rollout remains necessary.
+
+- **F/G/J — Figures, validation and export.** Displayed inclusions derive from `PriceFor`’s pooled allowance at its actual quantity (`billingpage.go:406`). Unavailable Personal is explicitly unavailable and displayed at its supported quantity. Current per-member resources remain integral after every band factor, so larger team quantities introduce no discrepancy. Fractional display units are exact within three decimals; the formatter’s truncating one-third example is not produced by current valid terms. Band-A sentences are asserted verbatim (`billingcurrency_test.go:2573`).
+
+ Book validation checks complete matrices, checked rounding arithmetic, currency coverage, duplicate keys and legacy EUR equality (`internal/plans/pricebook.go:149`, `:231`). Configuration supports complete additional catalogs with unique price IDs (`cmd/console/billing.go:106`). Provisioning accepts at least 36 configured keys and the console’s own-only currency-option shape (`implementation/carloku-paid-launch/stripe-provision.sh:424`, `:434`). The public export contains commercial data, not account/provider secrets.
+
+- **K — Pricing site.** Its pinned export is byte-identical to the platform’s committed export. Exact rate formatting, band-scaled inclusions and exactness checks agree with current terms (`copy-review/pricing/build.mjs:48`, `:103`, `:160`). Approved numeric strings are checked against the export; country options carry the exported band and default currency (`:144`, `:208`). Links always carry both parameters, with currency populated only after an explicit choice (`src/site.js:75`).
+
+ The pinned generated page carries the draft marker; both generation and the ordinary build’s checker require exactly `PRICEBOOK_ALLOW_DRAFT=1` (`build.mjs:376`, `hack/check-pricebook.mjs:7`, `package.json:6`). A locale-only GBP display can lead to a console USD default with empty suggestions: that follows ruling 9. I found no current mismatch for a selected country and explicit currency.
+
+**Claims the code contradicts**
+
+| Claim | Assessment |
+|---|---|
+| Owned completed sessions with mismatching price terms all receive the refusal review | **False:** finding 1. `TermsVersion` is part of those terms, but its early rejection bypasses the review. |
+| The operator no longer needs provider-metadata lookup (`billingcurrency_test.go:2910`) | **False through the supported API:** finding 2. Persistence alone does not expose the reference. |
+| Every refused session is retained in the retirement record | **Incomplete on upgrade:** finding 3. Fresh-review persistence is correct. |
+| Seventh-review claims were all corrected (`DISPOSITIONS-multi-currency.md:184`) | **Incomplete.** Lines `79` and `120` still say provisioning tests read “generated checks”; they read shell source. Line `116` still says the old trigger is dropped first. |
+| Removing the inclusion check is a killed mutation (`DISPOSITIONS-multi-currency.md:143`) | **False as stated.** Removing an assertion over already-exact data still passes. Mutating the data to violate the asserted property is a different, meaningful test. |
+| Every fix has a distinguishing test | **Too broad.** The migration test checks uninterrupted final state, so unsafe DROP-before-CREATE can pass. Repeated resolution returns before DELETE, so removing its intent predicate also passes. Provisioning substring assertions do not execute failure handling. |
+| Quote membership is compared “under the membership lock” (`billinghandlers.go:109`, `billingservice.go:367`, design line `149`) | **Imprecise.** The first comparison uses the billing lock; the intent snapshot is subsequently checked under the membership write lock. The combined protection holds. |
+| The seventh reviewers agreed on “the one P2” (`DISPOSITIONS-multi-currency.md:176`) | **False:** Astra classified the second-read substitution as P2 as well; Opus classified it P3. |
+| The site test proves export provenance (`test/pricing-pricebook.test.mjs:32`) | **False.** It checks schema, currencies and a commit-reference pattern. The pinned files match, but that assertion does not establish equality. |
+
+The review-four unanimity sentence and invalid-currency ordering explanation are now corrected (`DISPOSITIONS-multi-currency.md:108`, `:114`). Its listed mutations have relevant assertions, but do not establish the broader “every fix” claim. Review three’s default-as-explicit mutation is correctly described as equivalent for accepted ordinary forms.
+
+I found no concrete wall-clock, test-order, temporary-root or shared-cookie-jar failure in the inspected additions. Historical mutation-run claims cannot be established from source inspection alone.
+
+**What must be verified against Stripe test mode before enablement**
+
+1. **Catalog:** Read all 36 prices with expanded currency options. Verify amounts, metadata, recurrence and tax behavior. Exercise more than 100 objects and a failed later page; verification must stop on failure.
+2. **Checkout matrix:** Complete USD/EUR/GBP purchases across IE, GB, US, IN and JP, including IN/GBP. Confirm unit × quantity, session/subscription/item currency, disabled Adaptive Pricing and no conversion. Record incompatible price/session-currency refusal shapes.
+3. **Renewals and quantity changes:** Exercise both proration policies and renewal in each currency. Confirm unchanged price identity, correct amounts and effective dates; complete the quantity-fence verification.
+4. **Overages:** Exercise every meter, rounding boundary and currency-specific floor through creation, recovery, finalization and payment. Confirm frozen currency, line amounts and subtotal, including automatic tax and canceled subscriptions.
+5. **Recovery:** Lose responses around checkout and invoice operations; replay and recover by listing within and beyond retention windows. Confirm original identities, legacy compatibility and no duplicate charges.
+6. **Refusals:** Verify open/completed expiry responses and repeated/reordered webhooks. Exercise unbound paid refusal, settlement, retirement, a new purchase and the next metered month. Expect one actionable review, preserved session identity and no recurring hold.
+7. **Customer and discount constraints:** Establish currency behavior during subscriptions, after cancellation and with outstanding invoices. Exercise incompatible fixed-amount discounts, the percentage promotion and house coupon.
+
+**Verdict: reject.**
\ No newline at end of file
diff --git a/docs/superpowers/specs/DISPOSITIONS-multi-currency.md b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
index 06ff95fce..538993814 100644
--- a/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
+++ b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
@@ -67,7 +67,7 @@ Reports: `OPUS-REVIEW-multi-currency-review2.md`, `CODEX-FINDINGS-multi-currency
| Opus P3-4: rollback leaves stale columns that fail closed; an older build on a month where a legacy subscription ended and a book one began. | The second case is the barrier above. The first is a roll note (below). | | |
| Design text stale (six columns; `ErrAssignmentHistory`; 36 or 48 keys; Adaptive Pricing without the legacy exception). | Corrected. | design | |
-**Roll note.** The billing environment and the console binary change together: this build needs `CONSOLE_BILLING_PRICE_BOOK` and the 36 keys, and the build before it exits on them. Once any account has a book-priced row, no binary from before price books may run against the database: its reads of the account rows and intents would fail closed, and the database refuses its freezes over book history, but a legacy subscription it recorded for such an account would leave the account's `currency` and `price_book` columns stale and this build refusing the account for good. Billing is off everywhere today, so this is a note for the first roll.
+**Roll note.** The billing environment and the console binary change together (and, added at review eight: once any `billing_checkout_currency_refused` review has been resolved, no binary from before the eighth-round commit may start against the database: an earlier binary's startup sweep does not know the outcome `checkout_refused_resolved` and would mark the account's quantity history unproven; this binary's sweep clears such a mark again on the next start, so a roll back and forward heals, but the account is held in between): this build needs `CONSOLE_BILLING_PRICE_BOOK` and the 36 keys, and the build before it exits on them. Once any account has a book-priced row, no binary from before price books may run against the database: its reads of the account rows and intents would fail closed, and the database refuses its freezes over book history, but a legacy subscription it recorded for such an account would leave the account's `currency` and `price_book` columns stale and this build refusing the account for good. Billing is off everywhere today, so this is a note for the first roll.
Mutation checks on these fixes, nine in a scratch copy: eight failed their tests (the barrier absent, a bound session not expired on either resume path or by the pass, a paid session expired and unreviewed, a second currency among the options, case-folded duplicates, another intent's session kept). One survived, redundant with the count of used keys: a partial catalog of another book is refused by that count whichever check is removed.
@@ -132,7 +132,7 @@ The fixes are in the commit that adds this section and have not been read by a r
## Review five (at 1f005804; Opus accepted with fixes, Astra rejected)
-Reports: `OPUS-REVIEW-multi-currency-review5.md`, `CODEX-FINDINGS-multi-currency-review5.md`. Both found the GBP purchase consistent end to end and the fourth round's code changes present; Astra marked three of them partly closed (ownership, crafted quotes, migration), which is what its findings below are about. Its rejection rests on ownership not being applied on every path, on the fabricated-quote case, and on the trigger swap; Opus found the same three, lower. The code decides: each was reachable, and each is fixed below with a test that fails without it. Mutation checks in a scratch copy: the listing acting without ownership, reconcile ignoring the bound id and customer, the bind without ownership, the quote signature not checked, the resolution leaving the intent, the webhook alone filing nothing, and a per-member figure in the terms made not whole in base units (a data mutant, since removing a test assertion over exact data can show nothing): all killed. The pass's own check of the returned id against the intent's survived its removal and is kept as belt and braces: the bind refuses such a session on its bound-id clause, so the outcome is the same by construction.
+Reports: `OPUS-REVIEW-multi-currency-review5.md`, `CODEX-FINDINGS-multi-currency-review5.md`. Both found the GBP purchase consistent end to end and the fourth round's code changes present; Astra marked three of them partly closed (ownership, crafted quotes, migration), which is what its findings below are about. Its rejection rests on ownership not being applied on every path, on the fabricated-quote case, and on the trigger swap; Opus found the same three, lower. The code decides: each was reachable, and each is fixed below with a test that fails without it. Mutation checks in a scratch copy: the listing acting without ownership, reconcile ignoring the bound id and customer, the bind without ownership, the quote signature not checked, the resolution leaving the intent, the webhook alone filing nothing, and a per-member figure in the terms made not whole in base units (a data mutant, since removing a test assertion over exact data can show nothing; the earlier sentence in review four's list that counted the removed assertion as killed was wrong and is withdrawn): all killed. The pass's own check of the returned id against the intent's survived its removal and is kept as belt and braces: the bind refuses such a session on its bound-id clause, so the outcome is the same by construction.
| Finding | Disposition | Where | Reproduction |
| --- | --- | --- | --- |
@@ -141,7 +141,7 @@ Reports: `OPUS-REVIEW-multi-currency-review5.md`, `CODEX-FINDINGS-multi-currency
| Astra 3 (P2), Opus P3-2: the trigger swap ran as two statements, so a crash between them left neither trigger. | Fixed: the new trigger is created in the migration list as before, and the old one is dropped afterwards in a transaction that first confirms the new one exists; no moment has neither (the ordering is the guarantee; the two do not share a transaction, and no test can show the atomicity itself, only the result). The roll note gains: builds of this branch from before the rename must not run after this one (they would create the old name beside the new). | `billingstore.go` `retireEarlierBarrierTrigger` | `TestBarrierTriggerLetsThisBuildsLegacyMonthsThrough` ("a database that carries the earlier, wider trigger") |
| Astra 4 (P3), Opus carried P3-3: resolving a refused, paid session's review left the intent standing, so the next pass reopened the review. | Fixed: resolving a review of that reason retires the account's pending intent in the same transaction; the next pass and webhook find nothing to bind and the review stays resolved. | `billingstore.go` `resolveBillingReview` | `TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution` |
| Opus P3-3: one clause of the inclusions test was a tautology. | Fixed: the test asserts, for every paid plan, band factor and axis, that the figure times the factor is whole in base units and that the per-member share shows in three decimals of its unit; the tautology is gone; the page comment says what the test checks. | `billingcurrency_test.go`, `billingpage.go` | the test itself (a mutant removing the check is killed) |
-| Opus P3-4, Astra claims: the webhook leg ran after the review was already open; the bind customer check and three `ownSession` clauses were untested. | Fixed: the webhook alone files the review on a fresh fixture; each `ownSession` clause has a case; the bound-id and customer paths are exercised. | tests | `TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution`, `TestOwnSessionClauses`, `TestOwnershipIsEnforcedOnEveryPath` |
+| Opus P3-4, Astra claims: the webhook leg ran after the review was already open; the bind customer check and three `ownSession` clauses were untested. | Fixed: the webhook alone files the review on a fresh fixture; each `ownSession` clause has a case; the bound-id and customer paths are exercised (review eight: the negative webhook cases now assert their status too, 204 for an acknowledged refusal and 503 for a read the console cannot yet settle). | tests | `TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution`, `TestOwnSessionClauses`, `TestOwnershipIsEnforcedOnEveryPath` |
| Opus P3-5, Astra 6 (P3): Check 2 counted products account-wide and read one page of 100 prices. | Fixed: every page is read (`starting_after`) and products and prices are counted by their metadata (`terms_version`, `price_book`); the plans test reads the script's text for the pager and the filters (it does not run them: no Stripe here; the rehearsal does). | `stripe-provision.sh`; `plans/provision_test.go` | `TestStripeProvisionScriptMatchesThePriceBook` |
| Astra 7 (P3): the verifier refused an own-only option whose `tax_behavior` is the empty string, which the console accepts. | Fixed: the verifier accepts null, empty or `exclusive`, as the console does. | `stripe-provision.sh` | the same test (the accepted shapes are in the asserted expression) |
| Astra 5 (P3), Opus P3-6: `npm run build` bypassed the draft check. | Fixed on the site (`9427c22`): the ordinary build runs the check first; the test reads both scripts. | site `package.json` | site test |
@@ -188,3 +188,21 @@ Mutation checks in a scratch copy: the new outcome left out of the obligation li
Open after this round: the site's `bands.p3` sentence; `billing.quote.unknown` and `billing.currency.unknown` as unreviewed system copy; everything that needs Stripe test mode. Nothing else on this branch is marked open.
The fixes are in the commit that adds this section and have not been read by a reviewer.
+
+## Review eight (at 3e941f00; Opus accepted with fixes, Astra rejected)
+
+Reports: `OPUS-REVIEW-multi-currency-review8.md`, `CODEX-FINDINGS-multi-currency-review8.md`. Both found every seventh-round finding closed; neither found a P1 or a P2 in the code's money paths. Opus: a rollback trap the seventh round's outcome created, and the refused session's id not shown to the operator while the review is open. Astra: an owned completed session whose terms version changed with its currency was refused before the review branch (a variant of its round-four finding 6), the reference not carried through the operator API, and an already-open review from an earlier build never learning its reference. All fixed below, each with a test that fails without it; four mutants were killed in a scratch copy.
+
+| Finding | Disposition | Where | Reproduction |
+| --- | --- | --- | --- |
+| Astra 1 (P2): the binder checked plan, band and terms version before ownership, so an owned completed session whose terms version changed with its currency was refused with no review. | Fixed: ownership first; then any mismatch of plan, band, terms, price terms or currency on an owned completed session files the one review. | `billingservice.go` `bindBillingCheckoutFor` | `TestBoundSessionCompletedInAnotherCurrencyIsReviewedOnce` (terms changed with the currency, through the pass and through a fresh webhook) |
+| Opus P3-1: a binary from before the seventh round, rolled back to after a refusal was resolved, marks the account's quantity history unproven and nothing clears it. | Fixed in two parts: the roll note says no binary from before the eighth-round commit may start after a refusal has been resolved; and this binary's startup sweep clears a gap it (or an earlier binary) marked under state `history_unproven` when every processed event carries an outcome the current list admits, so a roll back and forward heals. The state label is recomputed by the next summary write. | `billingquantityaudit.go`, dispositions roll note | `TestStartupHealsAGapFromAnOutcomeAdmittedSince` (a gap from the new outcome is cleared; one with a bad outcome behind it stays) |
+| Opus P3-2, Astra 2 (P3): the refused session's id was not shown to the operator while the review was open. | Fixed: the review reader and the operator API carry `reference`; the test reads the API as an operator. | `billingstore.go` `billingReview`, `billingoperator.go` | `TestOperatorSeesTheRefusedSessionWhileTheReviewIsOpen`, `TestUnboundRefusedSessionIsRecordedByItsId` |
+| Astra 3 (P3): a review filed by the seventh build (no reference) never learned the session's id when the session was met again. | Fixed: a filing that meets an open review fills an empty reference without a new occurrence. | `billingstore.go` `fileCheckoutRefusedReview` | `TestUnboundRefusedSessionIsRecordedByItsId` (reference blanked, the pass meets the session again: filled, occurrences unchanged) |
+| Opus claims: "under the membership lock" (design, two comments); "generated checks" (two dispositions rows); "removing the check was killed" (review four's list); the two-account fixture's comment ("B's create response was not lost": B stops before a session is created); the negative webhook cases assert no status; the migration test cannot show the atomicity itself. | Corrected: the compare runs under the billing lock and the intent snapshot's membership-locked check holds the count; the plans test reads the script's text; the review-four sentence is withdrawn; the fixture's comment says what happens; the webhook cases assert 204 or 503 per case; the migration row says the ordering is the guarantee and no test shows the atomicity. | comments, this file, tests | |
+
+Mutation checks in a scratch copy: a terms mismatch refused before ownership (no review); an open review never learning its reference; the sweep never healing; the listing without the reference: all killed.
+
+Open after this round: the site's `bands.p3` sentence; `billing.quote.unknown` and `billing.currency.unknown` as unreviewed system copy; everything that needs Stripe test mode. Nothing else on this branch is marked open.
+
+The fixes are in the commit that adds this section and have not been read by a reviewer.
diff --git a/docs/superpowers/specs/OPUS-REVIEW-multi-currency-review8.md b/docs/superpowers/specs/OPUS-REVIEW-multi-currency-review8.md
new file mode 100644
index 000000000..027149f8b
--- /dev/null
+++ b/docs/superpowers/specs/OPUS-REVIEW-multi-currency-review8.md
@@ -0,0 +1,104 @@
+# Eighth review of `feat/carloku-multi-currency-20261001` at `3e941f00` (base `12e70d95`)
+
+**Verdict: accept with fixes.** All seven seventh-round findings are closed in the code. There is no new P1 or P2. One new P3 is a rollback trap that the round-7 fix created; the rest are a display gap and claims the code still contradicts.
+
+How I worked:
+- I read only with `git show 3e941f00:` and a `git archive` snapshot in `$TMPDIR`, since deleted. I modified nothing and ran no tests.
+- The requested delta `3e941f00..3e941f00` is empty, so I read `124eab27..3e941f00` as the seventh-round fixes.
+- `internal/plans`, `cmd`, `implementation/` and the trigger code are unchanged in this round.
+- **What I did not re-trace:** code outside this delta is byte-identical to `124eab27`, which both seventh-round reports traced in full. For sections A, C, E–H, J and N–P below I checked only how the new code interacts with it; I did not re-trace those paths from scratch.
+
+## Seventh-review findings
+
+| Finding | Closed? | Would its test pass without the fix? |
+|---|---|---|
+| **Opus P2-1 / Astra 1**: `checkout_refused_resolved` was missing from the sweep and obligation lists | **Closed.** One constant, `quantityOutcomesSQL` (`billingquantityaudit.go:54`), feeds the guard (`:22`), the startup sweep (`:43`) and `quantityObligationTx` (`:58`). No other SQL or Go reader of `processing_outcome` keeps its own list (X). Every writer uses an outcome in the list. The guard's text is byte-identical to the round-6 text, so a round-6 database is not touched. | **No.** The fixture's event is closed with the new outcome. With the old lists, the `held` assertion and the freeze both fail. The second-start gap assertion also fails, because `AddMember` writes a `billing_quantity_changes` row for the sweep to mark. |
+| **Opus P3-1 / Astra 2**: bind's second read derived the account from the response | **Closed.** `bindBillingCheckoutFor` (`billingservice.go:917`, check at `:929`) runs `ownSession` against the expected intent and the expected intent's account, and requires `account_sqid` to equal that account, before `AccountBySqid`. Both pass sites pass `&intent` (`billingpass.go:200`, `:259`). The webhook alone passes `nil` (`billinghandlers.go:271`). | **No**, but the fixture is not what its comment says (Claims, item 4). B's intent stands unbound with no customer. Without the fix, bind takes B from the second answer, `ownSession` passes, and `afterB.SessionID` is set. |
+| **Opus P3-2 / Astra 3**: an unbound refused session was recorded nowhere | **Closed as the fix asked, with a display gap (P3-2 below).** The new `billing_reviews.reference` column (`billingstore.go:242`) is set once at filing (`:982`). The resolution falls back to it for an unbound intent (`:928`), and it reaches `billing_retired_intents` and the events' `processing_reference`. | **No.** The column and the value are both asserted. |
+| **Opus P3-3**: the band was not in the MAC | **Closed.** The page signs `quotedBand`, the quoted country's band from the configured map (`billingpage.go:267-271`). The door recomputes it with `plans.BandForCountry` (`billinghandlers.go:98`). With no country, both sides sign `""`, and the checkout then fails with `unavailable`. | **No.** Without the fix, a band-D form posted after India moves to band B verifies and opens a band-B intent. The test asserts that no intent exists. |
+| **Claims (both)** | **Partly corrected.** The trigger comment, the unused parameter, the second-resolution test comment, the review-four sentence and "before anything" are fixed. Four claims marked corrected are still in the text (below). | n/a |
+
+**Where the reviewers disagreed (Opus P3-1 against Astra 2, P2): the code says P3 was the right severity.**
+- The defect was real. The pass's guarantee did not extend to bind's re-read.
+- Reaching it needs the authenticated provider to return two different answers for the same session id in one pass. The session's metadata and `client_reference_id` are fixed by the console at creation, so no buyer or tenant can cause that.
+- Astra's own report says as much: "require inconsistent provider responses, rather than a demonstrated buyer-controlled exploit."
+- The reviewers agreed on every other finding.
+
+## Findings
+
+### P3-1. A rollback after any refusal resolution holds the account for good
+- **Where:** the new outcome is written at `billingstore.go:936`. The base binary's startup sweep (`12e70d95:internal/console/billingquantityaudit.go:42`) does not list it. `history_gap` is sticky (`billingmembership.go:470`), and nothing in the tree ever clears it.
+- **Scenario:**
+ 1. On this build, a Community intent with a pending member-join event completes in the wrong currency. The review is filed and resolved, and the event is closed as `checkout_refused_resolved`. The intent is deleted, so the account may have no book-priced row left.
+ 2. Billing is rolled back to `12e70d95`/main, binary and environment together, as the roll note requires.
+ 3. The base binary starts. Its sweep sets `history_gap=1, state='history_unproven'` on the account.
+ 4. Rolling forward again does not clear it. `quantityObligationTx` returns held on `history_gap<>0`, so the account's months never freeze and deletion is refused, with no review open.
+- **Why the roll note misses it:** the note (dispositions line 70) forbids only binaries from before price books after "a book-priced row". A resolved refusal can leave no such row, and a legacy-terms refusal never had one.
+- **Smallest fix:** extend the roll note: "once any `billing_checkout_currency_refused` review has been resolved, no binary before `3e941f00` may start against the database." Better, also give the operator a door that clears a gap whose only cause is an outcome now in `quantityOutcomesSQL`.
+
+### P3-2. The operator still cannot see the refused session's id while the review is open
+- **Where:** the operator listing, `billingoperator.go:61`, selects `account_id, subscription, reason, occurrences, created_at, last_seen_at` but not `reference`.
+- **Scenario:**
+ 1. A lost create response; the listing finds a paid EUR session and files the review.
+ 2. The operator page shows the intent id in the "subscription" column and nothing else.
+ 3. The operator has to settle the money before resolving, so they still have to search Stripe by `checkout_intent` metadata.
+ 4. The id becomes visible only in `billing_retired_intents`, after the resolution.
+- **Smallest fix:** select and render `reference` in the review list.
+
+## Sections
+
+- **A.** No path converts the GBP purchase or prices it in another currency. Only the bind and signature code changed this round, and neither touches money.
+- **B / M / Q / U / Z.** The MAC now covers the session token, account, book, country, band, currency, flag and member count.
+ - Replay works only for the identical tuple, which is the same price.
+ - A token change fails csrf (403) first.
+ - Plan and postal are not price inputs: plan chooses among rows the page showed, with Personal's `MaxQuantity` enforced at the intent; postal affects only tax.
+ - A country in the map whose band has no percentage is unquoted on the page and refused at the door because `country != quotedCountry`.
+ - The quote door is unchanged: an escaped redirect to the account's own billing page.
+- **C, O.** Unchanged. The held currency, legacy EUR included, is offered alone and signed as chosen.
+- **D / L / R / W / Y.** `ownSession` now gates both pass reads and bind's re-read against the pass's intent.
+ - The listing path passes the pre-save intent snapshot, so its bound-id clause is vacuous there. Bind's second `ownSession` against the fresh row, which now carries the saved session id, covers it.
+ - Bind's early return for an already-recorded subscription (`:950`) still comes before its second `ownSession`, as before. It is benign: it records nothing, and reconcile checks independently.
+- **E / N / T.** No change. Both triggers present fails closed; neither present is unreachable.
+- **F, G, J.** No change.
+- **H.** The `reference` column has a default and older binaries ignore it. The rollback trap is P3-1.
+- **K.** The site at `9427c22` is unchanged. `copy-review/pricing/pricebook.json` is byte-identical to `internal/plans/public-pricebook.json` at `3e941f00`. Its `pricebook.source.txt` still names `5047494b`; the bytes match, but the provenance line is stale.
+- **P.** The review-four table is unchanged; its summary sentence is now accurate.
+- **S / V.** The resolution retires exactly `(account_id, id = review key)`, and filing for an intent that is gone files nothing. The reference is first-writer-wins, which is fine because the review cannot be re-filed while open.
+- **X.** One list, as above.
+
+## Claims the code contradicts
+
+1. **"under the membership lock."** The dispositions say this was corrected, but it still stands in three places: the design (line 149, "compared again under the membership lock when the door charges"), `billinghandlers.go:109` and `billingservice.go:367`. The compare at `billingservice.go:413` runs under the billing lock. The effect is the same, because `checkIntentMembershipTx` re-checks the snapshot under the membership lock.
+2. **"generated checks."** Marked corrected, but still at dispositions lines 79 and 120. `provision_test.go` checks the shell source for substrings; nothing is generated.
+3. **"a mutant removing the check is killed."** Marked corrected (as "the inclusion-mutation wording"), but still at dispositions line 143. Removing assertions over the current exact data still passes. The data mutant at line 135 is the one that is killed.
+4. **`TestBindersSecondReadIsJudgedAgainstTheIntent`'s "B's create response was not lost"** (`billingcurrency_test.go:2966`). The fake customer's `account_sqid` is A's, so B's start fails at `ensureBillingCustomer`'s identity check before `CreateCheckout`. B's intent stands with no customer and is not ready, and `failCreate` is still armed. The test still kills the mutant, but the two-account case never exercises the customer clause.
+5. **"Everything of either is fixed below with a test that fails without it"** (review seven). Astra's seventh-round test claims were not addressed:
+ - the migration test passes with DROP-before-CREATE;
+ - the negative webhook cases assert no status;
+ - the named-delete mutant survives (admitted in review six).
+
+## Before enabling: Stripe test mode
+
+The thirteen items from the seventh review stand. In short:
+1. All 36 prices, with expanded currency options, and the pager past 100 prices with a failed later page.
+2. The currency × country checkout matrix, including IN with GBP: a fixed currency, Adaptive Pricing off, no conversion, unit × quantity.
+3. The refusal shape when a session's currency differs from its price's.
+4. The completed subscription's currency and metadata.
+5. The customer currency lock, during a live subscription and after it ends.
+6. GBP and USD overage invoices with only the IE tax registrations.
+7. Proration and renewal in each currency.
+8. Lost-response replays, and listing scoped by customer.
+9. Expiring a completed session.
+10. A replayed `checkout.session.completed` after a resolution: no review, nothing recorded.
+11. Promotion codes against fixed currencies.
+12. A refusal found by the listing, then resolved:
+ - the next month freezes and invoices;
+ - the session id is in `billing_retired_intents`;
+ - add for this round: it is visible on the operator page once P3-2 is fixed.
+13. A webhook and a pass meeting the same refused session at once: one review, keyed by the intent.
+
+Add for this round:
+
+14. In the deployment's own environment, not test mode: start a pre-`3e941f00` binary against a copy of a database holding a resolved refusal, and confirm the roll note (P3-1) is needed or that the database has no such row.
+
+**Verdict: accept with fixes.** Fix the roll note (P3-1) and correct the four stale claims before merge; P3-2 can follow.
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go
index f90cb9891..b0837dcd5 100644
--- a/internal/console/billingcurrency_test.go
+++ b/internal/console/billingcurrency_test.go
@@ -2363,6 +2363,42 @@ func TestBoundSessionCompletedInAnotherCurrencyIsReviewedOnce(t *testing.T) {
if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" || f.expireCalls != 0 {
t.Fatalf("state %+v, %d expiries; want nothing recorded and nothing expired", state, f.expireCalls)
}
+ // And when the terms version changed with the currency: still the
+ // intent's own session (its id, account, customer, mode), still one
+ // review, through the pass and through a fresh webhook.
+ for _, through := range []string{"the pass", "the webhook"} {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = "GB"
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); err != nil || dest == "" {
+ t.Fatalf("first click: dest %q err %v", dest, err)
+ }
+ intent, _ := fx.st.billingCheckoutIntent(acct.ID)
+ f.mu.Lock()
+ f.sessionStatus, f.sessionCurrency, f.meta["currency"], f.meta["terms_version"], f.subscriptionPrice = "complete", "eur", "eur", "2031-01-01-v9", intent.Price
+ f.mu.Unlock()
+ if through == "the pass" {
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(time.Minute))
+ } else {
+ payload := fmt.Sprintf(`{"id":"evt_terms","type":"checkout.session.completed","created":%d,"livemode":false,"data":{"object":{"id":%q}}}`, time.Now().Unix(), intent.SessionID)
+ ts := time.Now().Unix()
+ mac := hmac.New(sha256.New, []byte("whsec_test"))
+ fmt.Fprintf(mac, "%d.%s", ts, payload)
+ r := httptest.NewRequest(http.MethodPost, "/billing/webhook", strings.NewReader(payload))
+ r.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))))
+ w := httptest.NewRecorder()
+ fx.srv.ServeHTTP(w, r)
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("webhook: %d %s", w.Code, w.Body.String())
+ }
+ }
+ var n int
+ if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_reviews WHERE account_id=? AND reason=? AND resolved_at=0`, acct.ID, billingCheckoutRefusedReason).Scan(&n); err != nil || n != 1 {
+ t.Fatalf("terms changed with the currency, through %s: %d reviews (%v), want one", through, n, err)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" || f.expireCalls != 0 {
+ t.Fatalf("through %s: state %+v, %d expiries; want nothing recorded and nothing expired", through, state, f.expireCalls)
+ }
+ }
}
// The barrier trigger stops the older build and only the older build: a
@@ -2649,7 +2685,8 @@ func TestOwnershipIsEnforcedOnEveryPath(t *testing.T) {
}
return n
}
- webhook := func(t *testing.T, fx *consoleFixture, id string) {
+ webhook := func(t *testing.T, fx *consoleFixture, id string, want int) {
+ t.Helper()
payload := fmt.Sprintf(`{"id":%q,"type":"checkout.session.completed","created":%d,"livemode":false,"data":{"object":{"id":"cs_test_paid"}}}`, id, time.Now().Unix())
ts := time.Now().Unix()
mac := hmac.New(sha256.New, []byte("whsec_test"))
@@ -2658,6 +2695,13 @@ func TestOwnershipIsEnforcedOnEveryPath(t *testing.T) {
r.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))))
w := httptest.NewRecorder()
fx.srv.ServeHTTP(w, r)
+ // The webhook was delivered and answered as expected: an identity
+ // refusal is acknowledged (204) so the provider does not retry it
+ // for days; a read answered with another session's id is not yet
+ // settled (503), and the provider tries again.
+ if w.Code != want {
+ t.Fatalf("webhook %s: %d %s, want %d", id, w.Code, w.Body.String(), want)
+ }
}
t.Run("a listed session of another customer", func(t *testing.T) {
for _, currency := range []string{"eur", ""} {
@@ -2691,7 +2735,7 @@ func TestOwnershipIsEnforcedOnEveryPath(t *testing.T) {
t.Fatalf("currency %q: a session of another customer bound", currency)
}
_ = fx.srv.billingPass(context.Background(), f.now.Add(time.Minute))
- webhook(t, fx, "evt_customer_"+currency)
+ webhook(t, fx, "evt_customer_"+currency, http.StatusNoContent)
state, _ := fx.st.billingAccount(acct.ID)
if state.Subscription != "" || f.expireCalls != 0 || reviews(t, fx, acct) != 0 {
t.Fatalf("currency %q: state %+v, %d expiries, %d reviews; want nothing recorded, expired or reviewed for another customer's session", currency, state, f.expireCalls, reviews(t, fx, acct))
@@ -2754,7 +2798,7 @@ func TestOwnershipIsEnforcedOnEveryPath(t *testing.T) {
f.sessionStatus, f.sessionID, f.sessionCurrency, f.subscriptionPrice = "complete", "cs_other", currency, intent.Price
f.mu.Unlock()
_ = fx.srv.billingPass(context.Background(), f.now.Add(time.Minute))
- webhook(t, fx, "evt_other_"+currency)
+ webhook(t, fx, "evt_other_"+currency, http.StatusServiceUnavailable)
if _, err := fx.srv.bindBillingCheckout(context.Background(), "cs_test_paid"); err == nil {
t.Fatalf("currency %q: another session bound directly", currency)
}
@@ -2935,6 +2979,24 @@ func TestUnboundRefusedSessionIsRecordedByItsId(t *testing.T) {
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(err)
}
+ // The operator's own listing carries it.
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-refused-ref")
+ status, out := getJSON(t, fx.ts.URL+"/api/billing/reviews", token)
+ listed, _ := json.Marshal(out)
+ if status != http.StatusOK || !strings.Contains(string(listed), `"reference":"cs_test_paid"`) || !strings.Contains(string(listed), intent.ID) {
+ t.Fatalf("the operator's review listing: %d %s", status, listed)
+ }
+ // A review filed by a build before the reference existed learns it
+ // when the session is met again, with no new occurrence.
+ if _, err := fx.st.db.Exec(`UPDATE billing_reviews SET reference='' WHERE account_id=? AND subscription=?`, acct.ID, intent.ID); err != nil {
+ t.Fatal(err)
+ }
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(2*time.Minute))
+ var again string
+ var occurrences int64
+ if err := fx.st.db.QueryRow(`SELECT reference, occurrences FROM billing_reviews WHERE account_id=? AND subscription=?`, acct.ID, intent.ID).Scan(&again, &occurrences); err != nil || again != "cs_test_paid" || occurrences != 1 {
+ t.Fatalf("an open review with no reference, met again: %q %d %v", again, occurrences, err)
+ }
if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intent.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "paul@keymail.dev", Note: "refunded"}, f.now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
@@ -2962,8 +3024,11 @@ func TestBindersSecondReadIsJudgedAgainstTheIntent(t *testing.T) {
f.mu.Lock()
f.failCreate = true
f.mu.Unlock()
+ // B's checkout stops before a session is created (the fake's customer
+ // carries A's account), leaving B an intent with no customer and no
+ // session: ready for a provider answer to claim.
if _, err := fx.srv.startBillingCheckout(context.Background(), other, plans.PlanPersonal, "GB", "", "", f.now); err == nil {
- t.Fatal("fixture: B's create response was not lost")
+ t.Fatal("fixture: B's checkout went through")
}
intentB, _ := fx.st.billingCheckoutIntent(other.ID)
f.mu.Lock()
@@ -2992,3 +3057,97 @@ func TestBindersSecondReadIsJudgedAgainstTheIntent(t *testing.T) {
t.Fatalf("B %+v / %+v, A %+v, %d expiries, %d reviews; want nothing done for either account", stateB, afterB, stateA, f.expireCalls, reviewsEither)
}
}
+
+// The refused session's id is visible to the operator while the review is
+// open, not only after its resolution.
+func TestOperatorSeesTheRefusedSessionWhileTheReviewIsOpen(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry, f.failCreate = "GB", true
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now); err == nil {
+ t.Fatal("fixture: the create response was not lost")
+ }
+ intent, _ := fx.st.billingCheckoutIntent(acct.ID)
+ f.mu.Lock()
+ f.failCreate, f.sessionStatus, f.sessionCurrency, f.subscriptionPrice = false, "complete", "eur", intent.Price
+ f.mu.Unlock()
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(time.Minute))
+ open, err := fx.st.openBillingReviews()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, r := range open {
+ if r.AccountID == acct.ID && r.Reason == billingCheckoutRefusedReason {
+ found = true
+ if r.Subscription != intent.ID || r.Reference != "cs_test_paid" {
+ t.Fatalf("the open review: %+v; want the intent as its key and the session as its reference", r)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("no open refusal review in %+v", open)
+ }
+}
+
+// A quantity-history gap the startup sweep marked under an earlier list of
+// outcomes is cleared again on the next start when every processed event
+// carries an outcome the list has now; a gap with a real bad outcome
+// behind it stays.
+func TestStartupHealsAGapFromAnOutcomeAdmittedSince(t *testing.T) {
+ fx, _, acct := newCurrencyFixture(t)
+ if _, err := fx.st.db.Exec(`INSERT INTO billing_quantity_changes(account_id,generation,plan,terms,live_mode,members,desired_quantity,billed_quantity,billing_revision,intent_id,customer,subscription,period_start,period_end,history_gap,state,updated_at)
+ VALUES(?,1,'community','t',1,11,11,10,1,'','','',0,0,0,'in_sync',1)`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ // An event, appended unprocessed as the console appends them, then
+ // processed with the outcome the resolution writes.
+ if _, err := fx.st.db.Exec(`INSERT INTO billing_quantity_events(account_id,generation,occurred_at_ns,change,effect,address,members_before,members_after,desired_quantity,billed_quantity,plan,terms,live_mode,customer,subscription,intent_id,billing_revision,period_start,period_end)
+ VALUES(?,1,1000000000,'addition','prorate','kim@keymail.dev',10,11,11,10,'community','t',1,'','','i1',1,0,0)`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_events SET processed_at=2,processing_outcome='checkout_refused_resolved',processing_reference='cs_x' WHERE account_id=?`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ // What an earlier binary's sweep did: marked the gap, not knowing the
+ // outcome.
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_changes SET history_gap=1,state='history_unproven' WHERE account_id=?`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.migrateBilling(); err != nil {
+ t.Fatal(err)
+ }
+ var gap int
+ if err := fx.st.db.QueryRow(`SELECT history_gap FROM billing_quantity_changes WHERE account_id=?`, acct.ID).Scan(&gap); err != nil || gap != 0 {
+ t.Fatalf("the gap after this binary's start: %d %v; want it cleared", gap, err)
+ }
+ // A gap with a bad outcome behind it stays. (The events are append-only
+ // and guarded; the bad row is written with the guards lifted, as a
+ // corruption would be, and the migration puts the guards back.)
+ rows, err := fx.st.db.Query(`SELECT name FROM sqlite_master WHERE type='trigger' AND (tbl_name='billing_quantity_events' OR sql LIKE '%billing_quantity_events%')`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var triggers []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ t.Fatal(err)
+ }
+ triggers = append(triggers, name)
+ }
+ rows.Close()
+ for _, name := range triggers {
+ if _, err := fx.st.db.Exec(`DROP TRIGGER IF EXISTS ` + name); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := fx.st.db.Exec(`UPDATE billing_quantity_events SET processing_outcome='nonsense' WHERE account_id=?`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.migrateBilling(); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.db.QueryRow(`SELECT history_gap FROM billing_quantity_changes WHERE account_id=?`, acct.ID).Scan(&gap); err != nil || gap != 1 {
+ t.Fatalf("the gap with a bad outcome behind it: %d %v; want it kept", gap, err)
+ }
+}
diff --git a/internal/console/billinghandlers.go b/internal/console/billinghandlers.go
index 1a3467f46..5e367884a 100644
--- a/internal/console/billinghandlers.go
+++ b/internal/console/billinghandlers.go
@@ -106,8 +106,10 @@ func (s *Server) handleBillingCheckout(w http.ResponseWriter, r *http.Request) {
// The currency is given to the door only when it is a choice. With
// none, the billing country decides. The member count the page priced
// at goes with it: the door compares it with the count it charges,
- // under the membership lock, and a count that moved is a conflict
- // (the page says the account changed and shows the new price).
+ // under the billing lock (the membership-locked check is the intent
+ // snapshot's, against the door's own count), and a count that moved is
+ // a conflict (the page says the account changed and shows the new
+ // price).
posted := ""
if chosen {
posted = currency
diff --git a/internal/console/billingoperator.go b/internal/console/billingoperator.go
index 37df56ab2..ea310493e 100644
--- a/internal/console/billingoperator.go
+++ b/internal/console/billingoperator.go
@@ -58,7 +58,7 @@ func (s *Store) migrateBillingOperator() error {
// openBillingReviews lists every unresolved review hold, oldest first.
func (s *Store) openBillingReviews() ([]billingReview, error) {
- rows, err := s.db.Query(`SELECT account_id,subscription,reason,occurrences,created_at,last_seen_at FROM billing_reviews WHERE resolved_at=0 ORDER BY created_at, account_id, subscription, reason`)
+ rows, err := s.db.Query(`SELECT account_id,subscription,reason,occurrences,created_at,last_seen_at,reference FROM billing_reviews WHERE resolved_at=0 ORDER BY created_at, account_id, subscription, reason`)
if err != nil {
return nil, err
}
@@ -66,7 +66,7 @@ func (s *Store) openBillingReviews() ([]billingReview, error) {
var out []billingReview
for rows.Next() {
var x billingReview
- if err := rows.Scan(&x.AccountID, &x.Subscription, &x.Reason, &x.Occurrences, &x.CreatedAt, &x.LastSeenAt); err != nil {
+ if err := rows.Scan(&x.AccountID, &x.Subscription, &x.Reason, &x.Occurrences, &x.CreatedAt, &x.LastSeenAt, &x.Reference); err != nil {
return nil, err
}
out = append(out, x)
@@ -175,6 +175,8 @@ type billingReviewRow struct {
Occurrences int64 `json:"occurrences"`
CreatedAt int64 `json:"created_at"`
LastSeenAt int64 `json:"last_seen_at"`
+ // Reference: the refused session's id for a currency refusal, else "".
+ Reference string `json:"reference,omitempty"`
}
func (s *Server) handleAPIBillingReviews(w http.ResponseWriter, r *http.Request) {
@@ -193,7 +195,7 @@ func (s *Server) handleAPIBillingReviews(w http.ResponseWriter, r *http.Request)
if acct, err := s.store.AccountByID(x.AccountID); err == nil && acct != nil {
sqid = acct.Sqid
}
- out = append(out, billingReviewRow{Account: sqid, Subscription: x.Subscription, Reason: x.Reason, Occurrences: x.Occurrences, CreatedAt: x.CreatedAt, LastSeenAt: x.LastSeenAt})
+ out = append(out, billingReviewRow{Account: sqid, Subscription: x.Subscription, Reason: x.Reason, Occurrences: x.Occurrences, CreatedAt: x.CreatedAt, LastSeenAt: x.LastSeenAt, Reference: x.Reference})
}
writeJSON(w, http.StatusOK, map[string]any{"reviews": out})
}
diff --git a/internal/console/billingquantityaudit.go b/internal/console/billingquantityaudit.go
index 9989ffa99..71d6a4c80 100644
--- a/internal/console/billingquantityaudit.go
+++ b/internal/console/billingquantityaudit.go
@@ -43,6 +43,18 @@ func (s *Store) migrateQuantityHistoryGuards() error {
WHERE EXISTS(SELECT 1 FROM billing_quantity_events e WHERE e.account_id=billing_quantity_changes.account_id AND e.processed_at<>0 AND (e.processed_at<e.occurred_at_ns/1000000000 OR e.processing_outcome NOT IN ` + quantityOutcomesSQL + ` OR length(trim(e.processing_reference))=0 OR length(e.processing_reference)>256))`); err != nil {
return err
}
+ // And the inverse, for a gap this sweep itself marked (state
+ // history_unproven) under an earlier list of outcomes: an account none
+ // of whose processed events carries an outcome outside the list now is
+ // not in doubt, and a binary rolled back across a resolved refusal and
+ // forward again must not hold it for good. The state label is left for
+ // the next summary write to recompute; the gap, which is what holds the
+ // account (quantityObligationTx), is cleared.
+ if _, err := s.db.Exec(`UPDATE billing_quantity_changes SET history_gap=0
+ WHERE history_gap<>0 AND state='history_unproven'
+ AND NOT EXISTS(SELECT 1 FROM billing_quantity_events e WHERE e.account_id=billing_quantity_changes.account_id AND e.processed_at<>0 AND (e.processed_at<e.occurred_at_ns/1000000000 OR e.processing_outcome NOT IN ` + quantityOutcomesSQL + ` OR length(trim(e.processing_reference))=0 OR length(e.processing_reference)>256))`); err != nil {
+ return err
+ }
return nil
}
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go
index be9dd9726..f56856bb0 100644
--- a/internal/console/billingservice.go
+++ b/internal/console/billingservice.go
@@ -364,10 +364,11 @@ func (s *Server) startBillingCheckout(ctx context.Context, acct *Account, plan,
// startBillingCheckoutQuoted is startBillingCheckout for a form that was
// priced at quotedMembers members: the door compares that with the count
-// it charges, read under the membership lock, and refuses (errBillingConflict)
-// a count that moved, so a member who joined between the page and the post
-// is never charged for unseen. A negative quotedMembers asks for no such
-// check.
+// it charges, read under the billing lock (the intent snapshot's own
+// membership-locked check then holds that count against the account), and
+// refuses (errBillingConflict) a count that moved, so a member who joined
+// between the page and the post is never charged for unseen. A negative
+// quotedMembers asks for no such check.
func (s *Server) startBillingCheckoutQuoted(ctx context.Context, acct *Account, plan, country, postal, currency string, quotedMembers int, now time.Time) (string, error) {
b := s.billing
if b == nil {
@@ -954,18 +955,20 @@ func (s *Server) bindBillingCheckoutFor(ctx context.Context, sessionID string, e
if err != nil {
return "", err
}
- if intent.ID != session.Metadata["checkout_intent"] || intent.Plan != session.Metadata["plan"] || intent.Terms != session.Metadata["terms_version"] || intent.Band != session.Metadata["band"] {
- return "", errBillingIdentity
- }
// The intent's own session in every way (id once bound, account,
// customer, mode) before anything is recorded or reviewed from it.
+ // Ownership first: what the session says about the purchase (plan,
+ // band, terms, price terms, currency) is judged after, and a mismatch
+ // there on an owned, completed session is a review, not a silent
+ // refusal.
if !b.ownSession(session, intent, acct) {
return "", errBillingIdentity
}
- if sessionTerms, err := priceTermsFromMetadata(session.Metadata); err != nil || sessionTerms != intent.priceTerms() || (!sessionTerms.Legacy() && !session.FixedCurrency(string(sessionTerms.Currency))) {
- // This intent's own session (its id, account, customer, plan and
- // band), completed on other price terms or in another currency
- // than it was asked for. Nothing is recorded from it, and a person
+ sessionTerms, termsErr := priceTermsFromMetadata(session.Metadata)
+ if intent.Plan != session.Metadata["plan"] || intent.Terms != session.Metadata["terms_version"] || intent.Band != session.Metadata["band"] || termsErr != nil || sessionTerms != intent.priceTerms() || (!sessionTerms.Legacy() && !session.FixedCurrency(string(sessionTerms.Currency))) {
+ // This intent's own session (its id, account, customer, mode),
+ // completed for another plan, band or terms, on other price terms
+ // or in another currency than it was asked for. Nothing is recorded from it, and a person
// is told, the same as when the background pass finds it unbound
// (refuseSession): one open review, keyed by the intent, however
// many passes, webhooks or bindings meet it, and filed only while
diff --git a/internal/console/billingstore.go b/internal/console/billingstore.go
index 991455ca0..b0cc207fb 100644
--- a/internal/console/billingstore.go
+++ b/internal/console/billingstore.go
@@ -773,6 +773,10 @@ type billingReview struct {
Subscription, Reason string
Occurrences int64
CreatedAt, LastSeenAt, ResolvedAt int64
+ // Reference is what the review is about beyond its key, when the key
+ // does not say: the refused session's id for a review keyed by its
+ // intent. Empty for the other reasons.
+ Reference string
}
// billingReview is the operator read a resolution must quote: the
@@ -973,14 +977,18 @@ func (s *Store) fileCheckoutRefusedReview(intent billingIntent, sessionID string
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0)`, intent.AccountID, intent.ID, billingCheckoutRefusedReason).Scan(&open); err != nil {
return err
}
- if open {
- return nil
- }
- if err := fileBillingReviewTx(tx, intent.AccountID, intent.ID, billingCheckoutRefusedReason, observed); err != nil {
- return err
+ if !open {
+ if err := fileBillingReviewTx(tx, intent.AccountID, intent.ID, billingCheckoutRefusedReason, observed); err != nil {
+ return err
+ }
}
- if _, err := tx.Exec(`UPDATE billing_reviews SET reference=? WHERE account_id=? AND subscription=? AND reason=? AND reference=''`, sessionID, intent.AccountID, intent.ID, billingCheckoutRefusedReason); err != nil {
- return err
+ // The session's id, on a review that has none yet: a review filed by
+ // a build before the reference existed, met again, learns it, with no
+ // new occurrence.
+ if sessionID != "" {
+ if _, err := tx.Exec(`UPDATE billing_reviews SET reference=? WHERE account_id=? AND subscription=? AND reason=? AND reference=''`, sessionID, intent.AccountID, intent.ID, billingCheckoutRefusedReason); err != nil {
+ return err
+ }
}
return tx.Commit()
}