carlos / platform Public

Stage one of the retirement protocol: a refused intent is released only on what the store has recorded of its session, and everything not plainly safe is held

Review eighteen found that an operator's retire could delete an intent
whose session was still open and payable. Reordering the review and the
expiry had been shown wrong both ways, so the retirement is now a protocol
with recorded steps (design revision 4, after three rejected design
reviews): the review is filed first and is authoritative; the fate of the
session the intent is pinned to is written once, from a provider read whose
identity clauses all hold (never from an expiry answer); the operator's
resolution reads that state in its own transaction and refuses unless it
allows the outcome.

Stage one offers retire for a session proven expired and unpaid with
nothing attached, and keep for a session proven complete and paid with no
subscription attached but its own. It holds everything else under its
review: a paid session that is not kept, any case a subscription made
outside checkout is attached to, any case the provider contradicted itself
about, and every review opened before this build. What disposes of those is
not decided (DECISIONS-FOR-PAUL 1 to 5 on the launch branch).

The door requests the expiry itself and answers 503 while it cannot be
proven. The pass retries a failed expiry on a schedule, last and bounded.
A click under a review is offered no URL. Evidence against a recorded fate,
and a subscription naming an intent retired on a proven expiry, are
disputes: an account review filed once. A delete barrier stops an older
binary from deleting an intent whose session is not proven expired; the
roll rule (older consoles stopped first, no rollback) carries the rest.

