carlos / platform Public

Multi-currency: the tenth review's findings

Reconcile judges the intent's own, complete session with intentSession
after ownership, as the binder does, and a mismatch files the same
intent-keyed review and records nothing, so a subscription event before
the completion event meets the same judgement; the binder's inline
comparison is intentSession too. ownSession requires the session's
account metadata to name the account, so a session naming two accounts
is nobody's on every path. The single-review reader carries the
reference. Tests for each, the fake's session metadata separable from
the subscription's, the post-resolution test replaying the retired
session's own late completion; dispositions corrected and "Review ten".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 25487506e00c51455d1c9c5c0c3ca90b79f87b11 parent faf1bd2
5 files changed, +226 −32
  • docs/superpowers/specs/DISPOSITIONS-multi-currency.md +24 −8
  • internal/console/billingcurrency_test.go +166 −8
  • internal/console/billingservice.go +19 −10
  • internal/console/billingservice_test.go +15 −4
  • internal/console/billingstore.go +2 −2

CI failed — run details

diff --git a/docs/superpowers/specs/DISPOSITIONS-multi-currency.md b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
index d5c34df1d..568ca6c11 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 (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.
+**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; nothing clears such a mark [review nine removed the sweep that did, because it could not tell that mark from the holds set on purpose]: the account's months never freeze and it cannot retire until an operator's door that does not yet exist clears it, so the prohibition is absolute): 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.
@@ -105,7 +105,7 @@ The fixes are in the commit that adds this section and have not been read by a r
## Review four (at 8958392b; Opus accepted with fixes, Astra rejected)
-Reports: `OPUS-REVIEW-multi-currency-review4.md`, `CODEX-FINDINGS-multi-currency-review4.md`. Both found the third round's code changes present and no path that stores or sends money in a currency other than the purchase's (Astra marked ownership, the crafted form and the trigger migration partly closed, which its findings were about); the disputed trigger reading went Astra's way. Every finding of either that is marked fixed below has a test that fails without it; eight mutants, one per fix, were killed in a scratch copy; two items are left open and say so.
+Reports: `OPUS-REVIEW-multi-currency-review4.md`, `CODEX-FINDINGS-multi-currency-review4.md`. Both found the third round's code changes present and no path that stores or sends money in a currency other than the purchase's (Astra marked ownership, the crafted form and the trigger migration partly closed, which its findings were about); the disputed trigger reading went Astra's way. Every code finding of either that is marked fixed below has a test that fails without it, with three qualifications review ten named: the provisioning script's checks are tested by reading the script's text, the trigger's create-then-drop order is tested by a migration that succeeds (not by one that would fail in the other order), and a repeated resolution returns before its delete, so the delete's intent predicate is tested by the first resolution alone; eight mutants, one per fix, were killed in a scratch copy at the time (the runs are not reproducible from this file); two items are left open and say so.
| Finding | Disposition | Where | Reproduction |
| --- | --- | --- | --- |
@@ -173,7 +173,7 @@ The fixes are in the commit that adds this section and have not been read by a r
## Review seven (at 124eab27; Opus accepted with fixes, Astra rejected)
-Reports: `OPUS-REVIEW-multi-currency-review7.md`, `CODEX-FINDINGS-multi-currency-review7.md`. The two agree on every finding this round, and on the first two as P2 (the outcome list, and the binder's second read): the outcome the resolution writes was admitted by the processing guard and unknown to the two other validators, so a resolved refusal held the account for good with nothing open to clear it. Both closed the sixth round's quote-key and quantity findings. Everything of either is fixed below with a test that fails without it; four mutants were killed in a scratch copy.
+Reports: `OPUS-REVIEW-multi-currency-review7.md`, `CODEX-FINDINGS-multi-currency-review7.md`. The two agree on every finding this round, and on the first as P2 (the outcome list; the binder's second read was Opus P3 and Astra P2, as its row says): the outcome the resolution writes was admitted by the processing guard and unknown to the two other validators, so a resolved refusal held the account for good with nothing open to clear it. Both closed the sixth round's quote-key and quantity findings. Everything of either is fixed below with a test that fails without it; four mutants were killed in a scratch copy.
| Finding | Disposition | Where | Reproduction |
| --- | --- | --- | --- |
@@ -191,13 +191,13 @@ The fixes are in the commit that adds this section and have not been read by a r
## 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, and Astra's one P2 was in the binder's review routing, not in a money path. 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.
+Reports: `OPUS-REVIEW-multi-currency-review8.md`, `CODEX-FINDINGS-multi-currency-review8.md`. Both found every seventh-round code finding closed (Astra marked the reference handling partly closed, which its round-eight findings below are about); neither found a P1, and Astra's one P2 was in the binder's review routing, not in a money path. 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` |
+| 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 at the time: the roll note, and a startup sweep that cleared such a gap. [Superseded in review nine: the sweep could not tell that gap from the holds set on purpose and was removed; the roll note alone stands, and such a gap stays held.] | `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 operator listing and API carry `reference`; the test reads the API as an operator. [Review ten: the single-review reader `billingReview` did not select it; it does from the tenth-round commit.] | `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 | |
@@ -209,14 +209,30 @@ The fixes are in the commit that adds this section and have not been read by a r
## Review nine (at f8a23ef3; Opus accepted with fixes, Astra rejected)
-Reports: `OPUS-REVIEW-multi-currency-review9.md`, `CODEX-FINDINGS-multi-currency-review9.md`. Both found every eighth-round finding closed and agreed on the one P2: the startup sweep added in round eight to heal a gap marked under an obsolete outcome list could not tell such a mark from the pre-history default or the clock-rollback hold, which read the same (`history_unproven`), and would have cleared those on the next start. Fixed below with the tests both asked for; one P3 from Opus fixed too.
+Reports: `OPUS-REVIEW-multi-currency-review9.md`, `CODEX-FINDINGS-multi-currency-review9.md`. Both found every eighth-round code finding closed (the stale sentences of this file that Astra listed were corrected in part, the rest in the tenth-round commit) and agreed on the one P2: the startup sweep added in round eight to heal a gap marked under an obsolete outcome list could not tell such a mark from the pre-history default or the clock-rollback hold, which read the same (`history_unproven`), and would have cleared those on the next start. Fixed below with the tests both asked for; one P3 from Opus fixed too.
| Finding | Disposition | Where | Reproduction |
| --- | --- | --- | --- |
| Opus P2-1, Astra 1 (P2): the heal sweep cleared gaps set on purpose. | Fixed by removal: no sweep clears a gap; a gap's cause is not recorded. The roll note forbids the rollback that would make an obsolete-outcome mark; clearing one is an operator's door that does not exist yet (open). | `billingquantityaudit.go` | `TestQuantityHistoryMigrationMarksOldSummaryUnproven` and `TestQuantityAuditClockRollbackPreservesEventAndHolds` (each now restarts the store and asserts the gap stays); `TestAGapFromAnObsoleteOutcomeListStaysHeld` (the mark stays, the account stays held) |
-| Opus P3-1: `intentSession` did not judge plan, band or terms version, so an open session with one of them altered was offered and paid, then reviewed, where it could have been expired. | Fixed: `intentSession` compares plan, band and terms version too, so the resume paths refuse and expire such a session. | `billingservice.go` | `TestCheckoutNeverOffersASessionInAnotherCurrency` ("an open session whose plan, band or terms changed is refused and expired") |
+| Opus P3-1: `intentSession` did not judge plan, band or terms version, so an open session with one of them altered was offered and paid, then reviewed, where it could have been expired. | Fixed: `intentSession` compares plan and band too, so the resume paths refuse and expire such a session. (The terms-version clause it also gained is redundant: `priceTermsFromMetadata` already carried the terms version into the price-terms comparison, and that iteration of the test passes without it; the plan and band clauses are the fix.) | `billingservice.go` | `TestCheckoutNeverOffersASessionInAnotherCurrency` ("an open session whose plan, band or terms changed is refused and expired") |
| Opus claims: six stale sentences in this file (the review-eight "no P2", "generated checks" twice, "dropped first", "removing the check is killed", review seven's "the one P2"). | Corrected in this commit. | this file | |
Open after this round: an operator's door to clear a quantity-history gap whose cause the operator has established (none exists; the account stays held); the site's `bands.p3` sentence; two unreviewed system strings on the billing page; everything that needs Stripe test mode.
The fixes are in the commit that adds this section and have not been read by a reviewer.
+
+## Review ten (reviewed `faf1bd25`; Opus accepted with fixes, Astra accepted with fixes)
+
+Reports: `OPUS-REVIEW-multi-currency-review10.md`, `CODEX-FINDINGS-multi-currency-review10.md`. The first round in which both reviewers accept. Both found every ninth-round code finding closed (the plan and band clauses of `intentSession` with their tests; the terms-version clause redundant, as the row above now says), nothing new in a money path, and the same P3: reconcile judged the bound session on ownership and currency alone. Both lists of stale sentences in this file are corrected in place. Fixed below, each with a test that fails without it; three mutants were killed in a scratch copy (the account-metadata clause removed; reconcile's purchase judgement removed; the single reader's reference column removed).
+
+| Finding | Disposition | Where | Reproduction |
+| --- | --- | --- | --- |
+| Opus P3-1, Astra 1 (P3): reconcile checked the bound session with `ownSession` and the currency, not `intentSession`, so a subscription event delivered before the completion event recorded a purchase whose session the binder would have reviewed for its plan or band. | Fixed: reconcile judges the intent's own, complete session with `intentSession` after ownership, and a mismatch files the same intent-keyed review the binder files and records nothing; the intent stands. The binder's inline comparison is `intentSession` too, so the purchase judgement has one definition. | `billingservice.go` `reconcileBillingSubscription`, `bindBillingCheckoutFor` | `TestASubscriptionEventBeforeTheCompletionMeetsTheSameJudgement` (the session's metadata alone altered, plan and band in turn, through the fake's new `sessionMeta` knob; `customer.subscription.created` first: one review keyed by the intent with the session as its reference, nothing recorded, the intent standing; then the completion and a second subscription event: still one) |
+| Astra 2 (P3): a session whose `client_reference_id` is A's and whose `metadata.account_sqid` is B's passed `ownSession` and `intentSession`, so the resume and listing paths could offer or keep what the binder would refuse. | Fixed: `ownSession` requires the account metadata to name the account too; such a session is nobody's on every path, and, being nobody's, is not expired either. | `billingservice.go` `ownSession` | `TestOwnSessionClauses` ("another account in the metadata"); `TestASessionNamingTwoAccountsIsNobodys` (a bound open session read back so is not offered, not expired, the intent left as it was; a listed one is not bound, and the same listing binds it once its accounts agree) |
+| Opus P3-2, Astra claims: the roll note promised a heal that review nine removed. | Corrected: the prohibition is absolute; a mark an older binary makes stays until an operator's door that does not exist. | this file, roll note | |
+| Astra claims: the single-review reader `billingReview` did not carry `reference`. | Fixed: it selects the column; the resolution test reads the review singly. | `billingstore.go` `billingReview` | `TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution` |
+| Astra claims: the post-resolution test replayed an open replacement session, not the retired session's late completion, because the fake had one session id. | Fixed: the second purchase binds `cs_test_second` (the fake answers any session path with its one session, so a console that asked for another id sees the mismatch), and the late webhook for `cs_test_paid` is answered, once, with the retired session as it was (complete, EUR, the retired intent's metadata): nothing recorded, reviewed or expired, the standing intent untouched. | `billingservice_test.go`, `billingcurrency_test.go` | the same test |
+| Astra claims: the site test named "the book is the platform export" checked the schema, the currencies and a source note, not equality. | Renamed for what it checks (site `3f1610d`); equality with the platform's export is checked by hand against the commit the source note names, as before. | site `test/pricing-pricebook.test.mjs` | |
+| Both: stale sentences in this file (review seven's agreement sentence; "every seventh-round finding closed"; the review-eight heal row; the terms-version row; "generated"/"uniformly distinguishing" in review four; the review-nine summary). | Corrected in place, each with the correction in brackets where the original sentence is history. | this file | |
+
+Still open after this round, as both reviewers list: everything that needs Stripe test mode (the list in both reports and in `ENABLEMENT.md` section 6, now with session metadata immutability, line-item reads and webhook ordering added), the two unreviewed system strings, and the operator's door for a quantity-history gap. The eleventh review reads this commit.
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go
index ecb969fec..2a85a2d46 100644
--- a/internal/console/billingcurrency_test.go
+++ b/internal/console/billingcurrency_test.go
@@ -2656,7 +2656,7 @@ func TestOwnSessionClauses(t *testing.T) {
b := fx.srv.billing
intent := billingIntent{ID: "0123456789abcdef0123456789abcdef0123456789abcdef", Customer: "cus_paid"}
good := func() *billingstripe.Session {
- return &billingstripe.Session{ID: "cs_a", LiveMode: b.cfg.LiveMode, CustomerID: "cus_paid", ClientReferenceID: acct.Sqid, Metadata: map[string]string{"checkout_intent": intent.ID}}
+ return &billingstripe.Session{ID: "cs_a", LiveMode: b.cfg.LiveMode, CustomerID: "cus_paid", ClientReferenceID: acct.Sqid, Metadata: map[string]string{"checkout_intent": intent.ID, "account_sqid": acct.Sqid}}
}
if !b.ownSession(good(), intent, acct) {
t.Fatal("the intent's own session is not its own")
@@ -2667,7 +2667,10 @@ func TestOwnSessionClauses(t *testing.T) {
"another intent": func(s *billingstripe.Session, _ *billingIntent) {
s.Metadata["checkout_intent"] = "fedcba9876543210fedcba9876543210fedcba9876543210"
},
- "another account": func(s *billingstripe.Session, _ *billingIntent) { s.ClientReferenceID = "zzz" },
+ "another account": func(s *billingstripe.Session, _ *billingIntent) { s.ClientReferenceID = "zzz" },
+ "another account in the metadata": func(s *billingstripe.Session, _ *billingIntent) {
+ s.Metadata["account_sqid"] = "zzz"
+ },
"another customer": func(s *billingstripe.Session, _ *billingIntent) { s.CustomerID = "cus_other" },
"not the bound session": func(_ *billingstripe.Session, i *billingIntent) { i.SessionID = "cs_b" },
} {
@@ -2874,6 +2877,11 @@ func TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution(t *testing.T)
if open, n := review(); open != 1 || n != 1 {
t.Fatalf("after the webhook alone: %d open, %d occurrences; want the review filed by it", open, n)
}
+ // The single-review reader, which a resolution quotes, carries the
+ // session too.
+ if one, err := fx.st.billingReview(acct.ID, intent.ID, billingCheckoutRefusedReason); err != nil || one.Reference != intent.SessionID || one.Occurrences != 1 {
+ t.Fatalf("the review read singly: %+v %v; want the session as its reference", one, err)
+ }
// A member joins while the refused intent stands: a quantity event
// against the intent, which the resolution must close. (Such events
// are kept for live, team-plan intents; the fixture's intent is made
@@ -2931,12 +2939,17 @@ func TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution(t *testing.T)
if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_quantity_changes WHERE account_id=? AND history_gap<>0`, acct.ID).Scan(&gaps); err != nil || gaps != 0 {
t.Fatalf("%d history gaps after a second start (%v)", gaps, err)
}
- // A new purchase opens a new intent; a binding of the old session that
- // read it before the resolution files nothing now, and a resolution
- // named for the old intent (already resolved: it returns before any
- // delete) touches the new one not at all.
+ // A new purchase opens a new intent, bound to a session of its own
+ // (cs_test_second); a binding of the old session that read it before
+ // the resolution files nothing now, and a resolution named for the old
+ // intent (already resolved: it returns before any delete) touches the
+ // new one not at all.
+ oldMeta := map[string]string{}
f.mu.Lock()
- f.sessionStatus, f.sessionCurrency = "open", ""
+ for k, v := range f.meta {
+ oldMeta[k] = v
+ }
+ f.sessionStatus, f.sessionCurrency, f.sessionID = "open", "", "cs_test_second"
f.mu.Unlock()
// (The fake's sessions expire an hour after its clock: the new purchase
// is made within that hour.)
@@ -2960,14 +2973,159 @@ func TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution(t *testing.T)
if after, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || after.ID != second.ID {
t.Fatalf("the new intent after a resolution named for the old: %+v %v", after, err)
}
+ if bound, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || bound.SessionID != "cs_test_second" {
+ t.Fatalf("the second intent's session: %+v %v", bound, err)
+ }
_ = fx.srv.billingPass(context.Background(), f.now.Add(2*time.Hour))
+ // The retired session's own completion, delivered late: the provider
+ // answers the read of cs_test_paid with that session as it was, paid
+ // in EUR for the retired intent. It is nobody's now: not the standing
+ // intent's, and its own intent is retired, so nothing is recorded,
+ // reviewed or expired.
+ f.mu.Lock()
+ f.sessionAnswers = []func(*fakeBillingStripe){func(f *fakeBillingStripe) {
+ f.sessionID, f.sessionStatus, f.sessionCurrency, f.sessionMeta = "cs_test_paid", "complete", "eur", oldMeta
+ }}
+ f.mu.Unlock()
send("evt_again")
if open, n := review(); open != 0 || n != 1 {
- t.Fatalf("after resolution, a pass and a webhook: %d open, %d occurrences; want the review to stay resolved", open, n)
+ t.Fatalf("after resolution, a pass and the retired session's late completion: %d open, %d occurrences; want the review to stay resolved", open, n)
}
if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" || f.expireCalls != 0 {
t.Fatalf("state %+v, %d expiries; want nothing recorded or expired", state, f.expireCalls)
}
+ if after, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || after.ID != second.ID || after.SessionID != "cs_test_second" {
+ t.Fatalf("the standing intent after the late completion: %+v %v", after, err)
+ }
+}
+
+// A session that names two accounts (one in its client reference, another
+// in its metadata) is nobody's: not offered again, not bound from a
+// listing, and not expired either, since the console cannot say whose it
+// is. (Review ten, Astra 2: the binder refused such a session and the
+// resume and listing paths did not.)
+func TestASessionNamingTwoAccountsIsNobodys(t *testing.T) {
+ click := func(fx *consoleFixture, f *fakeBillingStripe, acct *Account, after time.Duration) (string, error) {
+ return fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(after))
+ }
+ t.Run("a bound open session read back with another account in its metadata is not offered", func(t *testing.T) {
+ fx, f, acct := newCurrencyFixture(t)
+ f.customerCountry = "GB"
+ if dest, err := click(fx, f, acct, 0); err != nil || dest == "" {
+ t.Fatalf("first click: dest %q err %v", dest, err)
+ }
+ before, _ := fx.st.billingCheckoutIntent(acct.ID)
+ f.mu.Lock()
+ f.meta["account_sqid"] = "zzz"
+ f.mu.Unlock()
+ if dest, err := click(fx, f, acct, time.Minute); dest != "" || !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("the session named another account: dest %q err %v; want no destination", dest, err)
+ }
+ if f.expireCalls != 0 || f.sessionStatus != "open" {
+ t.Fatalf("%d expiries, session %s; want a session that is nobody's left alone", f.expireCalls, f.sessionStatus)
+ }
+ if after, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || after.ID != before.ID || after.SessionID != before.SessionID {
+ t.Fatalf("intent %+v %v; want it as it was", after, err)
+ }
+ })
+ t.Run("a listed session with another account in its metadata is not bound", func(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, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || intent.SessionID != "" {
+ t.Fatalf("fixture: intent %+v %v", intent, err)
+ }
+ terms := intent.priceTerms()
+ f.mu.Lock()
+ f.failCreate, f.sessionStatus, f.sessionCurrency = false, "open", "gbp"
+ // The intent's purchase in every respect but the account metadata.
+ f.sessionMeta = map[string]string{"checkout_intent": intent.ID, "account_sqid": "zzz", "plan": intent.Plan, "band": intent.Band, "terms_version": terms.TermsVersion, "price_book": terms.PriceBook, "currency": string(terms.Currency)}
+ f.mu.Unlock()
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(time.Minute))
+ after, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || after.SessionID != "" || f.expireCalls != 0 {
+ t.Fatalf("intent %+v %v, %d expiries; want the listed session neither bound nor expired", after, err, f.expireCalls)
+ }
+ // And with the metadata agreeing, the same listing binds it: the
+ // fixture is otherwise the intent's own.
+ f.mu.Lock()
+ f.sessionMeta["account_sqid"] = acct.Sqid
+ f.mu.Unlock()
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(2*time.Minute))
+ if bound, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || bound.SessionID == "" {
+ t.Fatalf("the same session with its accounts agreeing: %+v %v; want it bound", bound, err)
+ }
+ })
+}
+
+// A subscription event that arrives before the completion event for the
+// intent's own session, completed for another plan, meets the same
+// judgement as the completion would: nothing is recorded, the review is
+// filed once, keyed by the intent, and the intent stands. (Review ten,
+// both reviewers: reconcile judged the bound session on ownership and
+// currency alone, so delivery order decided whether a plan or band
+// mismatch was reviewed or recorded.)
+func TestASubscriptionEventBeforeTheCompletionMeetsTheSameJudgement(t *testing.T) {
+ for _, field := range []string{"plan", "band"} {
+ 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("%s: first click: dest %q err %v", field, dest, err)
+ }
+ intent, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || intent.SessionID == "" {
+ t.Fatalf("%s: fixture: intent %+v %v", field, intent, err)
+ }
+ // The subscription is the intent's purchase exactly; the session's
+ // metadata alone differs from it.
+ sessionMeta := map[string]string{}
+ f.mu.Lock()
+ for k, v := range f.meta {
+ sessionMeta[k] = v
+ }
+ sessionMeta[field] = map[string]string{"plan": plans.PlanBusiness, "band": plans.BandD}[field]
+ f.sessionStatus, f.subscriptionPrice, f.sessionMeta = "complete", intent.Price, sessionMeta
+ f.mu.Unlock()
+ if code := f.webhookPost(t, fx, "evt_sub_first_"+field, "customer.subscription.created", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("%s: the subscription event: %d", field, code)
+ }
+ review := func() (open int, occurrences int64, reference string) {
+ err := fx.st.db.QueryRow(`SELECT occurrences, reference FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0`, acct.ID, intent.ID, billingCheckoutRefusedReason).Scan(&occurrences, &reference)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, 0, ""
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return 1, occurrences, reference
+ }
+ if open, n, ref := review(); open != 1 || n != 1 || ref != intent.SessionID {
+ t.Fatalf("%s: after the subscription event alone: %d open, %d occurrences, reference %q; want one review keyed by the intent with the session", field, open, n, ref)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" {
+ t.Fatalf("%s: the subscription was recorded before the session was judged: %+v", field, state)
+ }
+ if after, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || after.ID != intent.ID {
+ t.Fatalf("%s: intent %+v %v; want it standing", field, after, err)
+ }
+ // The completion event after it, and the subscription event again:
+ // still one review, nothing recorded.
+ if code := f.webhookPost(t, fx, "evt_done_"+field, "checkout.session.completed", intent.SessionID); code != http.StatusNoContent {
+ t.Fatalf("%s: the completion event: %d", field, code)
+ }
+ if code := f.webhookPost(t, fx, "evt_sub_again_"+field, "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("%s: the subscription event again: %d", field, code)
+ }
+ if open, n, _ := review(); open != 1 || n != 1 {
+ t.Fatalf("%s: after the completion and a second subscription event: %d open, %d occurrences; want the one review", field, open, n)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" || f.expireCalls != 0 {
+ t.Fatalf("%s: state %+v, %d expiries; want nothing recorded or expired", field, state, f.expireCalls)
+ }
+ }
}
// A refused, paid session found by the listing (the create response was
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go
index 9d7e58c30..f4fecd799 100644
--- a/internal/console/billingservice.go
+++ b/internal/console/billingservice.go
@@ -255,12 +255,14 @@ func (b *billingRuntime) intentSession(session *billingstripe.Session, intent bi
}
// ownSession reports whether a session is this intent's at all: this
-// account's, in this mode, carrying the intent's id, for the intent's own
-// customer (once the intent has one), and, once the intent is bound, the
-// very session it is bound to. Only such a session is ever expired,
-// offered, bound or recorded on the intent's behalf.
+// account's (its client reference and its account metadata both, so a
+// session that names two accounts is nobody's), in this mode, carrying
+// the intent's id, for the intent's own customer (once the intent has
+// one), and, once the intent is bound, the very session it is bound to.
+// Only such a session is ever expired, offered, bound or recorded on the
+// intent's behalf.
func (b *billingRuntime) ownSession(session *billingstripe.Session, intent billingIntent, acct *Account) bool {
- if session == nil || session.ID == "" || session.LiveMode != b.cfg.LiveMode || session.Metadata["checkout_intent"] != intent.ID || session.ClientReferenceID != acct.Sqid {
+ if session == nil || session.ID == "" || session.LiveMode != b.cfg.LiveMode || session.Metadata["checkout_intent"] != intent.ID || session.ClientReferenceID != acct.Sqid || session.Metadata["account_sqid"] != acct.Sqid {
return false
}
if intent.Customer != "" && session.CustomerID != intent.Customer {
@@ -793,9 +795,17 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
if !b.ownSession(session, intent, acct) || session.SubscriptionID != id || session.CustomerID != sub.CustomerID {
return errBillingIdentity
}
- // A purchase priced from a book was paid in the book's currency for
- // the buyer's choice, with no conversion on the session.
- if !priceTerms.Legacy() && !session.FixedCurrency(string(priceTerms.Currency)) {
+ // And the purchase the intent describes: plan, band, terms, price
+ // terms, and for a book purchase its currency with no conversion
+ // on it. The intent's own session completed for anything else is
+ // the review the binder files for it (bindBillingCheckoutFor), so
+ // a subscription event that arrives before the completion event
+ // meets the same judgement and records nothing; the intent stands
+ // until the operator resolves the review.
+ if !b.intentSession(session, intent, acct) {
+ if err := s.store.fileCheckoutRefusedReview(intent, session.ID, now); err != nil {
+ return err
+ }
return errBillingIdentity
}
if session.PaymentStatus != "paid" && session.PaymentStatus != "no_payment_required" {
@@ -967,8 +977,7 @@ func (s *Server) bindBillingCheckoutFor(ctx context.Context, sessionID string, e
if !b.ownSession(session, intent, acct) {
return "", errBillingIdentity
}
- 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))) {
+ if !b.intentSession(session, intent, acct) {
// 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
diff --git a/internal/console/billingservice_test.go b/internal/console/billingservice_test.go
index 49d940069..48de1e2b4 100644
--- a/internal/console/billingservice_test.go
+++ b/internal/console/billingservice_test.go
@@ -49,8 +49,12 @@ type fakeBillingStripe struct {
// answering a read of one session with another).
sessionCustomer, sessionID string
// sessionSqid, when set, is the client_reference_id the session names
- // in place of the fixture account's.
+ // in place of the fixture account's. sessionMeta, when set, is the
+ // metadata the session carries in place of the metadata the checkout
+ // was created with (meta), which the subscription keeps: the provider
+ // answering with session metadata that differs from the subscription's.
sessionSqid string
+ sessionMeta map[string]string
// sessionAnswers, when set, are applied to the fake in order, one per
// rendering of the session, before it is rendered: the provider
// answering successive reads of one session differently.
@@ -171,7 +175,11 @@ func (f *fakeBillingStripe) session() map[string]any {
if f.sessionSqid != "" {
sqid = f.sessionSqid
}
- return map[string]any{"currency": currency, "id": id, "url": "https://checkout.stripe.com/c/paid", "status": f.sessionStatus, "expires_at": f.now.Add(time.Hour).Unix(), "livemode": false, "customer": customer, "subscription": sub, "payment_status": payment, "client_reference_id": sqid, "metadata": f.meta, "customer_details": map[string]any{"address": map[string]string{"country": "IE"}}}
+ meta := f.meta
+ if f.sessionMeta != nil {
+ meta = f.sessionMeta
+ }
+ return map[string]any{"currency": currency, "id": id, "url": "https://checkout.stripe.com/c/paid", "status": f.sessionStatus, "expires_at": f.now.Add(time.Hour).Unix(), "livemode": false, "customer": customer, "subscription": sub, "payment_status": payment, "client_reference_id": sqid, "metadata": meta, "customer_details": map[string]any{"address": map[string]string{"country": "IE"}}}
}
func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
@@ -236,7 +244,7 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
return
}
out = f.session()
- case r.URL.Path == "/v1/checkout/sessions/cs_test_paid/expire" && r.Method == http.MethodPost:
+ case strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/") && strings.HasSuffix(r.URL.Path, "/expire") && r.Method == http.MethodPost:
f.expireCalls++
if f.failExpire > 0 {
f.failExpire--
@@ -251,7 +259,10 @@ func (f *fakeBillingStripe) serve(w http.ResponseWriter, r *http.Request) {
}
f.sessionStatus = "expired"
out = map[string]any{"id": "cs_test_paid", "url": "https://checkout.stripe.com/c/paid", "status": "expired", "customer": "cus_paid", "livemode": false, "client_reference_id": f.sqid, "metadata": f.meta}
- case r.URL.Path == "/v1/checkout/sessions/cs_test_paid":
+ case strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/") && r.Method == http.MethodGet:
+ // Any id: the session rendered is the fake's one session, whose id
+ // is sessionID or cs_test_paid; a console that asked for another
+ // id sees the mismatch, as it would from the provider.
out = f.session()
case r.URL.Path == "/v1/coupons/coupon_house":
if f.coupon != nil {
diff --git a/internal/console/billingstore.go b/internal/console/billingstore.go
index b0cc207fb..c2e61fc1e 100644
--- a/internal/console/billingstore.go
+++ b/internal/console/billingstore.go
@@ -783,8 +783,8 @@ type billingReview struct {
// Occurrences it returns is the value resolveBillingReview is conditional on.
func (s *Store) billingReview(accountID int64, subscription, reason string) (billingReview, error) {
x := billingReview{AccountID: accountID, Subscription: subscription, Reason: reason}
- err := s.db.QueryRow(`SELECT occurrences,created_at,last_seen_at,resolved_at FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=?`,
- accountID, subscription, reason).Scan(&x.Occurrences, &x.CreatedAt, &x.LastSeenAt, &x.ResolvedAt)
+ err := s.db.QueryRow(`SELECT occurrences,created_at,last_seen_at,resolved_at,reference FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=?`,
+ accountID, subscription, reason).Scan(&x.Occurrences, &x.CreatedAt, &x.LastSeenAt, &x.ResolvedAt, &x.Reference)
return x, err
}