Eight tests, one per interleaving; eleven existing tests reworked (those
that retired a paid, contradicted or hand-made case now assert the hold);
seventeen mutants killed. The round-eighteen P2 stays recorded as open
until this is reviewed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 59666bab4d31bd1b3d5186448d1c2d1426455a6c parent 15041ca
13 files changed, +1630 −146
  • docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md +37 −2
  • docs/superpowers/specs/CODEX-FINDINGS-intent-retirement-design-review3.md +117 −0
  • docs/superpowers/specs/DISPOSITIONS-multi-currency.md +20 −0
  • internal/console/billingcurrency_test.go +74 −67
  • internal/console/billingholds_test.go +5 −1
  • internal/console/billingoperator.go +74 −41
  • internal/console/billingpass.go +18 −0
  • internal/console/billingretirement.go +601 −0
  • internal/console/billingretirement_test.go +544 −0
  • internal/console/billingreview_test.go +3 −0
  • internal/console/billingservice.go +86 −29
  • internal/console/billingservice_test.go +7 −0
  • internal/console/billingstore.go +44 −6

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md b/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md
index 9f36566ee..5964bc481 100644
--- a/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md
+++ b/docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md
@@ -1,6 +1,6 @@
-# The retirement of a refused checkout intent: a durable protocol (revision 3)
+# The retirement of a refused checkout intent: a durable protocol (revision 4)
-Status: design, revision 3, written 2026-10-02 on `feat/carloku-multi-currency-20261001`. Astra rejected revision 2 as well (`CODEX-FINDINGS-intent-retirement-design-review2.md`: five P2s, three P3s); the section "Revision 3" at the end answers each and narrows what is built first. Where that section and the text above it disagree, the section governs. Revision 2's status follows. Revision 1 (`8c72e88b`) was rejected by Astra's design review (`CODEX-FINDINGS-intent-retirement-design-review1.md`: four P2s, five P3s); this revision answers each finding in place. Nothing here is built. Billing is OFF. It replaces the ordering arguments of reviews thirteen to eighteen about when the bound session is expired relative to the review: both orders were shown wrong by an interleaving, and the fix is a protocol with recorded steps, not a third order.
+Status: design, revision 4, written 2026-10-02 on `feat/carloku-multi-currency-20261001`. Astra rejected revisions 1, 2 and 3 (reports in this directory). The sections "Revision 3" and "Revision 4" at the end answer the second and third reviews; where a later section and earlier text disagree, the later section governs. Revision 4 defines stage 1, which is what is built; it is reviewed as code, not sent for a fourth design review. Revision 2's status follows. Revision 1 (`8c72e88b`) was rejected by Astra's design review (`CODEX-FINDINGS-intent-retirement-design-review1.md`: four P2s, five P3s); this revision answers each finding in place. Nothing here is built. Billing is OFF. It replaces the ordering arguments of reviews thirteen to eighteen about when the bound session is expired relative to the review: both orders were shown wrong by an interleaving, and the fix is a protocol with recorded steps, not a third order.
## The problem
@@ -87,3 +87,38 @@ At the pinned API version: a successful expiry prevents payment afterwards, incl
9. **Stripe test mode, added:** a pending payment after completion, and what cancellation does to it; collectible invoices and refunds after a subscription is canceled; whether a successful expiry replay is distinguishable from a first answer; that a GET after an expiry reflects it.
**Decisions for Paul, complete list:** (1) may money ever be accepted without the purchase recording (the design says no); (2) how contradictory provider evidence is adjudicated, and whether a session whose identity stays contradicted has any exceptional disposition (the design holds it); (3) the copy the buyer sees while under review; (4) the settlement predicate: what must be true of the payment, invoices, refunds and subscription before a paid refused purchase may be retired as settled; (5) whether a referenced (never bound) session may be kept.
+
+## Revision 4: what review 3 changes, and what stage 1 is
+
+Astra's third review (`CODEX-FINDINGS-intent-retirement-design-review3.md`) rejected revision 3 on four P2s, each with a smallest change that is a conservative hold, and found review 2's findings otherwise closed at design level. This revision applies those four changes. It is not sent for a fourth design review: three bounded reviews have been run, and what remains is built and reviewed as code. Where this section and the text above it disagree, this section governs.
+
+**Stage 1 offers two dispositions, each only for the plain case, and holds every other case under its review:**
+
+- `retire`: the review was filed by this protocol (not migrated); it has **no recorded obligation at all**; nothing is disputed; the session's fate is proven `expired` (or the intent is never-created). A case a hand-made subscription touched is not retired in stage 1: canceling that subscription is not settling its money (review 3, finding 2), and what settles it is decision 2.
+- `keep`: the review was filed by this protocol; its only recorded obligation, if any, is the kept session's own subscription; nothing is disputed; the session is `complete` and paid; the subscription passes reconcile's judgement. A case with any other subscription attached is held (finding 1).
+- Held, with no disposition in stage 1: a paid session that is not kept; any case with a hand-made subscription attached; any disputed case; **every review that was open before this build** (finding 3: its obligations were never recorded, and an empty table is not proof there were none). A migrated review is marked as such (`billing_reviews.protocol = 0`); a review this build files has `protocol = 1`.
+
+**Disputes and late evidence survive the intent** (finding 4). A contradiction is recorded against the session, not only on the intent row: `billing_checkout_disputes` (account, session id, the intent it belonged to, what contradicted what, first and last seen, occurrences), with an account-level review `billing_checkout_fate_disputed` whose subject is the session id. It is written by any actor that meets contradicting evidence for a session the console pinned, whether the intent stands, was retired, or was consumed by a `keep`; lookup is by the session id through the intent, the retirement record, or the `keep` resolution's archived evidence. The review holds the account's invoices like any other and is resolved only by a person; stage 1 defines no outcome for it beyond acknowledging it with a note after a fresh read (it never deletes, records or releases anything). A subscription that names a retired or consumed intent and was not known as an obligation when the review was resolved is filed the same way. The webhook writes the dispute, the rejected-event row and the seen-event row in one transaction; a duplicate delivery changes nothing.
+
+**The trigger's claim, narrowed** (review 3's first contradicted claim): the delete barrier stops an older binary from deleting an intent whose session is not proven expired. It does not make an older binary follow the protocol (it would not check obligations or the generation); the roll rule does: older consoles are stopped before this build starts and are not rolled back to.
+
+**The second read after a gate refusal** is a database read and already sees a resolution committed by another console (review 3's second contradicted claim); the text above that called it process-local is wrong.
+
+**Added to the Stripe test-mode list:** a read after an identity correction; whether a completed session's subscription id can change (a different non-empty id never replaces a recorded obligation; it is a dispute); what `incomplete_expired` and `canceled` mean for money already taken, pending payments, invoices and refunds.
+
+**Decisions for Paul** are in `implementation/carloku-paid-launch/DECISIONS-FOR-PAUL.md` on the launch branch (items 1 to 5); stage 1 is built without them and they decide only what is held.
+
+## Stage one as built
+
+Built in `internal/console/billingretirement.go` and the paths it touches, with one test per interleaving in `billingretirement_test.go`. Where the build differs from the text above, this is what it does and why.
+
+- **No generation counter.** Revision 3 put a generation on the review, to be compared across an actor's provider work. Stage one needs none, because every fact a resolution reads is monotonic and is read inside the resolution's own transaction: a fate is written once and never rewritten; an attached subscription is only ever added; a dispute is only ever set; the review's protocol mark does not change while it is open. A fate, an attached subscription or a dispute recorded after the operator looked and before the transaction ran is therefore what the transaction sees. Evidence that contradicts a recorded fate is never discarded for arriving later or earlier: it is a dispute.
+- **A fate comes from a read, never from an expiry answer.** `advanceRefusal` reads the pinned session, requests the expiry of a bound session still open, and reads again; only a read whose identity clauses all hold is recorded. A payment that beats the expiry shows as `complete` in that second read.
+- **Webhooks: evidence before acknowledgement, idempotently, not one transaction.** The binder and reconcile record what they prove (a fate, an attached subscription, a dispute) in their own transactions, each of which changes nothing when repeated, and only then is the event recorded as rejected and seen. A crash between the two leaves the event unseen, the provider delivers it again, and the evidence writes are no-ops. A dispute is keyed by its session and its wording, so the same contradiction delivered twice files one review once.
+- **`keep` does not re-run reconcile's judgement of the subscription.** The door requires the bound session to be the intent's own, the intent's purchase, complete, paid and carrying a subscription, and the store requires the recorded fate complete, nothing attached but the session's own subscription, and no dispute. A subscription that is still not the purchase is refused again by the next reconcile, which files the review's next occurrence: keep closes nothing for good. (The read-only judgement revision 3 proposed needs reconcile's checks factored out; not done.)
+- **A stale observation after `keep`** (a pass that read a mismatch before the keep and files after it) reopens the review as a new occurrence while the intent still stands: one more hold, visible, never a silent record.
+- **An unbound intent under a review that names no session** is still reconciled by the pass, which is how its session is found; nothing records or retires under the review on that path.
+- **A retirement from before the protocol** (a retirement record with no fate) keeps its behaviour: its session's and its subscription's later events settle as rejected events. A subscription that names an intent retired on a proven expiry is filed as a dispute for that session, once.
+- **The reviews listing** shows each refusal's recorded fate, what is attached, and why `retire` and `keep` are or are not available.
+
+What stage one leaves held, with no disposition: a paid session that is not kept; any case a subscription made outside checkout is attached to; any disputed case; every review opened before this build. Decisions 1 to 5 govern those.
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-intent-retirement-design-review3.md b/docs/superpowers/specs/CODEX-FINDINGS-intent-retirement-design-review3.md
new file mode 100644
index 000000000..5fec449e8
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-intent-retirement-design-review3.md
@@ -0,0 +1,117 @@
+All references are at **`15041ca2`**. **D** means `docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md`; revision 3 governs. Source inspection only: no modifications, tests, network commands or AWS commands.
+
+| Second-review finding | Closed by revision 3 at design level? |
+|---|---|
+| **1. Separate subscription obligations survive retirement** | **Partly.** D:71 persists them and invalidates concurrent resolution through generation changes. Cancellation alone does not settle their money, and `keep` does not dispose of other obligations. Findings 1–2 below. |
+| **2. `settled` precedes financial settlement** | **Closed for stage 1 by exclusion.** D:69 withholds `settled`; D:89 makes its predicate an owner decision. The full disposition remains undesigned. |
+| **3. Timestamps discard contradictions behind cached answers** | **Yes for the ordering defect.** D:73 replaces timestamps with generations, requires a subsequent GET after expiry, and preserves incompatible evidence. Its destination after intent consumption remains incomplete. |
+| **4. Dispute resolution has no authoritative fate** | **Partly.** D:75 removes adjudication from stage 1, so no conflicting fate can authorize resolution. D:79 archives `keep` evidence, but the subsequent dispute state and enforcement remain undefined after consumption. Finding 4. |
+| **5. Migration barrier starts too late** | **Yes for mixed-version timing.** D:81 stops older consoles before startup and installs the barrier before handling refusals. Migration of existing reviews’ obligations remains missing. Finding 3. |
+| **6. Completed payment evidence becomes permanently stale** | **Yes.** D:77 permits refreshed payment evidence; `keep` obtains fresh session and subscription reads. |
+| **7. Concurrent exact replay returns a gate failure** | **Yes.** D:83 requires rereading after losing the race and applying the transactional exact comparison. The current gate-failure reread already works across consoles sharing the database. |
+| **8. Audit and webhook atomicity are unspecified** | **Yes.** D:83 identifies the resolution history as the atomic audit, declares general-audit replay behavior, and requires evidence, rejection and seen-event writes together, with duplicate delivery producing no second mutation. |
+
+| Second-review contradicted claim | Closed? |
+|---|---|
+| **Ordinary expiry retirement is untouched** | **Yes.** D:79 explicitly changes `retireIntent` to respect a `keep` resolution. |
+| **Retired completions retain account attribution beside a replacement** | **Yes, in this commit’s code.** The retirement lookup precedes replacement-intent lookup and returns the typed account error (`internal/console/billingservice.go:1137`). |
+| **Replays perform no writes anywhere** | **Yes as clarified.** D:83 limits the guarantee to the atomic resolution; the general audit may repeat. |
+| **Today’s `keep` gate guarantees the corrected subscription records** | **Yes at design level.** D:79 adds read-only subscription validation. That validation is proposed, not present in today’s session-only gate. |
+
+**Stage 1 is not safe to build exactly as specified.** The original failed-expiry retirement race is closed, but the following gaps can still release a review without disposing of its obligations or preserving its later contradictions.
+
+**Findings, most severe first**
+
+1. **P2 — `keep` can release a review while another recorded subscription remains collectible.**
+
+ **Where:** D:28, :69, :71, :79; `internal/console/billingservice.go:828`, :941–970.
+
+ **Scenario:** A hand-made subscription H names intent I and has the wrong price. Its refusal records H as an obligation. Expiry of I’s bound session S fails. The buyer’s retained URL then completes S, creating the correct subscription C.
+
+ The operator selects `keep`. S is complete and paid; C passes the newly specified subscription validation. There is no terminal or identity contradiction. Every stated `keep` condition passes, although H remains active. The review closes and C records beside H’s continuing charges.
+
+ D:71 checks obligations only for `retire`. Validating C does not dispose of H.
+
+ **Smallest acceptable change:** Apply an obligation predicate to `keep` too. Identify the subscription being accepted, require every other recorded obligation to have an approved disposition, and compare the obligation generation in the resolution transaction. For stage 1, holding any case with an unresolved additional subscription is sufficient.
+
+2. **P2 — Cancellation of a triggering subscription still substitutes for financial settlement under `retire`.**
+
+ **Where:** D:26, :69, :71, :87, :89; `internal/console/billingservice.go:822–825`; `internal/console/billingstore.go:1002–1009`.
+
+ **Scenario:** Hand-made subscription H has collected money, or has a pending payment or collectible invoice. The console expires the separate bound checkout S. The operator cancels H.
+
+ D:71 now allows `retire`: S is proven expired and every recorded obligation is canceled. Nothing requires a completed refund or disposition of H’s pending payments and invoices. Retirement deletes I, clears the review and causes subsequent H events to take the retired-intent rejection path.
+
+ Deferring `settled` protects completed **sessions**, but this path reaches the same unresolved financial decision through an expired session and a separate subscription.
+
+ **Smallest acceptable change:** Make the deferred settlement policy apply to associated subscriptions as well. Until that policy exists, hold cases requiring financial disposition. A cancellation status alone must not establish that disposition. Any stage-1 exception needs an explicit, evidenced predicate proving that no unresolved financial obligation remains.
+
+ This is also an owner decision: D:71 currently chooses a weaker settlement predicate than the decision deferred at D:89.
+
+3. **P2 — Existing open reviews migrate with an empty obligation set that can falsely pass resolution.**
+
+ **Where:** D:55, :71, :81; `internal/console/billingstore.go:1026–1055`; `internal/console/billingservice.go:850`; `internal/console/billingpass.go:183–225`.
+
+ **Scenario:** Before upgrade, H caused a refusal review for I. The existing review records S as its reference; it does not preserve H as an obligation. After upgrade, the new obligation table contains no row for H.
+
+ The pass proves S expired. Before another H event arrives, the operator retires I. Checking “every recorded obligation” succeeds over an empty set, although H remains active. The startup delete trigger also permits this deletion because S’s fate is expired and undisputed.
+
+ Stopping old binaries closes the mixed-version race; it cannot reconstruct information missing from existing reviews.
+
+ **Smallest acceptable change:** Mark migrated open reviews as having **unknown obligation completeness**. Refuse new dispositions until their obligations have been reconstructed and verified, or keep those reviews held throughout stage 1. Do not equate an empty migrated table with a proven absence of obligations.
+
+ Preserve already-committed legacy resolutions and their exact replay behavior.
+
+4. **P2 — Late contradictions have no fully specified durable hold after `keep` consumes the intent.**
+
+ **Where:** D:21, :23, :51, :73, :75, :79; `internal/console/billingstore.go:171–172`, :766–768, :1033–1037; `internal/console/billingservice.go:1128–1129`.
+
+ **Scenario:** Console A starts reading S and receives contradictory identity evidence, but pauses before persisting it. The provider metadata is corrected. Console B obtains valid evidence, commits `keep`, and reconciliation records the purchase and consumes I. A resumes.
+
+ D:73 expressly requires preserving the incompatible evidence despite its older generation. But `fate_disputed_at` lived on the deleted intent. D:79 preserves resolution evidence without defining a surviving mutable dispute record, its lookup path, or its effect on the now-recorded subscription. The existing refusal helper silently returns when the intent is gone; resolution history is immutable.
+
+ D:51 describes a dispute on a **retirement** record, but `keep` creates no such record. The already-recorded subscription fast path also bypasses the original intent.
+
+ **Smallest acceptable change:** Define a durable dispute target that survives both retirement and consumption. Specify how late evidence finds it by the pinned session, how it creates an actionable account/subscription hold, and which subsequent operations that hold blocks. Preserve the original resolution audit unchanged. Include this in the webhook’s atomic evidence/rejection/seen transaction.
+
+ Apply the same lifecycle rule to a newly discovered obligation arriving after resolution: it must not disappear merely because the intent no longer stands.
+
+**Other requested protocol checks**
+
+- **Fate evidence:** D:14 sufficiently separates ownership from purchase correctness: exact session ID, test/live mode, intent metadata, both account identifiers and known customer are required. A session read back as nobody’s cannot prove expiry. `complete` is distinct from payment; `keep` additionally requires acceptable payment status and subscription validation. D:73 correctly excludes an expiry response alone, including a remembered successful response, from proving expiry. Terminal fate remains immutable; payment evidence may refresh. These guarantees remain conditional on the provider assumptions being verified.
+
+- **Atomic completion and locks:** The named lock at D:25 is correct: `s.billing.locks.lock(acct.Sqid, "billing")`, spanning the gate and resolution without recursive acquisition. A transaction reserves the store’s sole connection (`internal/console/store.go:65`), so another operation through that store cannot modify fate between its transactional read and commit. Helpers must use `tx`, not `s.db`. Other consoles require database conflict handling and generation checks. A retry must not attach old provider evidence to a newly read generation.
+
+ The service mutex is insufficient by itself. Pass ownership/refusal work and recovery binding run outside it (`billingpass.go:197`, :257), as does expected-intent ownership handling before the binder’s lock (`billingservice.go:1110`, :1123). Their proposed fate writes must use the same transactional conditions.
+
+- **Outcome effects and replay:** The specified local effects are coherent: `retire` archives and deletes exactly the refused intent and closes only its pending checkout quantity events; `keep` leaves those available for recording. Neither should erase rejected-event history. `settled` is the appropriate separate disposition for a completed purchase that will not record, but remains unavailable until its financial predicate is approved. Exact actor/note/outcome comparison remains sound with three outcome names; evidence is the archived basis of the original decision, not something a replay reevaluates.
+
+- **Clicks and retries:** Refusing every resume path prevents further URL disclosure. It cannot revoke a URL already held, which D:23 correctly acknowledges. Keeping the intent standing prevents a replacement purchase while the review remains open. The scheduled backoff, separate budget and cursor close the previous starvation concern at design level. Expiry must target the pinned bound ID—or the fully validated, pinned reference in the lost-create case—under the same fixed key. A late response must never retarget a replacement intent.
+
+- **Lost create response:** D:12–15 and :85 define adequate proof for the referenced session. A paid referenced session cannot be retired as expired and cannot be kept in stage 1; it remains held. This safely defers decision 5.
+
+- **Migration and disabled billing:** Historical `retire` must remain historical `retire` with absent protocol evidence, never inferred expiry or relabeled `settled`. An omitted outcome still means `retire`; it conflicts on a new completed-session case. Committed exact replays must bypass provider requirements even with billing disabled. New provider-dependent dispositions remain refused. Finding 3 is the additional migration requirement.
+
+**Stripe verification and owner decisions**
+
+Before enablement, D:59 and :87 correctly require test-mode verification of terminal exclusivity, retained URLs, payment racing expiry, complete proof fields, same-key concurrency, remembered errors, lost responses, key-retention expiry, subsequent GET visibility, delayed payment, `no_payment_required`, and referenced-session recovery.
+
+Add explicit acceptance criteria for:
+
+- Reads after **identity correction**, including delayed GET/listing responses; verifying visibility of expiry alone does not establish visibility of corrected identity.
+- Whether a completed session’s subscription association can change. D:77 permits refreshing `session_subscription`; a different nonempty subscription ID must not silently replace an associated obligation.
+- The financial meaning of both accepted obligation statuses, especially `incomplete_expired`, and pending payments, invoices and refunds after cancellation. Those assumptions now affect stage-1 `retire`, not only deferred `settled`.
+
+Crash boundaries, competing consoles, duplicate webhooks and transactional rollback belong in controlled local interleaving tests. They must include obligation discovery during resolution, migrated reviews, and evidence arriving after `keep` consumption. No such tests were run here.
+
+Decisions 1, 2, 4 and 5 can remain deferred if the corresponding cases stay held. Decision 3 remains necessary before shipping buyer copy. The obligation policy in Findings 1–2 must either receive owner approval or use a conservative hold before stage 1 is built.
+
+**Claims the design makes that the code contradicts**
+
+- **D:81: an older binary’s `retire` necessarily aborts under the trigger.** The stated trigger permits deletion when fate is expired and undisputed. The old resolution path at `internal/console/billingstore.go:982–1009` would then delete without checking the new obligations or generation. The mandatory stop-and-no-rollback rule makes the rollout safe from that writer; the trigger alone does not enforce the complete protocol. Narrow the claim accordingly.
+
+- **D:83: the existing gate-failure reread is effective only “within one console.”** `internal/console/billingoperator.go:350–353` rereads the database and recognizes a resolution committed by another console too. The final transaction still arbitrates competing writes, but the preliminary reread is not process-local.
+
+The four contradictions from review 2 are otherwise corrected or explicitly superseded. I have not counted proposed protocol fields and behavior as defects merely because they are not implemented at this commit.
+
+**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 33200ce65..55d21a253 100644
--- a/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
+++ b/docs/superpowers/specs/DISPOSITIONS-multi-currency.md
@@ -364,3 +364,23 @@ Reports: `OPUS-REVIEW-multi-currency-review18.md`, `CODEX-FINDINGS-multi-currenc
| Claims (both): `ownBoundSession`'s comment described filing twice and ended mid-sentence; the review-seventeen row said a failed expiry "is tried again at the next read" (true for `ownBoundSession`, false for reconcile's refusal, whose session the pass sees as an ordinary open one). | The comment is corrected and says what is open; the row's claim is withdrawn here: nothing retries reconcile's failed expiry until the protocol is built. `refuse()`'s and `ownSession`'s comments on the order are left for the protocol's commit, which rewrites both sites. | `billingservice.go`, this file | |
Open: the P2 above; everything needing Stripe test mode (both lists; Astra adds expiry failures, completion during expiry and a restart after the review is filed, with operator resolution attempted in each interval); the two unreviewed system strings; the operator's door for a quantity-history gap; the metered model's remaining code.
+
+## Stage one of the retirement protocol (review eighteen's P2)
+
+The design (`2026-10-02-intent-retirement-protocol-design.md`) was reviewed three times by Astra and rejected three times (`CODEX-FINDINGS-intent-retirement-design-review1.md` to `-review3.md`); revision 4 applies the third review's four changes, each a conservative hold, and is built as stage one. **The P2 is closed only when this code has been reviewed and its tests accepted; until then it is recorded as open in `READINESS.md`.**
+
+What changed in behaviour:
+
+| Before | Stage one |
+| --- | --- |
+| The operator's `retire` deleted the intent on the operator's word, whatever its session's state. | `retire` is accepted only for a review this protocol filed, with nothing attached, nothing disputed, and the session proven expired and unpaid by a provider read (or no session ever created). The door requests the expiry itself and answers 503 while it cannot be proven; the store's transaction refuses on the recorded state. |
+| A paid, refused session was retired by the operator ("refunded"). | Held. What settles a paid refused purchase is decision 2. |
+| A hand-made subscription's case was retired once its session was expired. | Held: the subscription is recorded as attached, and canceling it is not settling it. |
+| A failed expiry after the review was retried by nothing. | The pass retries it on a schedule (1, 5, 15 minutes, then hourly), last in the pass and at most three refusals per pass. |
+| A click under a review resumed the bound session's URL when the session was still the intent's purchase. | No URL, no new intent, while the review is open. |
+| A recorded outcome could be contradicted later with no record. | A contradiction of a recorded fate, and a subscription naming an intent retired on a proven expiry, are disputes: an account review, filed once. |
+| An older binary's retire could delete the intent. | A delete barrier aborts it unless the session is proven expired; the roll rule (older consoles stopped first, no rollback) carries the rest. |
+
+Tests (`billingretirement_test.go`), each named for its interleaving: `TestRetireDoesNotReleaseAnIntentWhoseSessionIsStillPayable` (review eighteen's scenario: 503 while the expiry fails, held afterwards for the attached subscription, no URL offered, the listing's disposition); `TestRetireOnAProvenExpiryWithNothingAttached` (held while the session reads as another customer's; retired on proof; evidence and audit; exact replay with no provider read; a late subscription is one dispute however often delivered); `TestAPaymentBeforeTheRetireIsACompletionNotARetirement` (the payment recorded as the fate; retire refused; keep refused while unpaid; keep; an expiry proof after keep retires nothing; the purchase records); `TestThePassRetriesAFailedExpiryOnItsSchedule`; `TestAReviewFromBeforeTheProtocolIsHeld`; `TestTheDeleteBarrierHoldsARefusedIntent`; `TestEvidenceAgainstARecordedFateIsADispute`; `TestKeepIsHeldWhileAnotherSubscriptionIsAttached`. Eleven existing tests were reworked: those that retired a paid, contradicted or hand-made case now assert the hold; two that are about what follows a retirement (`TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution`, `TestARetiredSubscriptionSettlesBesideAReplacementIntent`) assert the hold and then continue from a retirement as a build from before the protocol left it (`retiredBeforeTheProtocol`), which a database can carry; three migration tests drop the barrier with the columns it names.
+
+Not built, stated: `settled`; the adjudication of a dispute; `keep` for a never-bound session; the read-only subscription judgement before `keep`; a per-pass cursor across more refusals than the budget (the per-intent schedule spreads them); the buyer's copy under review (the existing pending answer is shown).
diff --git a/internal/console/billingcurrency_test.go b/internal/console/billingcurrency_test.go
index f5a0845fa..bb1b954a9 100644
--- a/internal/console/billingcurrency_test.go
+++ b/internal/console/billingcurrency_test.go
@@ -2922,12 +2922,14 @@ func TestRefusedPaidSessionReviewThroughTheWebhookAndItsResolution(t *testing.T)
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(err)
}
- // The review is keyed by the intent, which is what the resolution
- // retires: exactly that intent, with its quantity events closed and a
- // record of what it was.
- 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.Fatalf("resolve: %v", err)
- }
+ // Under the retirement protocol a paid session is not retired: what
+ // settles its money is not decided, so the case is held (stage one).
+ heldRetire(t, fx, acct, intent.ID, "paul@keymail.dev", "completed", f.now.Add(time.Hour))
+ // The rest of this test is what follows a retirement made by a build
+ // from before the protocol, which a database can carry: the review is
+ // keyed by the intent, which is what that resolution retired, with
+ // its quantity events closed and a record of what it was.
+ retiredBeforeTheProtocol(t, fx, acct, intent.ID, "paul@keymail.dev", "refunded", f.now.Add(time.Hour))
if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil {
t.Fatal("the refused intent still stands after its review was resolved")
}
@@ -3281,13 +3283,15 @@ func TestUnboundRefusedSessionIsRecordedByItsId(t *testing.T) {
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)
- }
- var retired string
- if err := fx.st.db.QueryRow(`SELECT session_id FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, acct.ID, intent.ID).Scan(&retired); err != nil || retired != "cs_test_paid" {
- t.Fatalf("the retirement record's session: %q %v", retired, err)
+ // The never-bound intent is pinned to the session its review names,
+ // and that session's completion is its recorded fate: it is held (a
+ // paid session is not retired in stage one, and a never-bound one is
+ // never kept).
+ st, err := fx.st.refusalState(acct.ID, intent.ID)
+ if err != nil || st.pinned() != "cs_test_paid" || st.Fate != sessionFateComplete {
+ t.Fatalf("state %+v %v; want the review's session pinned and recorded complete", st, err)
}
+ heldRetire(t, fx, acct, intent.ID, "paul@keymail.dev", "completed", f.now.Add(time.Hour))
}
// The pass's ownership test protects the binder's own second read as
@@ -3493,25 +3497,15 @@ func TestABoundSessionReadBackAsNobodysIsReviewed(t *testing.T) {
if standing, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || standing.ID != intent.ID {
t.Fatalf("intent %+v %v; want it standing", standing, err)
}
- // The operator resolves the review: the intent is retired and a new
- // purchase opens.
+ // The operator cannot retire it: a session that reads back as another
+ // account's proves nothing about itself, so its fate is unproven and
+ // the intent is held; and the buyer's next click is offered nothing.
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(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(10*time.Minute)); err != nil {
- t.Fatalf("resolve: %v", err)
- }
- if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil {
- t.Fatal("the intent still stands after its review was resolved")
- }
- f.mu.Lock()
- f.sessionStatus, f.sessionID, f.subscriptionPrice = "open", "cs_test_second", ""
- f.mu.Unlock()
- if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(20*time.Minute)); err != nil || dest == "" {
- t.Fatalf("a new purchase after the resolution: dest %q err %v", dest, err)
- }
- if second, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || second.ID == intent.ID || second.SessionID != "cs_test_second" {
- t.Fatalf("the new intent: %+v %v", second, err)
+ heldRetire(t, fx, acct, intent.ID, "paul@keymail.dev", "not yet proven expired", f.now.Add(10*time.Minute))
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(20*time.Minute)); dest != "" || !errors.Is(err, errBillingUnderReview) {
+ t.Fatalf("a click under the review: dest %q err %v; want nothing offered", dest, err)
}
}
@@ -3804,13 +3798,13 @@ func TestAHandMadeSubscriptionExpiresTheOpenBoundSession(t *testing.T) {
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(err)
}
- if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intent.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "paul@keymail.dev", Note: "cancelled at the provider"}, f.now.Add(10*time.Minute)); err != nil {
- t.Fatalf("resolve: %v", err)
- }
- var retired string
- if err := fx.st.db.QueryRow(`SELECT session_id FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, acct.ID, intent.ID).Scan(&retired); err != nil || retired != intent.SessionID {
- t.Fatalf("the retirement record: %q %v; want the resolution to retire the intent it reviewed", retired, err)
+ // Its session is proven expired, and it is still held: the hand-made
+ // subscription is attached to it, and canceling a subscription at the
+ // provider is not settling it (stage one of the retirement protocol).
+ if st, err := fx.st.refusalState(acct.ID, intent.ID); err != nil || st.Fate != sessionFateExpired || len(st.Obligations) != 1 {
+ t.Fatalf("state %+v %v; want the expiry proven and the subscription attached", st, err)
}
+ heldRetire(t, fx, acct, intent.ID, "paul@keymail.dev", "a subscription is attached", f.now.Add(10*time.Minute))
}
// A reconciliation paused between its checks and its record (the provider
@@ -3855,8 +3849,12 @@ func TestAResolutionDuringAReconciliationLeavesNothingRecorded(t *testing.T) {
f.mu.Lock()
f.meta["band"], f.subscriptionPrice = intent.Band, intent.Price
f.customerHook = func() {
- 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.Errorf("resolve during the reconciliation: %v", err)
+ // Under the retirement protocol the retire is held (the session
+ // is complete and its subscription attached), so the review
+ // stays open, and that is what refuses the record.
+ var held *billingReviewHeld
+ 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)); !errors.As(err, &held) {
+ t.Errorf("resolve during the reconciliation: %v; want it held", err)
}
}
f.mu.Unlock()
@@ -3872,18 +3870,14 @@ func TestAResolutionDuringAReconciliationLeavesNothingRecorded(t *testing.T) {
_ = fx.st.db.QueryRow(`SELECT count(*) FROM billing_book_assignments WHERE account_id=?`, acct.ID).Scan(&assignments)
_ = fx.st.db.QueryRow(`SELECT count(*) FROM billing_events WHERE id='evt_race_recon'`).Scan(&queued)
if state.Subscription != "" || assignments != 0 || queued != 0 {
- t.Fatalf("state %+v, %d assignments, %d events recorded; want nothing recorded from a retired intent", state, assignments, queued)
+ t.Fatalf("state %+v, %d assignments, %d events recorded; want nothing recorded under the open review", state, assignments, queued)
}
- if _, err := fx.st.billingCheckoutIntent(acct.ID); err == nil {
- t.Fatal("the intent stands after its resolution")
- }
- var retired string
- if err := fx.st.db.QueryRow(`SELECT session_id FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, acct.ID, intent.ID).Scan(&retired); err != nil || retired != intent.SessionID {
- t.Fatalf("the retirement record: %q %v", retired, err)
+ if standing, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || standing.ID != intent.ID {
+ t.Fatalf("the intent: %+v %v; want it standing under its review", standing, err)
}
- // Every later event of that subscription is settled.
- if code := f.webhookPost(t, fx, "evt_race_after", "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent {
- t.Fatalf("after the resolution: %d; want 204", code)
+ var retired int
+ if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_retired_intents WHERE account_id=?`, acct.ID).Scan(&retired); err != nil || retired != 0 {
+ t.Fatalf("%d retirement records (%v); want none", retired, err)
}
}
@@ -4037,14 +4031,12 @@ func TestKeepIsRefusedForAnIntentThatCannotRecord(t *testing.T) {
if status, body := postJSON(t, fx2.ts.URL+"/api/billing/reviews/resolve", map[string]any{"account": acct2.Sqid, "subscription": intent2.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "x", "outcome": "keep"}, token2); status != http.StatusConflict {
t.Fatalf("keep on a session paid in the wrong currency: %d %v; want 409", status, body)
}
- // retire works, and the audit names the outcome.
+ // retire is held too: the hand-made subscription is attached to the
+ // purchase (the audit of a retire that goes through is checked in
+ // TestRetireOnAProvenExpiryWithNothingAttached).
status, body = postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "refunded"}, token)
- if status != http.StatusOK || body["retired"] != true {
- t.Fatalf("retire: %d %v", status, body)
- }
- var detail string
- if err := fx.st.db.QueryRow(`SELECT detail FROM audit WHERE action='billing-review-resolve' ORDER BY id DESC LIMIT 1`).Scan(&detail); err != nil || !strings.Contains(detail, "outcome=retire") {
- t.Fatalf("the audit detail: %q %v; want the outcome in it", detail, err)
+ if status != http.StatusConflict || !strings.Contains(fmt.Sprint(body["error"]), "held") {
+ t.Fatalf("retire: %d %v; want 409 held", status, body)
}
}
@@ -4068,9 +4060,10 @@ func TestARetiredSubscriptionSettlesBesideAReplacementIntent(t *testing.T) {
if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
t.Fatal(err)
}
- if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: first.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "paul@keymail.dev", Note: "refunded"}, f.now.Add(10*time.Minute)); err != nil {
- t.Fatalf("resolve: %v", err)
- }
+ // Held under the protocol (a paid session); retired as a build from
+ // before it would have left it, which is the state this test is about.
+ heldRetire(t, fx, acct, first.ID, "paul@keymail.dev", "completed", f.now.Add(10*time.Minute))
+ retiredBeforeTheProtocol(t, fx, acct, first.ID, "paul@keymail.dev", "refunded", f.now.Add(10*time.Minute))
// A replacement purchase opens and stands.
oldMeta := map[string]string{}
f.mu.Lock()
@@ -4213,6 +4206,10 @@ func TestAnOlderResolutionRowReadsAsRetire(t *testing.T) {
}
// The older build's table: the same, without the column.
for _, q := range []string{
+ // As the older build left it: no delete barrier (which names the
+ // column), no outcome, no evidence.
+ `DROP TRIGGER billing_refused_intent_delete_barrier`,
+ `ALTER TABLE billing_review_resolutions DROP COLUMN evidence`,
`ALTER TABLE billing_review_resolutions DROP COLUMN outcome`,
`INSERT INTO billing_reviews(account_id,subscription,reason,created_at,occurrences,last_seen_at,resolved_at) VALUES(7,'ci_old',?,100,1,100,200)`,
`INSERT INTO billing_review_resolutions(account_id,subscription,reason,occurrence,last_seen_at,actor,note,resolved_at) VALUES(7,'ci_old',?,1,100,'paul@keymail.dev','refunded',200)`,
@@ -4275,7 +4272,9 @@ func TestTheRejectedListingBreaksTiesNewestFirst(t *testing.T) {
// and a keep that another request decided while the gate was reading is
// answered by the store's replay comparison, not by the gate. (Review
// eighteen: Opus P3-1, the nil-billing branch had no test; Astra 2, two
-// identical requests could disagree.)
+// identical requests could disagree.) The case is a keepable one: the
+// intent's own purchase, paid through a URL the buyer kept while the
+// session read back as another customer's and its expiry failed.
func TestKeepWithBillingOffAndAKeepDecidedDuringTheGate(t *testing.T) {
fx, f, acct := newCurrencyFixture(t)
f.customerCountry = "GB"
@@ -4284,13 +4283,16 @@ func TestKeepWithBillingOffAndAKeepDecidedDuringTheGate(t *testing.T) {
}
intent, _ := fx.st.billingCheckoutIntent(acct.ID)
f.mu.Lock()
- f.subscriptionPrice = currencyPriceID(plans.PlanPersonal, plans.BandD, plans.GBP)
+ f.sessionCustomer, f.failExpire = "cus_other", 100
f.mu.Unlock()
- if code := f.webhookPost(t, fx, "evt_keep_off", "customer.subscription.created", "sub_paid"); code != http.StatusNoContent {
- t.Fatalf("the hand-made subscription event: %d", code)
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(time.Minute)); dest != "" || err == nil {
+ t.Fatalf("the contradicted read: dest %q err %v", dest, err)
}
+ f.mu.Lock()
+ f.sessionCustomer, f.sessionStatus, f.subscriptionPrice, f.failExpire = "", "complete", intent.Price, 0
+ f.mu.Unlock()
token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-keep-off")
- keep := map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "corrected at the provider", "outcome": "keep"}
+ keep := map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "paid through the original link", "outcome": "keep"}
open := func() int {
var n int
if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0`, acct.ID, intent.ID, billingCheckoutRefusedReason).Scan(&n); err != nil {
@@ -4306,20 +4308,25 @@ func TestKeepWithBillingOffAndAKeepDecidedDuringTheGate(t *testing.T) {
if status != http.StatusConflict || open() != 1 {
t.Fatalf("keep with billing off: %d %v, %d open; want 409 and the review still open", status, body, open())
}
- // The gate's own read finds the session expired (the refusal expired
- // it), which refuses keep; but during that read the same resolution is
- // committed by another request. The answer is the replay's 200.
+ // The door's first read proves the completion. The gate's own read
+ // (the second) is answered unpaid, which refuses keep; but during
+ // that read the same resolution is committed by another request. The
+ // answer is the replay's 200, not the gate's 409.
f.mu.Lock()
- f.sessionAnswers = []func(*fakeBillingStripe){func(*fakeBillingStripe) {
- if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intent.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "ops@keymail.dev", Note: "corrected at the provider", Outcome: "keep"}, f.now.Add(time.Minute)); err != nil {
+ f.sessionAnswers = []func(*fakeBillingStripe){func(*fakeBillingStripe) {}, func(f *fakeBillingStripe) {
+ if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intent.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "ops@keymail.dev", Note: "paid through the original link", Outcome: "keep"}, f.now.Add(2*time.Minute)); err != nil {
t.Errorf("the competing resolution: %v", err)
}
+ f.paymentStatus = "unpaid"
}}
f.mu.Unlock()
if status, body := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", keep, token); status != http.StatusOK {
t.Fatalf("keep decided during the gate: %d %v; want the replay's 200", status, body)
}
- // The same race with another note is the store's 409, not the gate's.
+ f.mu.Lock()
+ f.paymentStatus = ""
+ f.mu.Unlock()
+ // The same with another note is the store's 409, not the gate's.
other := map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "another note", "outcome": "keep"}
if status, body := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", other, token); status != http.StatusConflict || !strings.Contains(fmt.Sprint(body["error"]), "already resolved") {
t.Fatalf("a different note after the decision: %d %v; want the store's 409", status, body)
diff --git a/internal/console/billingholds_test.go b/internal/console/billingholds_test.go
index 8ff8c6fe1..fc864960a 100644
--- a/internal/console/billingholds_test.go
+++ b/internal/console/billingholds_test.go
@@ -27,7 +27,11 @@ func TestBillingReviewsResolvedAtMigrationIsIdempotent(t *testing.T) {
want.AccountID, "sub_pre", "pre_existing_reason", now.Unix()); err != nil {
t.Fatal(err)
}
- // Migration adds resolved_at back to a database that predates it.
+ // Migration adds resolved_at back to a database that predates it (and
+ // so predates the delete barrier, which names the column).
+ if _, err := s.db.Exec(`DROP TRIGGER billing_refused_intent_delete_barrier`); err != nil {
+ t.Fatal(err)
+ }
if _, err := s.db.Exec(`ALTER TABLE billing_reviews DROP COLUMN resolved_at`); err != nil {
t.Fatal(err)
}
diff --git a/internal/console/billingoperator.go b/internal/console/billingoperator.go
index 3c4ebc82c..3c25b1e05 100644
--- a/internal/console/billingoperator.go
+++ b/internal/console/billingoperator.go
@@ -182,6 +182,9 @@ type billingReviewRow struct {
LastSeenAt int64 `json:"last_seen_at"`
// Reference: the refused session's id for a currency refusal, else "".
Reference string `json:"reference,omitempty"`
+ // Disposition, for a checkout refusal: the session's recorded fate,
+ // what is attached, and why retire or keep is held (empty: available).
+ Disposition *refusalDisposition `json:"disposition,omitempty"`
}
func (s *Server) handleAPIBillingReviews(w http.ResponseWriter, r *http.Request) {
@@ -200,7 +203,13 @@ 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, Reference: x.Reference})
+ row := billingReviewRow{Account: sqid, Subscription: x.Subscription, Reason: x.Reason, Occurrences: x.Occurrences, CreatedAt: x.CreatedAt, LastSeenAt: x.LastSeenAt, Reference: x.Reference}
+ if x.Reason == billingCheckoutRefusedReason {
+ if st, err := s.store.refusalState(x.AccountID, x.Subscription); err == nil {
+ row.Disposition = st.disposition()
+ }
+ }
+ out = append(out, row)
}
writeJSON(w, http.StatusOK, map[string]any{"reviews": out})
}
@@ -248,6 +257,9 @@ func (s *Server) billingKeepRefused(ctx context.Context, acct *Account, intentID
if session.Status != "complete" || session.SubscriptionID == "" {
return "the session is " + session.Status + " with subscription " + strconv.Quote(session.SubscriptionID), nil
}
+ if session.PaymentStatus != "paid" && session.PaymentStatus != "no_payment_required" {
+ return "the session's payment is " + strconv.Quote(session.PaymentStatus), nil
+ }
return "", nil
}
@@ -321,51 +333,70 @@ func (s *Server) handleAPIBillingReviewResolve(w http.ResponseWriter, r *http.Re
jsonError(w, http.StatusNotFound, "no such billing review")
return
}
- if req.Outcome == "keep" && req.Reason == billingCheckoutRefusedReason {
- // Keeping an intent is for a purchase that can still record: the
- // intent's bound session, read now, must be its own, the purchase
- // the intent describes, complete and carrying a subscription. Any
- // other kept intent could never record and would later expire
- // with no retirement record, its subscription neither recorded
- // nor settled. The gate is asked only while the occurrence is
- // still open: an identical replay of a resolution already made is
- // the store's to answer (it repeats without writing), whatever
- // has happened to the intent since.
- already, err := s.store.billingReview(acct.ID, req.Subscription, req.Reason)
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- jsonError(w, http.StatusInternalServerError, "could not read the review. try again")
- return
- }
- if err == nil && already.ResolvedAt != 0 && already.Occurrences == req.Occurrences {
- // resolved already: the store decides whether this repeats it
- } else if s.billing == nil {
- jsonError(w, http.StatusConflict, "billing is not configured on this console, so the intent's session cannot be read; resolve with retire")
- return
- } else if why, gerr := s.billingKeepRefused(r.Context(), acct, req.Subscription); gerr != nil || why != "" {
- // The gate refused, or the provider failed. Another request may
- // have resolved this occurrence while the gate was reading (two
- // identical requests; another console): then the answer is the
- // store's exact replay comparison, not the gate's (review
- // eighteen, Astra 2).
- again, rerr := s.store.billingReview(acct.ID, req.Subscription, req.Reason)
- switch {
- case rerr == nil && again.ResolvedAt != 0 && again.Occurrences == req.Occurrences:
- // decided meanwhile: fall through to the store
- case gerr != nil:
- log.Printf("console: api billing review keep: %v", gerr)
- jsonError(w, http.StatusServiceUnavailable, "the intent's session could not be read. try again")
- return
- default:
- jsonError(w, http.StatusConflict, "keep needs a completed session with a subscription ("+why+"); resolve with retire")
- return
- }
- }
- }
outcome := req.Outcome
if outcome == "" {
outcome = "retire"
}
+ if req.Reason == billingCheckoutRefusedReason {
+ // A checkout refusal is resolved under the retirement protocol
+ // (billingretirement.go). The store's transaction decides, from
+ // the intent's recorded state, whether the outcome is available;
+ // this door first makes that state as current as the provider
+ // allows, under the account's billing lock.
+ //
+ // An occurrence already resolved is the store's to answer (its
+ // exact replay comparison), before any provider call or gate, and
+ // again after any of them refuses: another request, here or on
+ // another console, may have resolved it meanwhile.
+ decided := func() bool {
+ again, rerr := s.store.billingReview(acct.ID, req.Subscription, req.Reason)
+ return rerr == nil && again.ResolvedAt != 0 && again.Occurrences == req.Occurrences
+ }
+ if !decided() {
+ if s.billing != nil {
+ defer s.billing.locks.lock(acct.Sqid, "billing")()
+ intent, ierr := s.store.billingCheckoutIntent(acct.ID)
+ if ierr != nil && !errors.Is(ierr, sql.ErrNoRows) {
+ jsonError(w, http.StatusInternalServerError, "could not read the intent. try again")
+ return
+ }
+ if ierr == nil && intent.ID == req.Subscription {
+ // Work the session toward a proven fate now: for
+ // retire, its expiry is requested here if it is still
+ // open, and only what the provider then shows is
+ // recorded.
+ if aerr := s.advanceRefusal(r.Context(), intent, acct, time.Now()); aerr != nil && !decided() {
+ log.Printf("console: api billing review resolve: %s", billingDiagnostic(aerr))
+ jsonError(w, http.StatusServiceUnavailable, "the session's state at the provider could not be established. the review stays open. try again")
+ return
+ }
+ }
+ }
+ if outcome == "keep" && !decided() {
+ // Keeping an intent is for a purchase that can still
+ // record: the intent's bound session, read now, must be
+ // its own, the purchase the intent describes, complete,
+ // paid and carrying a subscription. (A subscription that
+ // is still not the purchase files the review again when
+ // it is reconciled: keep closes nothing for good.)
+ if s.billing == nil {
+ jsonError(w, http.StatusConflict, "billing is not configured on this console, so the intent's session cannot be read")
+ return
+ }
+ if why, gerr := s.billingKeepRefused(r.Context(), acct, req.Subscription); (gerr != nil || why != "") && !decided() {
+ if gerr != nil {
+ log.Printf("console: api billing review keep: %v", gerr)
+ jsonError(w, http.StatusServiceUnavailable, "the intent's session could not be read. try again")
+ return
+ }
+ jsonError(w, http.StatusConflict, "keep needs a completed, paid session with a subscription ("+why+"). the review stays open")
+ return
+ }
+ }
+ }
+ }
err = s.store.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: req.Subscription, Reason: req.Reason, Occurrence: req.Occurrences, Actor: addr, Note: strings.TrimSpace(req.Note), Outcome: outcome}, time.Now())
+ var held *billingReviewHeld
switch {
case err == nil:
detail := req.Reason
@@ -395,6 +426,8 @@ func (s *Server) handleAPIBillingReviewResolve(w http.ResponseWriter, r *http.Re
jsonError(w, http.StatusConflict, "the review recurred since it was read. read it again")
case errors.Is(err, errBillingReviewResolved):
jsonError(w, http.StatusConflict, "that occurrence was already resolved with a different note or outcome")
+ case errors.As(err, &held):
+ jsonError(w, http.StatusConflict, "held: "+held.Why+". the review stays open")
case errors.Is(err, errBillingReviewActor):
http.NotFound(w, r)
default:
diff --git a/internal/console/billingpass.go b/internal/console/billingpass.go
index 9f0273b8b..e80ee6823 100644
--- a/internal/console/billingpass.go
+++ b/internal/console/billingpass.go
@@ -132,10 +132,26 @@ func (s *Server) billingPass(ctx context.Context, now time.Time) error {
if err != nil {
failures = append(failures, err)
}
+ // An intent under a refusal review is not reconciled as a pending
+ // purchase: nothing binds, records or retires under the review. It is
+ // worked toward a proven fate last, within its own budget
+ // (billingPassRefusals).
+ var refused []billingIntent
for _, intent := range intents {
if ctx.Err() != nil {
return errors.Join(append(failures, ctx.Err())...)
}
+ // (One whose session is not known yet, an unbound intent under a
+ // review that names none, is still reconciled: that is how its
+ // session is found. Nothing records or retires under the review
+ // on that path either.)
+ if st, err := s.store.refusalState(intent.AccountID, intent.ID); err != nil {
+ failures = append(failures, err)
+ continue
+ } else if st.Open && st.pinned() != "" {
+ refused = append(refused, intent)
+ continue
+ }
if err := s.reconcilePendingIntent(ctx, intent, now); err != nil {
failures = append(failures, err)
}
@@ -169,6 +185,8 @@ func (s *Server) billingPass(ctx context.Context, now time.Time) error {
if err := s.store.setBillingPassCursor(last); err != nil {
failures = append(failures, err)
}
+ // 3. Refused intents, last and bounded.
+ failures = append(failures, s.billingPassRefusals(ctx, refused, now)...)
return errors.Join(failures...)
}
diff --git a/internal/console/billingretirement.go b/internal/console/billingretirement.go
new file mode 100644
index 000000000..2f5e0975b
--- /dev/null
+++ b/internal/console/billingretirement.go
@@ -0,0 +1,601 @@
+package console
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+)
+
+// The retirement of a refused checkout intent, stage one
+// (docs/superpowers/specs/2026-10-02-intent-retirement-protocol-design.md,
+// revision 4).
+//
+// A refusal is three recorded facts, in order: the review (filed first,
+// authoritative until a person resolves it), the fate of the session the
+// intent is pinned to (proven from a provider answer and written once), and
+// the operator's resolution (one transaction, which reads the fate and
+// refuses unless it allows the outcome). The expiry of a still-open session
+// is requested under one fixed key by whoever meets the refusal: the reader
+// that files it, the pass on a schedule, and the operator's retire.
+//
+// Stage one offers two outcomes, each for the plain case only:
+//
+// - retire: the session proven expired and unpaid, no subscription
+// attached to the case, nothing contradictory, a review this protocol
+// filed;
+// - keep: the session proven complete, no subscription attached but the
+// session's own, nothing contradictory, a review this protocol filed
+// (the door also requires the session paid and the intent's purchase).
+//
+// Everything else is held under its review: a paid session that is not
+// kept, a case a subscription made outside checkout is attached to, a
+// case the provider contradicted itself about, and every review opened
+// before this protocol. What disposes of those is not decided.
+
+const (
+ sessionFateExpired = "expired"
+ sessionFateComplete = "complete"
+ // billingFateDisputedReason is the account's review for provider
+ // evidence that contradicts what was recorded about a pinned session,
+ // or that arrives about an intent that no longer stands. Its
+ // subscription field holds the session's id.
+ billingFateDisputedReason = "billing_checkout_fate_disputed"
+ // billingPassRefusalBudget bounds the refused intents one pass works
+ // on, after everything else: slow expiries of old refusals cannot
+ // starve reconciliation.
+ billingPassRefusalBudget = 3
+)
+
+// errBillingUnderReview: the account's purchase is under a refusal review.
+// Nothing is offered or opened until a person disposes of it. To a caller
+// that knows only pending, it is pending.
+var errBillingUnderReview error = billingUnderReviewError{}
+
+type billingUnderReviewError struct{}
+
+func (billingUnderReviewError) Error() string { return "billing_checkout_under_review" }
+func (billingUnderReviewError) Is(target error) bool { return target == errBillingPending }
+
+// billingReviewHeld is a resolution refused because the case is held in
+// stage one. The review stays open.
+type billingReviewHeld struct{ Why string }
+
+func (e *billingReviewHeld) Error() string { return "billing_review_held: " + e.Why }
+
+func (s *Store) migrateBillingRetirement() error {
+ for _, c := range [][3]string{
+ {"billing_checkout_intents", "session_fate", `TEXT NOT NULL DEFAULT ''`},
+ {"billing_checkout_intents", "session_fate_at", `INTEGER NOT NULL DEFAULT 0`},
+ {"billing_checkout_intents", "session_payment_status", `TEXT NOT NULL DEFAULT ''`},
+ {"billing_checkout_intents", "session_subscription", `TEXT NOT NULL DEFAULT ''`},
+ {"billing_checkout_intents", "fate_disputed_at", `INTEGER NOT NULL DEFAULT 0`},
+ {"billing_checkout_intents", "expiry_next_try_at", `INTEGER NOT NULL DEFAULT 0`},
+ {"billing_checkout_intents", "expiry_tries", `INTEGER NOT NULL DEFAULT 0`},
+ // 0: a review from before this protocol (what was attached to it
+ // was never recorded, so it is held); 1: filed by it.
+ {"billing_reviews", "protocol", `INTEGER NOT NULL DEFAULT 0`},
+ // What a resolution of a checkout refusal rested on. Empty for a
+ // resolution from before this protocol, which is never
+ // reinterpreted.
+ {"billing_review_resolutions", "evidence", `TEXT NOT NULL DEFAULT ''`},
+ {"billing_retired_intents", "session_fate", `TEXT NOT NULL DEFAULT ''`},
+ } {
+ if err := s.addColumnIfMissing(c[0], c[1], c[2]); err != nil {
+ return err
+ }
+ }
+ for _, stmt := range []string{
+ // The subscriptions that caused or were attached to a refusal,
+ // beside the session's own.
+ `CREATE TABLE IF NOT EXISTS billing_review_obligations (
+ account_id INTEGER NOT NULL, intent_id TEXT NOT NULL, subscription TEXT NOT NULL, first_seen_at INTEGER NOT NULL,
+ PRIMARY KEY(account_id, intent_id, subscription)
+ )`,
+ // Contradictory or late evidence about a pinned session, kept by
+ // the session so it survives the intent. One row per distinct
+ // contradiction: the same event delivered twice changes nothing.
+ `CREATE TABLE IF NOT EXISTS billing_checkout_disputes (
+ account_id INTEGER NOT NULL, session_id TEXT NOT NULL, detail TEXT NOT NULL, intent_id TEXT NOT NULL,
+ first_seen_at INTEGER NOT NULL, last_seen_at INTEGER NOT NULL,
+ PRIMARY KEY(account_id, session_id, detail)
+ )`,
+ // The barrier: an intent that has a refusal review is deleted only
+ // when no session was ever created for it, when its session is
+ // proven expired and undisputed, or by the record of a purchase
+ // the operator kept. It stops an older binary's retire from
+ // deleting an intent whose session is not proven expired; it does
+ // not make an older binary follow the protocol (the roll rule
+ // does: older consoles are stopped before this build starts).
+ `CREATE TRIGGER IF NOT EXISTS billing_refused_intent_delete_barrier BEFORE DELETE ON billing_checkout_intents
+ WHEN EXISTS (SELECT 1 FROM billing_reviews r WHERE r.account_id=OLD.account_id AND r.subscription=OLD.id AND r.reason='` + billingCheckoutRefusedReason + `')
+ AND NOT (OLD.session_id='' AND OLD.customer_ready=0)
+ AND NOT (OLD.session_fate='` + sessionFateExpired + `' AND OLD.fate_disputed_at=0)
+ AND NOT (OLD.session_fate='` + sessionFateComplete + `' AND OLD.fate_disputed_at=0 AND EXISTS (
+ SELECT 1 FROM billing_reviews r JOIN billing_review_resolutions x
+ ON x.account_id=r.account_id AND x.subscription=r.subscription AND x.reason=r.reason AND x.occurrence=r.occurrences
+ WHERE r.account_id=OLD.account_id AND r.subscription=OLD.id AND r.reason='` + billingCheckoutRefusedReason + `'
+ AND r.resolved_at<>0 AND x.outcome='keep'))
+ BEGIN SELECT RAISE(ABORT, 'billing_refused_intent_delete_barrier'); END`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// refusalState is what the store holds about a refused intent.
+type refusalState struct {
+ Stands, Exists, Open bool // the intent row; a refusal review row; that review open
+ Protocol int64
+ Reference string // the review's session
+ SessionID string // the intent's bound session
+ CustomerReady bool
+ Fate string
+ FateAt, Disputed int64
+ PaymentStatus string
+ SessionSubscription string
+ NextTry, Tries int64
+ Obligations []string
+}
+
+// pinned is the session the refusal is about: the one the intent is bound
+// to, or, for an intent never bound, the one the review names.
+func (st refusalState) pinned() string {
+ if st.SessionID != "" {
+ return st.SessionID
+ }
+ return st.Reference
+}
+
+// held says why an outcome is not available for the refused intent in
+// stage one, or "" when it is.
+func (st refusalState) held(outcome string) string {
+ if !st.Stands {
+ if outcome == "keep" {
+ return "no such intent stands"
+ }
+ return ""
+ }
+ if st.Protocol == 0 {
+ return "the review was opened before the retirement protocol, so what was attached to the purchase was never recorded"
+ }
+ if st.Disputed != 0 {
+ return "the provider's evidence about the session is contradictory (see the " + billingFateDisputedReason + " review)"
+ }
+ switch outcome {
+ case "retire":
+ if st.SessionID == "" && !st.CustomerReady && st.Reference == "" {
+ return ""
+ }
+ if len(st.Obligations) > 0 {
+ return "a subscription is attached to the purchase (" + strings.Join(st.Obligations, ", ") + "), and canceling a subscription is not settling it"
+ }
+ switch st.Fate {
+ case sessionFateExpired:
+ return ""
+ case sessionFateComplete:
+ return "its session was completed (payment " + st.PaymentStatus + "); retire is for an expired, unpaid session"
+ }
+ return "its session is not yet proven expired"
+ case "keep":
+ if st.Fate != sessionFateComplete {
+ return "its session is not proven complete"
+ }
+ for _, o := range st.Obligations {
+ if o != st.SessionSubscription {
+ return "a subscription other than the session's own is attached to the purchase (" + o + ")"
+ }
+ }
+ return ""
+ }
+ return "the outcome is not one stage one offers"
+}
+
+// evidence is what a resolution of the refusal rests on, for its record.
+func (st refusalState) evidence() string {
+ if !st.Stands {
+ return "no intent stood"
+ }
+ return "session=" + st.pinned() + " fate=" + st.Fate + " payment=" + st.PaymentStatus + " subscription=" + st.SessionSubscription
+}
+
+type billingQueryer interface {
+ QueryRow(string, ...any) *sql.Row
+ Query(string, ...any) (*sql.Rows, error)
+}
+
+func refusalStateQ(q billingQueryer, accountID int64, intentID string) (refusalState, error) {
+ var st refusalState
+ err := q.QueryRow(`SELECT session_id,customer_ready,session_fate,session_fate_at,fate_disputed_at,session_payment_status,session_subscription,expiry_next_try_at,expiry_tries
+ FROM billing_checkout_intents WHERE account_id=? AND id=?`, accountID, intentID).
+ Scan(&st.SessionID, &st.CustomerReady, &st.Fate, &st.FateAt, &st.Disputed, &st.PaymentStatus, &st.SessionSubscription, &st.NextTry, &st.Tries)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ case err != nil:
+ return st, err
+ default:
+ st.Stands = true
+ }
+ var resolvedAt int64
+ err = q.QueryRow(`SELECT protocol,reference,resolved_at FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=?`, accountID, intentID, billingCheckoutRefusedReason).
+ Scan(&st.Protocol, &st.Reference, &resolvedAt)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ case err != nil:
+ return st, err
+ default:
+ st.Exists, st.Open = true, resolvedAt == 0
+ }
+ rows, err := q.Query(`SELECT subscription FROM billing_review_obligations WHERE account_id=? AND intent_id=? ORDER BY subscription`, accountID, intentID)
+ if err != nil {
+ return st, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var sub string
+ if err := rows.Scan(&sub); err != nil {
+ return st, err
+ }
+ st.Obligations = append(st.Obligations, sub)
+ }
+ return st, rows.Err()
+}
+
+func (s *Store) refusalState(accountID int64, intentID string) (refusalState, error) {
+ return refusalStateQ(s.db, accountID, intentID)
+}
+
+// addRefusalObligation records a subscription attached to a refused
+// intent, while that intent's refusal review exists.
+func (s *Store) addRefusalObligation(accountID int64, intentID, subscription string, now time.Time) error {
+ if intentID == "" || subscription == "" {
+ return nil
+ }
+ _, err := s.db.Exec(`INSERT OR IGNORE INTO billing_review_obligations(account_id,intent_id,subscription,first_seen_at)
+ SELECT ?,?,?,? WHERE EXISTS (SELECT 1 FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=?)`,
+ accountID, intentID, subscription, now.Unix(), accountID, intentID, billingCheckoutRefusedReason)
+ return err
+}
+
+// disputeSessionTx records one contradiction about a pinned session. The
+// first time it is seen it files the account's dispute review and marks
+// the intent, if it still stands; seen again, it changes nothing but the
+// last-seen instant.
+func disputeSessionTx(tx *sql.Tx, accountID int64, sessionID, intentID, detail string, now time.Time) error {
+ if sessionID == "" || detail == "" {
+ return nil
+ }
+ res, err := tx.Exec(`INSERT OR IGNORE INTO billing_checkout_disputes(account_id,session_id,detail,intent_id,first_seen_at,last_seen_at) VALUES(?,?,?,?,?,?)`,
+ accountID, sessionID, detail, intentID, now.Unix(), now.Unix())
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ _, err := tx.Exec(`UPDATE billing_checkout_disputes SET last_seen_at=MAX(last_seen_at,?) WHERE account_id=? AND session_id=? AND detail=?`, now.Unix(), accountID, sessionID, detail)
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE billing_checkout_intents SET fate_disputed_at=? WHERE account_id=? AND id=? AND fate_disputed_at=0`, max(now.Unix(), 1), accountID, intentID); err != nil {
+ return err
+ }
+ return fileBillingReviewTx(tx, accountID, sessionID, billingFateDisputedReason, now)
+}
+
+// disputeSession is disputeSessionTx in its own transaction.
+func (s *Store) disputeSession(accountID int64, sessionID, intentID, detail string, now time.Time) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, detail, now); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// noteSessionProof records what a provider answer proves about the session
+// a refused intent is pinned to. A fate is written once and never changed:
+// a later proof of the other fate, or of another subscription on a
+// complete session, is a dispute. Proof about an intent that no longer
+// stands is compared with what its retirement or its keep rested on, and a
+// contradiction of that is a dispute too. Nothing is recorded for an
+// intent with no refusal review, or for a session that is not the pinned
+// one.
+func (s *Store) noteSessionProof(accountID int64, intentID, sessionID, fate, payment, subscription string, now time.Time) error {
+ if intentID == "" || sessionID == "" || (fate != sessionFateExpired && fate != sessionFateComplete) {
+ return nil
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ st, err := refusalStateQ(tx, accountID, intentID)
+ if err != nil {
+ return err
+ }
+ if !st.Stands {
+ // Retired: the archived fate. Consumed after a keep: the kept
+ // resolution's evidence names the session as complete.
+ var archivedSession, archivedFate string
+ err := tx.QueryRow(`SELECT session_id,session_fate FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, accountID, intentID).Scan(&archivedSession, &archivedFate)
+ switch {
+ case err == nil:
+ if archivedSession == sessionID && archivedFate == sessionFateExpired && fate == sessionFateComplete {
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, "read back complete after its intent was retired on a proven expiry", now); err != nil {
+ return err
+ }
+ }
+ case errors.Is(err, sql.ErrNoRows):
+ if st.Exists && st.Reference == sessionID && fate == sessionFateExpired {
+ var kept int
+ if err := tx.QueryRow(`SELECT count(*) FROM billing_review_resolutions WHERE account_id=? AND subscription=? AND reason=? AND outcome='keep'`, accountID, intentID, billingCheckoutRefusedReason).Scan(&kept); err != nil {
+ return err
+ }
+ if kept > 0 {
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, "read back expired after its purchase was kept as complete", now); err != nil {
+ return err
+ }
+ }
+ }
+ default:
+ return err
+ }
+ return tx.Commit()
+ }
+ if !st.Exists || st.pinned() != sessionID {
+ return nil
+ }
+ switch {
+ case st.Fate == "":
+ if _, err := tx.Exec(`UPDATE billing_checkout_intents SET session_fate=?,session_fate_at=?,session_payment_status=?,session_subscription=? WHERE account_id=? AND id=? AND session_fate=''`,
+ fate, max(now.Unix(), 1), payment, subscription, accountID, intentID); err != nil {
+ return err
+ }
+ case st.Fate != fate:
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, "read back "+fate+" after it was recorded "+st.Fate, now); err != nil {
+ return err
+ }
+ case fate == sessionFateComplete && st.SessionSubscription != "" && subscription != "" && subscription != st.SessionSubscription:
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, "its subscription read back as "+subscription+" after it was recorded as "+st.SessionSubscription, now); err != nil {
+ return err
+ }
+ case fate == sessionFateComplete:
+ // The terminal status stands; what was paid and for which
+ // subscription may become known later.
+ if _, err := tx.Exec(`UPDATE billing_checkout_intents SET session_payment_status=?,session_subscription=CASE WHEN session_subscription='' THEN ? ELSE session_subscription END WHERE account_id=? AND id=?`,
+ payment, subscription, accountID, intentID); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// noteExpiryTry schedules the next attempt to prove a refused intent's
+// fate: 1, 5, 15 minutes, then hourly.
+func (s *Store) noteExpiryTry(accountID int64, intentID string, now time.Time) error {
+ _, err := s.db.Exec(`UPDATE billing_checkout_intents SET expiry_tries=expiry_tries+1,
+ expiry_next_try_at=?+CASE expiry_tries WHEN 0 THEN 60 WHEN 1 THEN 300 WHEN 2 THEN 900 ELSE 3600 END
+ WHERE account_id=? AND id=?`, now.Unix(), accountID, intentID)
+ return err
+}
+
+// noteLateSubscription records a subscription that names an intent which
+// no longer stands and was not known as attached to it when it was
+// disposed of: a person must see it, and it must not vanish as a settled
+// event because the intent is gone.
+func (s *Store) noteLateSubscription(accountID int64, intentID, subscription string, now time.Time) error {
+ if intentID == "" || subscription == "" {
+ return nil
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ var known int
+ if err := tx.QueryRow(`SELECT count(*) FROM billing_review_obligations WHERE account_id=? AND intent_id=? AND subscription=?`, accountID, intentID, subscription).Scan(&known); err != nil {
+ return err
+ }
+ if known > 0 {
+ return nil
+ }
+ // Only for an intent retired on a proven expiry, which had no
+ // subscription at all. A retirement from before this protocol (no
+ // fate recorded) was of a paid session, whose own subscription's
+ // later events are expected and settle as they did.
+ var sessionID, fate string
+ err = tx.QueryRow(`SELECT session_id,session_fate FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, accountID, intentID).Scan(&sessionID, &fate)
+ if errors.Is(err, sql.ErrNoRows) || (err == nil && fate != sessionFateExpired) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if sessionID == "" {
+ sessionID = intentID
+ }
+ if err := disputeSessionTx(tx, accountID, sessionID, intentID, "subscription "+subscription+" names the retired intent and was not attached to it when it was retired", now); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// refusalReviewOpen reports whether the intent is under an open refusal
+// review.
+func (s *Store) refusalReviewOpen(accountID int64, intentID string) (bool, error) {
+ var open bool
+ err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0)`, accountID, intentID, billingCheckoutRefusedReason).Scan(&open)
+ return open, err
+}
+
+// sessionProof judges a provider answer as evidence about the session an
+// intent is pinned to. identity is false when the answer does not name
+// that session, this mode, the intent, the account in both places and the
+// intent's customer: such an answer proves no fate, whatever its status.
+// With identity, fate is expired (and unpaid, with no subscription),
+// complete, or "" for a session still open.
+func (b *billingRuntime) sessionProof(session *billingstripe.Session, intent billingIntent, pinned string, acct *Account) (fate string, identity bool) {
+ if session == nil || pinned == "" || session.ID != pinned || session.LiveMode != b.cfg.LiveMode || session.Metadata["checkout_intent"] != intent.ID ||
+ session.ClientReferenceID != acct.Sqid || session.Metadata["account_sqid"] != acct.Sqid || (intent.Customer != "" && session.CustomerID != intent.Customer) {
+ return "", false
+ }
+ switch {
+ case session.Status == "expired" && session.SubscriptionID == "" && session.PaymentStatus != "paid":
+ return sessionFateExpired, true
+ case session.Status == "complete":
+ return sessionFateComplete, true
+ }
+ return "", true
+}
+
+// noteSessionEvidence records what a session just read proves about a
+// refused intent, and disputes a recorded fate the read contradicts in
+// identity. It writes nothing for an intent with no refusal review.
+func (s *Server) noteSessionEvidence(intent billingIntent, acct *Account, session *billingstripe.Session, now time.Time) error {
+ if session == nil {
+ return nil
+ }
+ st, err := s.store.refusalState(acct.ID, intent.ID)
+ if err != nil || !st.Exists {
+ return err
+ }
+ pinned := st.pinned()
+ if pinned == "" || session.ID != pinned {
+ return nil
+ }
+ fate, identity := s.billing.sessionProof(session, intent, pinned, acct)
+ if !identity {
+ if st.Fate != "" {
+ return s.store.disputeSession(acct.ID, pinned, intent.ID, "read back as another account's, customer's or intent's after it was recorded "+st.Fate, now)
+ }
+ return nil
+ }
+ if fate == "" {
+ if st.Fate != "" {
+ return s.store.disputeSession(acct.ID, pinned, intent.ID, "read back open after it was recorded "+st.Fate, now)
+ }
+ return nil
+ }
+ return s.store.noteSessionProof(acct.ID, intent.ID, pinned, fate, session.PaymentStatus, session.SubscriptionID, now)
+}
+
+// advanceRefusal works a refused intent toward a recorded fate: it reads
+// the pinned session, records what the read proves, and, for a bound
+// session still open, requests its expiry under the intent's one key and
+// reads again. An expiry answer alone proves nothing (a replay under the
+// key carries no provenance): the fate comes from the read after it. It
+// works whenever it is called: the reader that files the review and the
+// operator's door call it at once, and the pass calls it on the intent's
+// schedule (billingPassRefusals, the one place the schedule is read).
+//
+// It returns nil when nothing more can be done now, including when the
+// fate stays unproven; the provider's errors are returned, with the next
+// try scheduled.
+func (s *Server) advanceRefusal(ctx context.Context, intent billingIntent, acct *Account, now time.Time) error {
+ b := s.billing
+ st, err := s.store.refusalState(acct.ID, intent.ID)
+ if err != nil || !st.Stands || !st.Exists {
+ return err
+ }
+ pinned := st.pinned()
+ if pinned == "" || st.Fate != "" || st.Disputed != 0 {
+ return nil
+ }
+ fail := func(err error) error {
+ if terr := s.store.noteExpiryTry(acct.ID, intent.ID, now); terr != nil {
+ return errors.Join(err, terr)
+ }
+ return err
+ }
+ session, err := b.client.GetCheckout(ctx, pinned)
+ if err != nil {
+ return fail(err)
+ }
+ if session.ID != pinned {
+ // Answered with another session: the read failed.
+ return fail(errBillingPending)
+ }
+ if fate, _ := b.sessionProof(session, intent, pinned, acct); fate != "" {
+ return s.store.noteSessionProof(acct.ID, intent.ID, pinned, fate, session.PaymentStatus, session.SubscriptionID, now)
+ }
+ // Open (or anything else not terminal). The expiry is the console's to
+ // request for a session it bound, by that id and under the intent's
+ // one key; a session it never bound is not expired here.
+ if session.Status == "open" && intent.SessionID != "" {
+ _, expireErr := b.client.ExpireCheckout(ctx, pinned, "carloku-expire-"+intent.ID)
+ again, err := b.client.GetCheckout(ctx, pinned)
+ if err != nil {
+ return fail(errors.Join(expireErr, err))
+ }
+ if again.ID != pinned {
+ return fail(errors.Join(expireErr, errBillingPending))
+ }
+ if fate, _ := b.sessionProof(again, intent, pinned, acct); fate != "" {
+ return s.store.noteSessionProof(acct.ID, intent.ID, pinned, fate, again.PaymentStatus, again.SubscriptionID, now)
+ }
+ if expireErr != nil {
+ return fail(expireErr)
+ }
+ }
+ return s.store.noteExpiryTry(acct.ID, intent.ID, now)
+}
+
+// billingPassRefusals works a bounded number of refused intents, in the
+// order they stand, after the pass's other work.
+func (s *Server) billingPassRefusals(ctx context.Context, refused []billingIntent, now time.Time) []error {
+ var failures []error
+ worked := 0
+ for _, intent := range refused {
+ if ctx.Err() != nil || worked >= billingPassRefusalBudget {
+ break
+ }
+ st, err := s.store.refusalState(intent.AccountID, intent.ID)
+ if err != nil {
+ failures = append(failures, err)
+ continue
+ }
+ if !st.Stands || !st.Exists || st.Fate != "" || st.Disputed != 0 || st.pinned() == "" || now.Unix() < st.NextTry {
+ continue
+ }
+ acct, err := s.store.AccountByID(intent.AccountID)
+ if err != nil || acct == nil {
+ if err != nil {
+ failures = append(failures, err)
+ }
+ continue
+ }
+ worked++
+ if err := s.advanceRefusal(ctx, intent, acct, now); err != nil {
+ failures = append(failures, err)
+ }
+ }
+ return failures
+}
+
+// refusalDisposition is what the operator sees of a refused intent: its
+// recorded fate, what is attached, and why each outcome is or is not
+// available.
+type refusalDisposition struct {
+ Fate string `json:"fate,omitempty"`
+ Disputed bool `json:"disputed,omitempty"`
+ Obligations []string `json:"obligations,omitempty"`
+ Retire string `json:"retire_held,omitempty"`
+ Keep string `json:"keep_held,omitempty"`
+}
+
+func (st refusalState) disposition() *refusalDisposition {
+ obligations := append([]string(nil), st.Obligations...)
+ sort.Strings(obligations)
+ return &refusalDisposition{Fate: st.Fate, Disputed: st.Disputed != 0, Obligations: obligations, Retire: st.held("retire"), Keep: st.held("keep")}
+}
diff --git a/internal/console/billingretirement_test.go b/internal/console/billingretirement_test.go
new file mode 100644
index 000000000..4b5c8088a
--- /dev/null
+++ b/internal/console/billingretirement_test.go
@@ -0,0 +1,544 @@
+package console
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/billingstripe"
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// Fixtures for the retirement protocol's tests (billingretirement.go).
+
+// refusedByAnotherCustomer opens a GBP purchase and has the bound session
+// read back as another customer's on the buyer's next click: the refusal
+// review is filed, and the console requests the session's expiry.
+func refusedByAnotherCustomer(t *testing.T) (*consoleFixture, *fakeBillingStripe, *Account, billingIntent) {
+ t.Helper()
+ 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, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || intent.SessionID == "" {
+ t.Fatalf("fixture: intent %+v %v", intent, err)
+ }
+ f.mu.Lock()
+ f.sessionCustomer = "cus_other"
+ f.mu.Unlock()
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(time.Minute)); dest != "" || !errors.Is(err, errBillingIdentity) {
+ t.Fatalf("the bound session read back as another customer's: dest %q err %v", dest, err)
+ }
+ return fx, f, acct, intent
+}
+
+// refusedByAHandMadeSubscription opens a GBP purchase and delivers a
+// subscription made outside checkout that names the intent at another
+// band's price, the buyer's session still open.
+func refusedByAHandMadeSubscription(t *testing.T, failExpire int) (*consoleFixture, *fakeBillingStripe, *Account, billingIntent, int) {
+ t.Helper()
+ 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, err := fx.st.billingCheckoutIntent(acct.ID)
+ if err != nil || intent.SessionID == "" {
+ t.Fatalf("fixture: intent %+v %v", intent, err)
+ }
+ f.mu.Lock()
+ f.subscriptionPrice = currencyPriceID(plans.PlanPersonal, plans.BandD, plans.GBP)
+ f.failExpire = failExpire
+ f.mu.Unlock()
+ code := f.webhookPost(t, fx, "evt_hand_made_"+intent.ID, "customer.subscription.created", "sub_paid")
+ return fx, f, acct, intent, code
+}
+
+// retiredBeforeTheProtocol leaves the account as a build from before the
+// retirement protocol left it after its operator retired a refused
+// purchase: the review resolved, a resolution row with the outcome retire
+// and no evidence, a retirement record with no fate, the intent's pending
+// checkout quantity events closed, the intent gone. No build of the
+// protocol makes this state (it holds a paid session); a database upgraded
+// from an earlier build can carry it, and what follows such a retirement
+// must keep working. The delete barrier is lifted for the one statement,
+// as it did not exist then, and put back by the migration.
+func retiredBeforeTheProtocol(t *testing.T, fx *consoleFixture, acct *Account, intentID, actor, note string, at time.Time) {
+ t.Helper()
+ if _, err := fx.st.db.Exec(`DROP TRIGGER billing_refused_intent_delete_barrier`); err != nil {
+ t.Fatal(err)
+ }
+ var sessionID, plan, band, currency, book, reference string
+ var occurrences, lastSeen int64
+ if err := fx.st.db.QueryRow(`SELECT session_id,plan,band,currency,price_book FROM billing_checkout_intents WHERE account_id=? AND id=?`, acct.ID, intentID).Scan(&sessionID, &plan, &band, &currency, &book); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.db.QueryRow(`SELECT occurrences,last_seen_at,reference FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=?`, acct.ID, intentID, billingCheckoutRefusedReason).Scan(&occurrences, &lastSeen, &reference); err != nil {
+ t.Fatal(err)
+ }
+ if sessionID == "" {
+ sessionID = reference
+ }
+ stamp := max(at.Unix(), lastSeen)
+ for _, q := range []struct {
+ sql string
+ args []any
+ }{
+ {`UPDATE billing_reviews SET resolved_at=? WHERE account_id=? AND subscription=? AND reason=?`, []any{stamp, acct.ID, intentID, billingCheckoutRefusedReason}},
+ {`INSERT INTO billing_review_resolutions(account_id,subscription,reason,occurrence,last_seen_at,actor,note,resolved_at,outcome) VALUES(?,?,?,?,?,?,?,?,'retire')`, []any{acct.ID, intentID, billingCheckoutRefusedReason, occurrences, lastSeen, actor, note, stamp}},
+ {`UPDATE billing_quantity_events SET processed_at=?,processing_outcome='checkout_refused_resolved',processing_reference=? WHERE account_id=? AND intent_id=? AND subscription='' AND processed_at=0`, []any{stamp, sessionID, acct.ID, intentID}},
+ {`INSERT INTO billing_retired_intents(account_id,intent_id,session_id,plan,band,currency,price_book,reason,actor,retired_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, []any{acct.ID, intentID, sessionID, plan, band, currency, book, billingCheckoutRefusedReason, actor, stamp}},
+ {`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=?`, []any{acct.ID, intentID}},
+ } {
+ if _, err := fx.st.db.Exec(q.sql, q.args...); err != nil {
+ t.Fatalf("%s: %v", q.sql, err)
+ }
+ }
+ if err := fx.st.migrateBillingRetirement(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// heldRetire asserts the store holds a retire of the refusal, for a reason
+// containing want, and that the intent still stands.
+func heldRetire(t *testing.T, fx *consoleFixture, acct *Account, intentID, actor, want string, at time.Time) {
+ t.Helper()
+ var held *billingReviewHeld
+ err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intentID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: actor, Note: "refunded"}, at)
+ if !errors.As(err, &held) || !strings.Contains(held.Why, want) {
+ t.Fatalf("retire: %v; want it held (%s)", err, want)
+ }
+ if standing, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || standing.ID != intentID {
+ t.Fatalf("the intent after the held retire: %+v %v; want it standing", standing, err)
+ }
+ if open, err := fx.st.refusalReviewOpen(acct.ID, intentID); err != nil || !open {
+ t.Fatalf("the review after the held retire: open %v %v", open, err)
+ }
+}
+
+func resolveRefusal(t *testing.T, fx *consoleFixture, token string, acct *Account, intent billingIntent, outcome, note string) (int, string) {
+ t.Helper()
+ body := map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": note}
+ if outcome != "" {
+ body["outcome"] = outcome
+ }
+ status, answer := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", body, token)
+ msg, _ := answer["error"].(string)
+ return status, msg
+}
+
+func refusalOpen(t *testing.T, fx *consoleFixture, acct *Account, intent billingIntent) bool {
+ t.Helper()
+ open, err := fx.st.refusalReviewOpen(acct.ID, intent.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return open
+}
+
+// Review eighteen's defect, both reviewers: the review is filed, the
+// expiry of the bound session fails, and the operator retires. The intent
+// must not go while its session is still payable. Under the protocol the
+// retire itself requests the expiry; while that fails it answers 503 and
+// nothing is resolved; and when the expiry has gone through, this case is
+// still held, because a subscription made outside checkout is attached to
+// it and expiring a checkout settles nothing about that subscription.
+func TestRetireDoesNotReleaseAnIntentWhoseSessionIsStillPayable(t *testing.T) {
+ fx, f, acct, intent, code := refusedByAHandMadeSubscription(t, 100)
+ if code != http.StatusServiceUnavailable {
+ t.Fatalf("the event whose refusal could not expire the session: %d; want 503 (the provider tries again)", code)
+ }
+ if !refusalOpen(t, fx, acct, intent) || f.sessionStatus != "open" {
+ t.Fatalf("fixture: review open %v, session %s; want the review filed and the session still open", refusalOpen(t, fx, acct, intent), f.sessionStatus)
+ }
+ st, err := fx.st.refusalState(acct.ID, intent.ID)
+ if err != nil || st.Fate != "" || len(st.Obligations) != 1 || st.Obligations[0] != "sub_paid" || st.Protocol != 1 {
+ t.Fatalf("state %+v %v; want no fate, the subscription recorded as attached, protocol 1", st, err)
+ }
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-payable")
+ // The session is payable and its expiry still fails: 503, nothing
+ // resolved, the intent standing.
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "", "refunded"); status != http.StatusServiceUnavailable {
+ t.Fatalf("retire while the expiry fails: %d %q; want 503", status, msg)
+ }
+ if standing, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || standing.ID != intent.ID || !refusalOpen(t, fx, acct, intent) || f.sessionStatus != "open" {
+ t.Fatalf("after the refused retire: intent %+v %v, session %s; want the intent standing under its open review", standing, err, f.sessionStatus)
+ }
+ // The buyer's next click is offered nothing.
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(2*time.Minute)); dest != "" || !errors.Is(err, errBillingUnderReview) || !errors.Is(err, errBillingPending) {
+ t.Fatalf("a click under the review: dest %q err %v; want no URL and the under-review answer", dest, err)
+ }
+ // The expiry goes through at the next retire. The session is no longer
+ // payable; the case is held for its attached subscription.
+ f.mu.Lock()
+ f.failExpire = 0
+ f.mu.Unlock()
+ status, msg := resolveRefusal(t, fx, token, acct, intent, "", "refunded")
+ if status != http.StatusConflict || !strings.Contains(msg, "held") || !strings.Contains(msg, "sub_paid") {
+ t.Fatalf("retire with a subscription attached: %d %q; want 409 held, naming it", status, msg)
+ }
+ st, _ = fx.st.refusalState(acct.ID, intent.ID)
+ if f.sessionStatus != "expired" || st.Fate != sessionFateExpired || !st.Stands || !st.Open {
+ t.Fatalf("session %s, state %+v; want the session expired, its fate recorded, the intent standing under its open review", f.sessionStatus, st)
+ }
+ // The operator sees why.
+ _, listing := getJSON(t, fx.ts.URL+"/api/billing/reviews", token)
+ rows, _ := listing["reviews"].([]any)
+ var seen bool
+ for _, r := range rows {
+ row, _ := r.(map[string]any)
+ if row["subscription"] != intent.ID {
+ continue
+ }
+ d, _ := row["disposition"].(map[string]any)
+ if d["fate"] != sessionFateExpired || !strings.Contains(d["retire_held"].(string), "sub_paid") {
+ t.Fatalf("the listing's disposition: %v", d)
+ }
+ seen = true
+ }
+ if !seen {
+ t.Fatalf("the refusal is not in the reviews listing: %v", listing)
+ }
+}
+
+// The one case stage one retires: a refusal with nothing attached whose
+// session is proven expired and unpaid. A session read back as another
+// customer's proves nothing while it reads so (held); once the provider's
+// record is the intent's own again, the read proves the expiry, and the
+// retire goes through, recorded with what it rested on.
+func TestRetireOnAProvenExpiryWithNothingAttached(t *testing.T) {
+ fx, f, acct, intent := refusedByAnotherCustomer(t)
+ if f.expireCalls != 1 || f.sessionStatus != "expired" {
+ t.Fatalf("fixture: %d expiries, session %s", f.expireCalls, f.sessionStatus)
+ }
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-proven")
+ // Expired at the provider, but every read of it still names another
+ // customer: no proof, so no retirement.
+ status, msg := resolveRefusal(t, fx, token, acct, intent, "", "expired, never paid")
+ if status != http.StatusConflict || !strings.Contains(msg, "not yet proven expired") {
+ t.Fatalf("retire while the session reads as another customer's: %d %q; want 409 held as unproven", status, msg)
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); err != nil {
+ t.Fatalf("the intent after the held retire: %v", err)
+ }
+ // The provider's record is corrected.
+ f.mu.Lock()
+ f.sessionCustomer = ""
+ f.mu.Unlock()
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "", "expired, never paid"); status != http.StatusOK {
+ t.Fatalf("retire on a proven expiry: %d %q", status, msg)
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("the intent after the retire: %v; want it gone", err)
+ }
+ var fate, evidence string
+ if err := fx.st.db.QueryRow(`SELECT session_fate FROM billing_retired_intents WHERE account_id=? AND intent_id=?`, acct.ID, intent.ID).Scan(&fate); err != nil || fate != sessionFateExpired {
+ t.Fatalf("the retirement record's fate: %q %v", fate, err)
+ }
+ if err := fx.st.db.QueryRow(`SELECT evidence FROM billing_review_resolutions WHERE account_id=? AND subscription=? AND reason=?`, acct.ID, intent.ID, billingCheckoutRefusedReason).Scan(&evidence); err != nil ||
+ !strings.Contains(evidence, "fate=expired") || !strings.Contains(evidence, "session="+intent.SessionID) {
+ t.Fatalf("the resolution's evidence: %q %v", evidence, err)
+ }
+ var detail string
+ if err := fx.st.db.QueryRow(`SELECT detail FROM audit WHERE action='billing-review-resolve' ORDER BY id DESC LIMIT 1`).Scan(&detail); err != nil || !strings.Contains(detail, "outcome=retire") {
+ t.Fatalf("the audit detail: %q %v; want the outcome in it", detail, err)
+ }
+ // The exact replay answers 200 with no provider read; another note 409.
+ requests := f.requests
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "", "expired, never paid"); status != http.StatusOK || f.requests != requests {
+ t.Fatalf("the exact replay: %d %q, %d provider requests; want 200 and none", status, msg, f.requests-requests)
+ }
+ if status, _ := resolveRefusal(t, fx, token, acct, intent, "", "another note"); status != http.StatusConflict {
+ t.Fatalf("a replay with another note: %d; want 409", status)
+ }
+ // A subscription that names the retired intent afterwards is news
+ // (nothing was attached when it was retired): settled as an event, and
+ // a person is told, once however often it is delivered.
+ f.mu.Lock()
+ f.subscriptionPrice = intent.Price
+ f.mu.Unlock()
+ for i, id := range []string{"evt_late_sub", "evt_late_sub", "evt_late_sub_again"} {
+ if code := f.webhookPost(t, fx, id, "customer.subscription.created", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("late subscription event %d: %d; want it settled", i, code)
+ }
+ }
+ var disputes, occurrences int
+ if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_checkout_disputes WHERE account_id=? AND session_id=?`, acct.ID, intent.SessionID).Scan(&disputes); err != nil || disputes != 1 {
+ t.Fatalf("%d disputes (%v); want one", disputes, err)
+ }
+ if err := fx.st.db.QueryRow(`SELECT occurrences FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0`, acct.ID, intent.SessionID, billingFateDisputedReason).Scan(&occurrences); err != nil || occurrences != 1 {
+ t.Fatalf("the dispute review: %d occurrences (%v); want one open review filed once", occurrences, err)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" {
+ t.Fatalf("recorded from a retired intent's subscription: %+v", state)
+ }
+}
+
+// A payment that wins the race with the expiry is the session's
+// completion, recorded as its fate: a retire that follows is refused, in
+// its own transaction, and the purchase, being the intent's own and paid,
+// can be kept and then records.
+func TestAPaymentBeforeTheRetireIsACompletionNotARetirement(t *testing.T) {
+ 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)
+ // Read back as another customer's while the expiry fails: the review
+ // is filed and the session stays open.
+ f.mu.Lock()
+ f.sessionCustomer, f.failExpire = "cus_other", 100
+ f.mu.Unlock()
+ if dest, err := fx.srv.startBillingCheckout(context.Background(), acct, plans.PlanPersonal, "GB", "", "", f.now.Add(time.Minute)); dest != "" || err == nil {
+ t.Fatalf("the contradicted read: dest %q err %v", dest, err)
+ }
+ if !refusalOpen(t, fx, acct, intent) || f.sessionStatus != "open" {
+ t.Fatalf("fixture: review open %v, session %s", refusalOpen(t, fx, acct, intent), f.sessionStatus)
+ }
+ // The provider's record is corrected and the buyer, who kept the URL,
+ // pays through it.
+ f.mu.Lock()
+ f.sessionCustomer, f.sessionStatus, f.subscriptionPrice, f.failExpire = "", "complete", intent.Price, 0
+ f.mu.Unlock()
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-race")
+ status, msg := resolveRefusal(t, fx, token, acct, intent, "", "expired, never paid")
+ if status != http.StatusConflict || !strings.Contains(msg, "its session was completed") {
+ t.Fatalf("retire after the payment: %d %q; want 409, the session was completed", status, msg)
+ }
+ st, _ := fx.st.refusalState(acct.ID, intent.ID)
+ if st.Fate != sessionFateComplete || st.PaymentStatus != "paid" || !st.Stands || !st.Open {
+ t.Fatalf("state %+v; want the completion recorded and the intent standing under its review", st)
+ }
+ // Nothing records under the review.
+ if code := f.webhookPost(t, fx, "evt_race_done", "checkout.session.completed", intent.SessionID); code == http.StatusOK {
+ t.Fatalf("the completion under the review: %d", code)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" {
+ t.Fatalf("recorded under an open review: %+v", state)
+ }
+ // Complete is not paid: while the session's payment is outstanding,
+ // keep is refused.
+ f.mu.Lock()
+ f.paymentStatus = "unpaid"
+ f.mu.Unlock()
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "keep", "paid through the original link"); status != http.StatusConflict || !strings.Contains(msg, "payment") {
+ t.Fatalf("keep while the payment is outstanding: %d %q; want 409", status, msg)
+ }
+ f.mu.Lock()
+ f.paymentStatus = ""
+ f.mu.Unlock()
+ // Kept: the review closes and the purchase records.
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "keep", "paid through the original link"); status != http.StatusOK {
+ t.Fatalf("keep: %d %q", status, msg)
+ }
+ // With the review closed and the intent standing for its record, an
+ // expiry proof for its session (which the provider should never give
+ // for a completed one) retires nothing: a session recorded complete
+ // is not retired as expired.
+ kept, _ := fx.st.billingCheckoutIntent(acct.ID)
+ proof := &billingstripe.Session{ID: intent.SessionID, Status: "expired", LiveMode: false, ClientReferenceID: acct.Sqid, CustomerID: "cus_paid", Metadata: map[string]string{"checkout_intent": intent.ID, "account_sqid": acct.Sqid}}
+ if err := fx.st.expireBillingCheckout(kept, proof, f.now.Add(3*time.Minute)); !errors.Is(err, errBillingConflict) {
+ t.Fatalf("an expiry proof after keep: %v; want a conflict and the intent standing", err)
+ }
+ if standing, err := fx.st.billingCheckoutIntent(acct.ID); err != nil || standing.ID != intent.ID {
+ t.Fatalf("the kept intent after an expiry proof: %+v %v", standing, err)
+ }
+ if code := f.webhookPost(t, fx, "evt_race_sub", "customer.subscription.created", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("the subscription event after keep: %d", code)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "sub_paid" {
+ t.Fatalf("after keep: %+v; want the purchase recorded", state)
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); !errors.Is(err, sql.ErrNoRows) {
+ t.Fatalf("the intent after the record: %v; want it consumed", err)
+ }
+}
+
+// The pass owns the retries: a refused intent whose expiry failed is tried
+// again on its schedule (one minute, then five), never before, and is not
+// reconciled as a pending purchase meanwhile.
+func TestThePassRetriesAFailedExpiryOnItsSchedule(t *testing.T) {
+ fx, f, acct, intent, _ := refusedByAHandMadeSubscription(t, 100)
+ calls := func() int { f.mu.Lock(); defer f.mu.Unlock(); return f.expireCalls }
+ first := calls()
+ if first != 1 || f.sessionStatus != "open" {
+ t.Fatalf("fixture: %d expiry requests, session %s", first, f.sessionStatus)
+ }
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(30*time.Second))
+ if calls() != first {
+ t.Fatalf("a pass before the first retry is due made %d expiry requests", calls()-first)
+ }
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(2*time.Minute))
+ if calls() != first+1 {
+ t.Fatalf("a pass after one minute: %d expiry requests; want one retry", calls()-first)
+ }
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(4*time.Minute))
+ if calls() != first+1 {
+ t.Fatalf("a pass before the second retry is due (five minutes after the first): %d", calls()-first)
+ }
+ f.mu.Lock()
+ f.failExpire = 0
+ f.mu.Unlock()
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(8*time.Minute))
+ st, err := fx.st.refusalState(acct.ID, intent.ID)
+ if err != nil || calls() != first+2 || f.sessionStatus != "expired" || st.Fate != sessionFateExpired || !st.Stands || !st.Open {
+ t.Fatalf("after the retry that went through: %d requests, session %s, state %+v %v; want the session expired, its fate recorded, the intent standing", calls()-first, f.sessionStatus, st, err)
+ }
+ // With the fate recorded the pass asks the provider nothing more.
+ requests := f.requests
+ _ = fx.srv.billingPass(context.Background(), f.now.Add(3*time.Hour))
+ if f.requests != requests {
+ t.Fatalf("a pass over a refused intent whose fate is recorded made %d provider requests", f.requests-requests)
+ }
+}
+
+// A review opened before the protocol recorded nothing of what was
+// attached to the purchase: it is held, whatever its session's fate.
+func TestAReviewFromBeforeTheProtocolIsHeld(t *testing.T) {
+ fx, f, acct, intent := refusedByAnotherCustomer(t)
+ f.mu.Lock()
+ f.sessionCustomer = ""
+ f.mu.Unlock()
+ if _, err := fx.st.db.Exec(`UPDATE billing_reviews SET protocol=0 WHERE account_id=? AND subscription=?`, acct.ID, intent.ID); err != nil {
+ t.Fatal(err)
+ }
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-legacy")
+ status, msg := resolveRefusal(t, fx, token, acct, intent, "", "expired, never paid")
+ if status != http.StatusConflict || !strings.Contains(msg, "before the retirement protocol") {
+ t.Fatalf("retire of a review from before the protocol: %d %q; want 409 held", status, msg)
+ }
+ if st, _ := fx.st.refusalState(acct.ID, intent.ID); st.Fate != sessionFateExpired || !st.Stands || !st.Open {
+ t.Fatalf("state %+v; want the fate proven and the intent still held", st)
+ }
+ // The store refuses it by itself too, whoever calls.
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ var held *billingReviewHeld
+ if err := fx.st.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: intent.ID, Reason: billingCheckoutRefusedReason, Occurrence: 1, Actor: "paul@keymail.dev", Note: "x"}, f.now.Add(time.Hour)); !errors.As(err, &held) {
+ t.Fatalf("the store's own answer: %v; want held", err)
+ }
+}
+
+// The delete barrier: whatever statement tries, an intent with a refusal
+// review is not deleted while its session is not proven expired (an older
+// binary's retire, or a hand at the database).
+func TestTheDeleteBarrierHoldsARefusedIntent(t *testing.T) {
+ fx, f, acct, intent := refusedByAnotherCustomer(t)
+ if _, err := fx.st.db.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=?`, acct.ID, intent.ID); err == nil || !strings.Contains(err.Error(), "billing_refused_intent_delete_barrier") {
+ t.Fatalf("a bare delete of a refused intent with no proven fate: %v; want the barrier", err)
+ }
+ // The older retire, as one transaction: the review resolved and the
+ // intent deleted. It aborts as a whole; the review stays open.
+ tx, err := fx.st.db.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tx.Exec(`UPDATE billing_reviews SET resolved_at=? WHERE account_id=? AND subscription=? AND reason=?`, f.now.Unix(), acct.ID, intent.ID, billingCheckoutRefusedReason); err != nil {
+ t.Fatal(err)
+ }
+ _, derr := tx.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=?`, acct.ID, intent.ID)
+ _ = tx.Rollback()
+ if derr == nil || !strings.Contains(derr.Error(), "billing_refused_intent_delete_barrier") {
+ t.Fatalf("the older retire's delete: %v; want the barrier", derr)
+ }
+ if !refusalOpen(t, fx, acct, intent) {
+ t.Fatal("the review is not open after the aborted transaction")
+ }
+ // With the expiry proven the same delete passes.
+ f.mu.Lock()
+ f.sessionCustomer = ""
+ f.mu.Unlock()
+ if err := fx.srv.advanceRefusal(context.Background(), intent, acct, f.now.Add(time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.db.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=?`, acct.ID, intent.ID); err != nil {
+ t.Fatalf("the delete with the expiry proven: %v", err)
+ }
+}
+
+// A recorded fate is never rewritten. Evidence that contradicts it is a
+// dispute: recorded once, an account review a person must see, and the
+// intent held whatever outcome is asked for.
+func TestEvidenceAgainstARecordedFateIsADispute(t *testing.T) {
+ fx, f, acct, intent := refusedByAnotherCustomer(t)
+ f.mu.Lock()
+ f.sessionCustomer = ""
+ f.mu.Unlock()
+ if err := fx.srv.advanceRefusal(context.Background(), intent, acct, f.now.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ if st, _ := fx.st.refusalState(acct.ID, intent.ID); st.Fate != sessionFateExpired {
+ t.Fatalf("fixture: state %+v", st)
+ }
+ // The same session now reads complete and paid.
+ f.mu.Lock()
+ f.sessionStatus, f.subscriptionPrice = "complete", intent.Price
+ f.mu.Unlock()
+ for _, id := range []string{"evt_contra", "evt_contra", "evt_contra_again"} {
+ _ = f.webhookPost(t, fx, id, "checkout.session.completed", intent.SessionID)
+ }
+ st, err := fx.st.refusalState(acct.ID, intent.ID)
+ if err != nil || st.Fate != sessionFateExpired || st.Disputed == 0 {
+ t.Fatalf("state %+v %v; want the first fate kept and the intent disputed", st, err)
+ }
+ var disputes int
+ var occurrences int64
+ if err := fx.st.db.QueryRow(`SELECT count(*) FROM billing_checkout_disputes WHERE account_id=? AND session_id=?`, acct.ID, intent.SessionID).Scan(&disputes); err != nil || disputes != 1 {
+ t.Fatalf("%d disputes (%v); want one", disputes, err)
+ }
+ if err := fx.st.db.QueryRow(`SELECT occurrences FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0`, acct.ID, intent.SessionID, billingFateDisputedReason).Scan(&occurrences); err != nil || occurrences != 1 {
+ t.Fatalf("the dispute review: %d occurrences (%v); want one, filed once", occurrences, err)
+ }
+ if state, _ := fx.st.billingAccount(acct.ID); state.Subscription != "" {
+ t.Fatalf("recorded from a disputed session: %+v", state)
+ }
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-dispute")
+ for _, outcome := range []string{"", "keep"} {
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, outcome, "x"); status != http.StatusConflict {
+ t.Fatalf("outcome %q on a disputed intent: %d %q; want 409", outcome, status, msg)
+ }
+ }
+ if _, err := fx.st.billingCheckoutIntent(acct.ID); err != nil {
+ t.Fatalf("the disputed intent: %v; want it standing", err)
+ }
+ // The ordinary expiry retirement does not take it either.
+ if err := fx.st.db.QueryRow(`SELECT 1 FROM billing_checkout_intents WHERE account_id=? AND fate_disputed_at<>0`, acct.ID).Scan(new(int)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Keep is for the plain case too: a completed session with a subscription
+// made outside checkout attached to the purchase is held, because keeping
+// the checkout's purchase disposes of nothing about that other
+// subscription.
+func TestKeepIsHeldWhileAnotherSubscriptionIsAttached(t *testing.T) {
+ fx, f, acct, intent, code := refusedByAHandMadeSubscription(t, 100)
+ if code != http.StatusServiceUnavailable {
+ t.Fatalf("fixture: %d", code)
+ }
+ // The buyer pays through the still-open session; its own subscription
+ // is another one than the hand-made.
+ f.mu.Lock()
+ f.sessionStatus, f.subscriptionPrice, f.sessionSubscription, f.failExpire = "complete", intent.Price, "sub_checkout", 0
+ f.mu.Unlock()
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-keep-held")
+ status, msg := resolveRefusal(t, fx, token, acct, intent, "keep", "paid")
+ if status != http.StatusConflict || !strings.Contains(msg, "sub_paid") {
+ t.Fatalf("keep with another subscription attached: %d %q; want 409 held, naming it", status, msg)
+ }
+ st, _ := fx.st.refusalState(acct.ID, intent.ID)
+ if st.Fate != sessionFateComplete || st.SessionSubscription != "sub_checkout" || !st.Stands || !st.Open {
+ t.Fatalf("state %+v; want the completion recorded with its own subscription and the intent held", st)
+ }
+ if status, msg := resolveRefusal(t, fx, token, acct, intent, "", "refunded"); status != http.StatusConflict {
+ t.Fatalf("retire of the same: %d %q; want 409", status, msg)
+ }
+}
diff --git a/internal/console/billingreview_test.go b/internal/console/billingreview_test.go
index c5b8185a6..5a2b406b6 100644
--- a/internal/console/billingreview_test.go
+++ b/internal/console/billingreview_test.go
@@ -138,6 +138,9 @@ func TestBillingReviewLastSeenIsNotBackdated(t *testing.T) {
func TestBillingReviewOccurrenceMigrationPreservesRows(t *testing.T) {
s, id, now := reviewFixture(t)
for _, stmt := range []string{
+ // A database that predates these also predates the delete
+ // barrier, which names them.
+ `DROP TRIGGER billing_refused_intent_delete_barrier`,
`DROP TABLE billing_review_resolutions`,
`ALTER TABLE billing_reviews DROP COLUMN occurrences`,
`ALTER TABLE billing_reviews DROP COLUMN last_seen_at`,
diff --git a/internal/console/billingservice.go b/internal/console/billingservice.go
index 129940ffd..543a3e536 100644
--- a/internal/console/billingservice.go
+++ b/internal/console/billingservice.go
@@ -266,9 +266,10 @@ func (b *billingRuntime) intentSession(session *billingstripe.Session, intent bi
// 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 offered, bound or recorded on the intent's
-// behalf, or expired on proof; the one session expired without passing
-// here is the intent's own bound session read back as nobody's
-// (ownBoundSession, by the id the console itself bound).
+// behalf, or expired on proof; the sessions whose expiry is requested
+// without passing here are a refused intent's own bound session
+// (advanceRefusal, by the id the console itself bound: read back as
+// nobody's, or attached to a subscription that is not its purchase).
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 || session.Metadata["account_sqid"] != acct.Sqid {
return false
@@ -300,10 +301,10 @@ func (e *billingIdentityOfAccount) Unwrap() error { return errBillingIdentity }
// so the intent is retired on no such proof. The review is the one a
// mismatched purchase files, with the session as its reference; the intent
// stands, so no second purchase opens beside it, until the operator
-// resolves the review. An expiry that fails here leaves the session
-// payable under an open review, and nothing yet proves or retries it:
-// that is review eighteen's finding, answered by the retirement protocol
-// (2026-10-02-intent-retirement-protocol-design.md), not by this order.
+// resolves the review. From the filing on, the retirement protocol
+// (billingretirement.go) owns the session: its expiry is requested here
+// and, if that fails, again by the pass and by the operator's retire, and
+// the intent is released only on what a provider read then proves.
// A read that is not of the bound session (a listing, a create response)
// files nothing: nothing was ever disclosed on it.
func (s *Server) ownBoundSession(ctx context.Context, session *billingstripe.Session, intent billingIntent, acct *Account, now time.Time) (bool, error) {
@@ -318,15 +319,21 @@ func (s *Server) ownBoundSession(ctx context.Context, session *billingstripe.Ses
// clause), so a pass that reads the expired session before this call
// returns, or a crash between the two, leaves the intent standing
// under its review rather than retired with no record. An expiry that
- // fails leaves the review standing too: the next read files nothing
- // new (the review is open) and tries the expiry again.
+ // fails leaves the review standing too, and is requested again by the
+ // pass on its schedule and by the operator's retire.
if err := s.store.fileCheckoutRefusedReview(intent, session.ID, now); err != nil {
return false, err
}
- if session.Status == "open" {
- if _, err := s.billing.client.ExpireCheckout(ctx, intent.SessionID, "carloku-expire-"+intent.ID); err != nil {
- return false, err
- }
+ // A recorded fate this read contradicts is a dispute; then the session
+ // is worked toward a proven fate (advanceRefusal: its expiry requested
+ // if it is open, and what the read after it proves recorded). A read
+ // that names another account proves nothing, so the fate of such a
+ // session stays unproven and the intent held.
+ if err := s.noteSessionEvidence(intent, acct, session, now); err != nil {
+ return false, err
+ }
+ if err := s.advanceRefusal(ctx, intent, acct, now); err != nil {
+ return false, err
}
return false, nil
}
@@ -361,6 +368,15 @@ func (s *Server) refuseSession(ctx context.Context, intent billingIntent, sessio
if err := s.store.fileCheckoutRefusedReview(intent, session.ID, now); err != nil {
return err
}
+ // What the completed session proves is recorded with the review:
+ // a session recorded complete is never retired as expired.
+ if acct, err := s.store.AccountByID(intent.AccountID); err != nil {
+ return err
+ } else if acct != nil {
+ if err := s.noteSessionEvidence(intent, acct, session, now); err != nil {
+ return err
+ }
+ }
return errBillingIdentity
}
expired, err := s.billing.client.ExpireCheckout(ctx, sessionID, "carloku-expire-"+intent.ID)
@@ -596,6 +612,18 @@ func (s *Server) startBillingCheckoutQuoted(ctx context.Context, acct *Account,
if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
return "", lookupErr
}
+ // An intent under a refusal review is offered to nobody and replaced
+ // by nothing: no URL, no second purchase, until a person disposes of
+ // it. (A URL the buyer already holds cannot be taken back; a payment
+ // through it before the provider has expired the session is recorded
+ // as the session's completion and goes to that person.)
+ if lookupErr == nil {
+ if open, err := s.store.refusalReviewOpen(acct.ID, previous.ID); err != nil {
+ return "", err
+ } else if open {
+ return "", errBillingUnderReview
+ }
+ }
// A ready intent whose CreateCheckout response was lost never saved its
// session id. Recover the original session under the original key before
// any comparison, so a lost purchase is bound and honoured rather than
@@ -822,38 +850,53 @@ func (s *Server) reconcileBillingSubscription(ctx context.Context, id, eventID s
if retired, err := s.store.billingIntentRetired(acct.ID, sub.Metadata["checkout_intent"]); err != nil {
return err
} else if retired {
+ // A subscription naming an intent that was retired on a proven
+ // expiry is news: nothing was attached to it then. A person
+ // is told (a dispute for the retired session), and the event
+ // is settled as before.
+ if err := s.store.noteLateSubscription(acct.ID, sub.Metadata["checkout_intent"], id, now); err != nil {
+ return err
+ }
return &billingIdentityOfAccount{AccountID: acct.ID}
}
}
refuse := func() error {
if pending != nil && pending.SessionID != "" && pending.ID == sub.Metadata["checkout_intent"] {
- // The bound session is read before the review names it: one
- // still open (a subscription made by hand at the provider
- // while the buyer's session waits) is expired by its bound id
- // first, as ownBoundSession does and for the same reason, so
- // nothing can be paid through it after the operator resolves
- // the review; the answer is used for nothing.
+ // The bound session is read before the review names it. The
+ // review is filed, the subscription recorded as attached to
+ // it, and the session worked toward a proven fate (one still
+ // open, a subscription made by hand at the provider while the
+ // buyer's session waits, has its expiry requested by its
+ // bound id); the intent is released only by the operator, on
+ // what the store has recorded (billingretirement.go).
session, err := b.client.GetCheckout(ctx, pending.SessionID)
if err != nil {
return err
}
if session.ID != pending.SessionID {
// Answered with another session: the read failed, and no
- // review is filed on a session that was not read (a
- // resolution would retire the intent with its own session
- // still open). The provider tries again.
+ // review is filed on a session that was not read. The
+ // provider tries again.
return errBillingPending
}
- // The review before the expiry, as in ownBoundSession: with the
- // review filed, no pass can retire the intent between the
- // provider's expiry and the filing.
+ // The review first: with it filed, nothing retires the intent
+ // but the operator's resolution.
if err := s.store.fileCheckoutRefusedReview(*pending, pending.SessionID, now); err != nil {
return err
}
- if session.Status == "open" {
- if _, err := b.client.ExpireCheckout(ctx, pending.SessionID, "carloku-expire-"+pending.ID); err != nil {
- return err
- }
+ // The subscription that caused the refusal is recorded with
+ // it: expiring the bound session says nothing about what this
+ // subscription has collected or will, and a case with a
+ // subscription attached is not retired in stage one. Then the
+ // session is worked toward a proven fate.
+ if err := s.store.addRefusalObligation(acct.ID, pending.ID, id, now); err != nil {
+ return err
+ }
+ if err := s.noteSessionEvidence(*pending, acct, session, now); err != nil {
+ return err
+ }
+ if err := s.advanceRefusal(ctx, *pending, acct, now); err != nil {
+ return err
}
}
return errBillingIdentity
@@ -1150,8 +1193,19 @@ func (s *Server) bindBillingCheckoutFor(ctx context.Context, sessionID string, e
// there on an owned, completed session is a review, not a silent
// refusal.
if !b.ownSession(session, intent, acct) {
+ // Read back as nobody's: if it is the session a refused intent is
+ // pinned to and a fate is recorded for it, that is a dispute.
+ if err := s.noteSessionEvidence(intent, acct, session, time.Now()); err != nil {
+ return "", err
+ }
return "", errBillingIdentity
}
+ // What this read proves about a refused intent's session is recorded
+ // (a completion under an open review, for one): it writes nothing for
+ // an intent with no refusal review.
+ if err := s.noteSessionEvidence(intent, acct, session, time.Now()); err != nil {
+ return "", err
+ }
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
@@ -1166,6 +1220,9 @@ func (s *Server) bindBillingCheckoutFor(ctx context.Context, sessionID string, e
if err := s.store.fileCheckoutRefusedReview(intent, session.ID, time.Now()); err != nil {
return "", err
}
+ if err := s.noteSessionEvidence(intent, acct, session, time.Now()); err != nil {
+ return "", err
+ }
return "", errBillingIdentity
}
r, err := s.store.db.Exec(`UPDATE billing_checkout_intents SET session_id=?,expires_at=? WHERE account_id=? AND id=? AND (session_id='' OR session_id=?)`, session.ID, session.ExpiresAt, acct.ID, intent.ID, session.ID)
diff --git a/internal/console/billingservice_test.go b/internal/console/billingservice_test.go
index aec2467b9..e70ca6acc 100644
--- a/internal/console/billingservice_test.go
+++ b/internal/console/billingservice_test.go
@@ -63,6 +63,10 @@ type fakeBillingStripe struct {
// happens while a reconciliation is paused between its checks and its
// record (an operator resolving a review, say).
customerHook func()
+ // sessionSubscription, when set, is the completed session's own
+ // subscription id (sub_paid otherwise): a session whose subscription
+ // is another one than a hand-made subscription the test delivers.
+ sessionSubscription string
// expireHook, when set, runs inside the expiry, once, after the fake
// has marked the session expired and before it answers: what happens
// between the provider's expiry and the console's next step.
@@ -159,6 +163,9 @@ func (f *fakeBillingStripe) session() map[string]any {
sub = "sub_paid"
customer = "cus_paid"
payment = "paid"
+ if f.sessionSubscription != "" {
+ sub = f.sessionSubscription
+ }
}
if f.paymentStatus != "" {
payment = f.paymentStatus
diff --git a/internal/console/billingstore.go b/internal/console/billingstore.go
index 7d1e76433..ad7c0ebce 100644
--- a/internal/console/billingstore.go
+++ b/internal/console/billingstore.go
@@ -278,6 +278,12 @@ func (s *Store) migrateBilling() error {
if err := s.migrateBillingQuantityOperations(); err != nil {
return err
}
+ // The retirement protocol's columns, tables and delete barrier
+ // (billingretirement.go), after every billing table they extend, so
+ // whoever runs this migration gets the whole schema.
+ if err := s.migrateBillingRetirement(); err != nil {
+ return err
+ }
return s.migrateEntitlements()
}
@@ -454,7 +460,17 @@ func (s *Store) retireIntent(intent billingIntent, proof *billingstripe.Session,
// it, whatever its session's state: the review may name a session the
// console itself expired (ownBoundSession, reconcile's refusal), and
// an expiry is then no proof that nothing was bought on the intent.
+ // The proof this retirement rests on is recorded as the session's fate
+ // first, where none is recorded (the delete barrier reads it), and an
+ // intent whose session is recorded complete, or whose evidence is
+ // disputed, is never retired on an expiry proof: that contradiction is
+ // a person's. All of it rolls back with the identity check below.
+ if _, err := tx.Exec(`UPDATE billing_checkout_intents SET session_fate=?,session_fate_at=? WHERE account_id=? AND id=? AND session_id=? AND session_fate=''`,
+ sessionFateExpired, max(now.Unix(), 1), intent.AccountID, intent.ID, intent.SessionID); err != nil {
+ return err
+ }
r, err := tx.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=? AND session_id=? AND live_mode=?
+ AND session_fate<>'complete' AND fate_disputed_at=0
AND NOT EXISTS (SELECT 1 FROM billing_reviews WHERE account_id=? AND subscription=? AND reason=? AND resolved_at=0)`,
intent.AccountID, intent.ID, intent.SessionID, intent.LiveMode, intent.AccountID, intent.ID, billingCheckoutRefusedReason)
if err != nil {
@@ -950,6 +966,21 @@ func (s *Store) resolveBillingReview(r billingReviewResolution, now time.Time) e
}
return nil
}
+ // A checkout refusal is resolved only as the recorded state of its
+ // intent allows (billingretirement.go): read here, in the transaction
+ // that resolves it, so a fate, a dispute or an attached subscription
+ // recorded since the operator looked is what decides.
+ evidence := ""
+ if r.Reason == billingCheckoutRefusedReason {
+ st, err := refusalStateQ(tx, r.AccountID, r.Subscription)
+ if err != nil {
+ return err
+ }
+ if why := st.held(r.outcome()); why != "" {
+ return &billingReviewHeld{Why: why}
+ }
+ evidence = st.evidence()
+ }
// Never record a resolution earlier than the occurrence it resolves.
at := max(now.Unix(), lastSeen)
res, err := tx.Exec(`UPDATE billing_reviews SET resolved_at=? WHERE account_id=? AND subscription=? AND reason=? AND occurrences=? AND resolved_at=0`,
@@ -964,8 +995,8 @@ func (s *Store) resolveBillingReview(r billingReviewResolution, now time.Time) e
if n != 1 {
return errBillingReviewStale
}
- if _, err := tx.Exec(`INSERT INTO billing_review_resolutions(account_id,subscription,reason,occurrence,last_seen_at,actor,note,resolved_at,outcome) VALUES(?,?,?,?,?,?,?,?,?)`,
- r.AccountID, r.Subscription, r.Reason, r.Occurrence, lastSeen, r.Actor, r.Note, at, r.outcome()); err != nil {
+ if _, err := tx.Exec(`INSERT INTO billing_review_resolutions(account_id,subscription,reason,occurrence,last_seen_at,actor,note,resolved_at,outcome,evidence) VALUES(?,?,?,?,?,?,?,?,?,?)`,
+ r.AccountID, r.Subscription, r.Reason, r.Occurrence, lastSeen, r.Actor, r.Note, at, r.outcome(), evidence); err != nil {
return err
}
// Resolving the review of a session paid in the wrong currency with
@@ -980,8 +1011,8 @@ func (s *Store) resolveBillingReview(r billingReviewResolution, now time.Time) e
// bind and the review does not come back. An intent that is not that
// one (a later purchase's) is left alone.
if r.Reason == billingCheckoutRefusedReason && r.outcome() == "retire" {
- var sessionID, plan, band, currency, book string
- err := tx.QueryRow(`SELECT session_id,plan,band,currency,price_book FROM billing_checkout_intents WHERE account_id=? AND id=?`, r.AccountID, r.Subscription).Scan(&sessionID, &plan, &band, &currency, &book)
+ var sessionID, plan, band, currency, book, fate string
+ err := tx.QueryRow(`SELECT session_id,plan,band,currency,price_book,session_fate FROM billing_checkout_intents WHERE account_id=? AND id=?`, r.AccountID, r.Subscription).Scan(&sessionID, &plan, &band, &currency, &book, &fate)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
@@ -1002,8 +1033,8 @@ func (s *Store) resolveBillingReview(r billingReviewResolution, now time.Time) e
if _, err := tx.Exec(`UPDATE billing_quantity_events SET processed_at=?,processing_outcome='checkout_refused_resolved',processing_reference=? WHERE account_id=? AND intent_id=? AND subscription='' AND processed_at=0`, at, reference, r.AccountID, r.Subscription); err != nil {
return err
}
- if _, err := tx.Exec(`INSERT INTO billing_retired_intents(account_id,intent_id,session_id,plan,band,currency,price_book,reason,actor,retired_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
- r.AccountID, r.Subscription, sessionID, plan, band, currency, book, r.Reason, r.Actor, at); err != nil {
+ if _, err := tx.Exec(`INSERT INTO billing_retired_intents(account_id,intent_id,session_id,plan,band,currency,price_book,reason,actor,retired_at,session_fate) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ r.AccountID, r.Subscription, sessionID, plan, band, currency, book, r.Reason, r.Actor, at, fate); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=? AND id=?`, r.AccountID, r.Subscription); err != nil {
@@ -1044,6 +1075,13 @@ func (s *Store) fileCheckoutRefusedReview(intent billingIntent, sessionID string
if err := fileBillingReviewTx(tx, intent.AccountID, intent.ID, billingCheckoutRefusedReason, observed); err != nil {
return err
}
+ // Filed (or filed again) by the retirement protocol: what is
+ // attached to the purchase is recorded from here on. A review left
+ // open by a build from before the protocol keeps protocol 0 and
+ // is held.
+ if _, err := tx.Exec(`UPDATE billing_reviews SET protocol=1 WHERE account_id=? AND subscription=? AND reason=?`, 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