carlos / platform Public

carlos billing: its first review answered (one unambiguous request or none, acknowledged writes, the console named, a shared wire contract, the reference section)

Opus accepted with fixes and Astra rejected 0f507bff. Answered here:

- Arguments: parseBilling turns the command line into one invocation or
  refuses it before any request. A positional argument is refused (a note
  typed without quotes dropped its second word and an --outcome keep after
  it, so the console took the default retire); so is a flag the subcommand
  does not use, --outcome for a reason that has none, a house write with
  neither --set nor --clear, and a note the console would refuse.
- Writes: resolve and house report done only on the door's own
  acknowledgement (the account, subject, reason, resolved, and for a
  checkout refusal retired; the account and the marker). An answer that
  never arrives says the change may have been made.
- What the operator is told: the console acted on is named on every write;
  a retire that found no intent standing says nothing was retired, not
  "kept"; a plain 404 says the address is not an operator there, not to
  check --account and --app; direct mode says to unset the environment;
  the listing says "not held by what is recorded", "no intent stands" and
  "disposition unavailable" instead of "available".
- The wire: internal/console/testdata/billing-operator-wire.json is the one
  list of names. The console's test holds the real handlers to it with real
  requests (listing, retire, keep, rejected, house set and clear); the
  CLI's test builds its fake console from it and checks the request keys
  the client sends.
- docs/site/cli.md has the carlos billing section the docs gate requires
  (the gate on 0f507bff failed on exactly that). It is new public copy and
  is not reviewed: the branch does not merge before Paul reads it.
- The internal guide says what resolving does (it is an act: an unsettled
  quantity operation is retired by it), that either outcome can expire an
  open session, that keep guarantees nothing, that a dispute cannot be
  resolved, which console a write goes to, and that rejected rows outlive
  the provider's event window.

Not done: an end-to-end resolve of a seeded refusal from package main (the
console's contract test does it against the real handlers instead).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 57f70c16630e6716b33cb500ba9f2b12e3d0a926 parent 0f507bf
6 files changed, +803 −156
  • billing_cmd.go +259 −71
  • billing_cmd_test.go +300 −45
  • docs/site/cli.md +32 −0
  • implementation/carloku-paid-launch/billing-operator.md +32 −13
  • internal/console/billingoperatorwire_test.go +167 −27
  • internal/console/testdata/billing-operator-wire.json +13 −0
diff --git a/billing_cmd.go b/billing_cmd.go
index c795b6e6a..0a0528467 100644
--- a/billing_cmd.go
+++ b/billing_cmd.go
@@ -1,14 +1,20 @@
// `carlos billing` — the operator's doors onto billing, from a terminal.
//
// Four console API doors exist for a platform operator (users.operator)
-// and answer 404 to everyone else (internal/console/billingoperator.go):
-// the open review holds, the resolution of one, the webhook events the
-// console refused, and the house accounts. This verb is a thin client for
-// them: it sends what the operator typed and prints what the console
-// answered. It decides nothing. In particular a checkout refusal is
-// resolved only as the console's recorded state allows (the retirement
-// protocol, internal/console/billingretirement.go); a refusal the console
-// holds comes back as its own message, which is printed as it is.
+// and answer a plain 404 to any other signed-in address
+// (internal/console/billingoperator.go): the open review holds, the
+// resolution of one, the webhook events the console refused, and the
+// house accounts. This verb is a thin client for them. It decides
+// nothing about billing: a checkout refusal is resolved only as the
+// console's recorded state allows (the retirement protocol,
+// internal/console/billingretirement.go), and a refusal the console holds
+// comes back as its own message, printed as it is.
+//
+// What it does decide is whether what was typed is one unambiguous
+// request: it refuses, before any request is made, arguments it would
+// otherwise drop, flags the subcommand does not use, and a note the
+// console would refuse; and it reports a write as done only when the
+// console's answer says so, naming the console it acted on.
//
// API mode only. The verb is not in the top-level usage: it is an
// operator's, and the doors confirm nothing to anyone else.
@@ -17,13 +23,17 @@ package main
import (
"context"
"encoding/json"
+ "errors"
"flag"
"fmt"
"io"
"log"
"net/http"
+ "os"
"strings"
"time"
+ "unicode"
+ "unicode/utf8"
)
const billingUsage = `usage: carlos billing reviews [--console <url>] [--json]
@@ -38,8 +48,24 @@ the count the listing showed). For a checkout refusal, --outcome is retire
(the default) or keep, and the console answers whether its recorded state
allows it. rejected lists the webhook events the console refused, newest
first. house lists the house accounts, or sets or clears the marker on one.
+
+The console is --console, else CARLOS_CONSOLE, else the one a .carlos/config
+above the current directory names, else the only one logged in. A write
+says which console it acted on. Quote a note of more than one word.
`
+// billingCheckoutRefused is the one reason whose resolution has an
+// outcome (internal/console: billingCheckoutRefusedReason).
+const billingCheckoutRefused = "billing_checkout_currency_refused"
+
+// billingNoIntentStands is the console's reason a keep is held when the
+// refused intent is gone (internal/console: refusalState.held).
+const billingNoIntentStands = "no such intent stands"
+
+// billingNoteMax is the console's limit on a note, in bytes
+// (internal/console: billingReviewNoteMax).
+const billingNoteMax = 256
+
// apiBillingReview is one open review hold as the console lists it.
type apiBillingReview struct {
Account string `json:"account"`
@@ -69,13 +95,24 @@ type apiBillingRejected struct {
ReceivedAt int64 `json:"received_at"`
}
+// billingError is the error for a door's refusal. The console's own
+// diagnosis (a JSON error) is passed on as it is. A plain 404 is what the
+// doors answer anyone who is not an operator, and what a console built
+// before them answers everyone: neither is about account membership.
+func (s *apiSession) billingError(status int, body []byte) error {
+ if status == http.StatusNotFound && !hasJSONError(body) {
+ return fmt.Errorf("not found: %s has no billing doors for %s (not an operator there, or a console from before them)", s.console, s.address)
+ }
+ return apiErrorFromBody(status, body)
+}
+
func (s *apiSession) billingGet(ctx context.Context, path string, out any) ([]byte, error) {
status, body, err := s.getJSON(ctx, path)
if err != nil {
return nil, err
}
if status != http.StatusOK {
- return nil, apiErrorFromBody(status, body)
+ return nil, s.billingError(status, body)
}
return body, json.Unmarshal(body, out)
}
@@ -115,125 +152,251 @@ type apiBillingResolution struct {
Outcome string `json:"outcome,omitempty"`
}
-// billingResolve posts one resolution. retired is the console's answer for
-// a checkout refusal (whether the intent was retired); known is false for
-// any other reason, which has no such answer.
+// errBillingUnacknowledged is a write the console answered 200 to without
+// the acknowledgement its door gives: something between here and the
+// console answered instead, or the console is not the build this client
+// knows. Nothing is reported as done.
+var errBillingUnacknowledged = errors.New("the console answered without acknowledging the change. nothing is reported as done: list again before repeating it")
+
+// billingResolve posts one resolution and reads the console's
+// acknowledgement of it. retired is the console's answer for a checkout
+// refusal (whether an intent was retired); known is false for any other
+// reason, which has no such answer.
func (s *apiSession) billingResolve(ctx context.Context, r apiBillingResolution) (retired, known bool, err error) {
status, body, err := s.postJSON(ctx, "/api/billing/reviews/resolve", r)
if err != nil {
- return false, false, err
+ return false, false, billingWriteUnknown(err)
}
if status != http.StatusOK {
- return false, false, apiErrorFromBody(status, body)
+ return false, false, s.billingError(status, body)
}
var res struct {
- Retired *bool `json:"retired"`
+ Account string `json:"account"`
+ Subject string `json:"subscription"`
+ Reason string `json:"reason"`
+ Resolved *bool `json:"resolved"`
+ Retired *bool `json:"retired"`
}
- if err := json.Unmarshal(body, &res); err != nil {
- return false, false, err
+ if err := json.Unmarshal(body, &res); err != nil || res.Resolved == nil || !*res.Resolved || res.Account != r.Account || res.Subject != r.Subject || res.Reason != r.Reason {
+ return false, false, errBillingUnacknowledged
}
- if res.Retired == nil {
- return false, false, nil
+ if r.Reason == billingCheckoutRefused {
+ if res.Retired == nil {
+ return false, false, errBillingUnacknowledged
+ }
+ return *res.Retired, true, nil
}
- return *res.Retired, true, nil
+ return false, false, nil
}
func (s *apiSession) billingHouseSet(ctx context.Context, account string, house bool, note string) error {
status, body, err := s.postJSON(ctx, "/api/billing/house", map[string]any{"account": account, "house": house, "note": note})
if err != nil {
- return err
+ return billingWriteUnknown(err)
}
if status != http.StatusOK {
- return apiErrorFromBody(status, body)
+ return s.billingError(status, body)
+ }
+ var res struct {
+ Account string `json:"account"`
+ House *bool `json:"house"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil || res.House == nil || *res.House != house || res.Account != account {
+ return errBillingUnacknowledged
}
return nil
}
+// billingWriteUnknown is a write whose answer never arrived (a timeout, a
+// dropped connection): the console may have made the change. Repeating it
+// exactly is safe (an exact repeat of a resolution answers as the first
+// did), and a changed note is not an exact repeat.
+func billingWriteUnknown(err error) error {
+ return fmt.Errorf("%w. the console may have made the change: list again, or repeat the command exactly", err)
+}
+
func billingSession(consoleFlag string) *apiSession {
sess, err := resolveMode(consoleFlag)
if err != nil {
log.Fatal(err)
}
if sess == nil {
- log.Fatal("billing: this verb works through a console; run: carlos auth login --console <url>")
+ log.Fatal("billing: this verb works through a console, and CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set. unset it to use the console you are logged in to")
}
return sess
}
-func runBilling(args []string) {
+// billingInvocation is one `carlos billing` command line, understood:
+// which door, on which console, and for a write, exactly what to send.
+type billingInvocation struct {
+ Verb string // reviews | rejected | resolve | house
+ Console string
+ JSON bool
+ Resolve apiBillingResolution
+ // HouseWrite is false for the house listing.
+ HouseWrite bool
+ HouseAccount string
+ House bool
+ HouseNote string
+}
+
+// billingFlagsOf says which flags each form of the verb takes; any other
+// flag given is refused, since a flag accepted and ignored reads as if it
+// had done something (a listing that looks filtered, a write that looks
+// like it will print JSON).
+var billingFlagsOf = map[string]map[string]bool{
+ "reviews": {"console": true, "json": true},
+ "rejected": {"console": true, "json": true},
+ "resolve": {"console": true, "account": true, "subject": true, "reason": true, "occurrences": true, "note": true, "outcome": true},
+ "house": {"console": true, "json": true},
+ "house write": {"console": true, "account": true, "set": true, "clear": true, "note": true},
+}
+
+// billingNote checks a note as the console will: something, in at most
+// billingNoteMax bytes of valid text with no control characters.
+func billingNote(note string) (string, error) {
+ note = strings.TrimSpace(note)
+ switch {
+ case note == "":
+ return "", errors.New("--note is required")
+ case len(note) > billingNoteMax:
+ return "", fmt.Errorf("--note is %d bytes; the console keeps at most %d", len(note), billingNoteMax)
+ case !utf8.ValidString(note) || strings.IndexFunc(note, unicode.IsControl) >= 0:
+ return "", errors.New("--note must be plain text on one line")
+ }
+ return note, nil
+}
+
+// parseBilling turns the arguments after `carlos billing` into one
+// invocation, or says why they are not one. It makes no request.
+func parseBilling(args []string) (billingInvocation, error) {
+ var inv billingInvocation
if len(args) == 0 {
- log.Fatal(billingUsage)
+ return inv, errors.New("a subcommand is required")
+ }
+ inv.Verb = args[0]
+ if _, known := billingFlagsOf[inv.Verb]; !known || inv.Verb == "house write" {
+ return inv, fmt.Errorf("no such subcommand: %s", inv.Verb)
}
- verb, rest := args[0], args[1:]
- fs := flag.NewFlagSet("billing "+verb, flag.ExitOnError)
- consoleFlag := fs.String("console", "", "console to use (default: the only logged-in one)")
- asJSON := fs.Bool("json", false, "print the console's answer as it came")
+ fs := flag.NewFlagSet("billing "+inv.Verb, flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ consoleFlag := fs.String("console", "", "console to use")
+ asJSON := fs.Bool("json", false, "for a listing: print the console's answer as it came")
account := fs.String("account", "", "account sqid")
- subject := fs.String("subject", "", "the review's subject: a subscription id, or the intent id for a checkout refusal")
+ subject := fs.String("subject", "", "the review's subject: a subscription id; for a checkout refusal the intent id; for a session dispute the session id")
reason := fs.String("reason", "", "the review's reason, as listed")
occurrences := fs.Int64("occurrences", 0, "the occurrences the listing showed")
note := fs.String("note", "", "why, for the record")
outcome := fs.String("outcome", "", "for a checkout refusal: retire | keep")
set := fs.Bool("set", false, "house: set the marker")
clear := fs.Bool("clear", false, "house: clear the marker")
- fs.Usage = flagUsage(fs, billingUsage)
- fs.Parse(rest)
- ctx := context.Background()
+ if err := fs.Parse(args[1:]); err != nil {
+ return inv, err
+ }
+ // flag.Parse stops at the first argument that is not a flag and
+ // leaves the rest unread: a note typed without quotes would lose its
+ // second word and every flag after it, an --outcome keep among them.
+ if fs.NArg() > 0 {
+ return inv, fmt.Errorf("unexpected argument %q (quote a value of more than one word; nothing was sent)", fs.Arg(0))
+ }
+ given := map[string]bool{}
+ fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
+ form := inv.Verb
+ if inv.Verb == "house" && (given["set"] || given["clear"] || given["account"] || given["note"]) {
+ form = "house write"
+ }
+ for name := range given {
+ if !billingFlagsOf[form][name] {
+ return inv, fmt.Errorf("--%s does not apply to billing %s", name, form)
+ }
+ }
+ inv.Console, inv.JSON = *consoleFlag, *asJSON
+ switch form {
+ case "resolve":
+ if *account == "" || *subject == "" || *reason == "" || *occurrences <= 0 {
+ return inv, errors.New("--account, --subject, --reason, --occurrences and --note are required")
+ }
+ text, err := billingNote(*note)
+ if err != nil {
+ return inv, err
+ }
+ if *outcome != "" {
+ if *reason != billingCheckoutRefused {
+ return inv, fmt.Errorf("--outcome applies only to a %s review", billingCheckoutRefused)
+ }
+ if *outcome != "retire" && *outcome != "keep" {
+ return inv, errors.New("--outcome is retire or keep")
+ }
+ }
+ inv.Resolve = apiBillingResolution{Account: *account, Subject: *subject, Reason: *reason, Occurrences: *occurrences, Note: text, Outcome: *outcome}
+ case "house write":
+ if *set == *clear || *account == "" {
+ return inv, errors.New("--account, exactly one of --set and --clear, and --note are required")
+ }
+ text, err := billingNote(*note)
+ if err != nil {
+ return inv, err
+ }
+ inv.HouseWrite, inv.HouseAccount, inv.House, inv.HouseNote = true, *account, *set, text
+ }
+ return inv, nil
+}
- switch verb {
+func runBilling(args []string) {
+ inv, err := parseBilling(args)
+ if err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ fmt.Fprint(os.Stderr, billingUsage)
+ os.Exit(0)
+ }
+ log.Fatalf("billing: %v\n%s", err, billingUsage)
+ }
+ ctx := context.Background()
+ sess := billingSession(inv.Console)
+ switch inv.Verb {
case "reviews":
- reviews, raw, err := billingSession(*consoleFlag).billingReviews(ctx)
+ reviews, raw, err := sess.billingReviews(ctx)
if err != nil {
log.Fatalf("billing reviews: %v", err)
}
- if *asJSON {
+ if inv.JSON {
fmt.Fprintln(stdout, string(raw))
return
}
printBillingReviews(stdout, reviews)
case "rejected":
- rejected, raw, err := billingSession(*consoleFlag).billingRejected(ctx)
+ rejected, raw, err := sess.billingRejected(ctx)
if err != nil {
log.Fatalf("billing rejected: %v", err)
}
- if *asJSON {
+ if inv.JSON {
fmt.Fprintln(stdout, string(raw))
return
}
printBillingRejected(stdout, rejected)
case "resolve":
- if *account == "" || *subject == "" || *reason == "" || *occurrences <= 0 || strings.TrimSpace(*note) == "" {
- log.Fatal("billing resolve: --account, --subject, --reason, --occurrences and --note are required\n" + billingUsage)
- }
- if *outcome != "" && *outcome != "retire" && *outcome != "keep" {
- log.Fatal("billing resolve: --outcome is retire or keep")
+ retired, known, err := sess.billingResolve(ctx, inv.Resolve)
+ if err != nil {
+ log.Fatalf("billing resolve on %s: %v", sess.console, err)
}
- retired, known, err := billingSession(*consoleFlag).billingResolve(ctx, apiBillingResolution{Account: *account, Subject: *subject, Reason: *reason, Occurrences: *occurrences, Note: *note, Outcome: *outcome})
+ line, err := billingResolvedLine(sess.console, inv.Resolve, retired, known)
if err != nil {
- log.Fatalf("billing resolve: %v", err)
+ log.Fatalf("billing resolve on %s: %v", sess.console, err)
}
- fmt.Fprintln(stdout, billingResolvedLine(*account, *subject, *reason, retired, known))
+ fmt.Fprintln(stdout, line)
case "house":
- sess := billingSession(*consoleFlag)
- if *set || *clear {
- if *set == *clear || *account == "" || strings.TrimSpace(*note) == "" {
- log.Fatal("billing house: --account, exactly one of --set and --clear, and --note are required\n" + billingUsage)
- }
- if err := sess.billingHouseSet(ctx, *account, *set, *note); err != nil {
- log.Fatalf("billing house: %v", err)
- }
- if *set {
- fmt.Fprintf(stdout, "%s is a house account\n", *account)
- } else {
- fmt.Fprintf(stdout, "%s is not a house account\n", *account)
+ if inv.HouseWrite {
+ if err := sess.billingHouseSet(ctx, inv.HouseAccount, inv.House, inv.HouseNote); err != nil {
+ log.Fatalf("billing house on %s: %v", sess.console, err)
}
+ fmt.Fprintln(stdout, billingHouseLine(sess.console, inv.HouseAccount, inv.House))
return
}
house, raw, err := sess.billingHouse(ctx)
if err != nil {
log.Fatalf("billing house: %v", err)
}
- if *asJSON {
+ if inv.JSON {
fmt.Fprintln(stdout, string(raw))
return
}
@@ -243,21 +406,34 @@ func runBilling(args []string) {
if len(house) == 0 {
mutedf(stdout, "no house accounts")
}
- default:
- log.Fatal(billingUsage)
}
}
-func billingResolvedLine(account, subject, reason string, retired, known bool) string {
- line := fmt.Sprintf("resolved %s %s (%s)", account, subject, reason)
- if known {
- if retired {
- line += ": the intent was retired"
- } else {
- line += ": the intent was kept"
- }
+// billingResolvedLine says what the console acknowledged, on which
+// console, in terms of the outcome that was asked for: the console's
+// retired answer says only whether an intent was retired, and a retire
+// that found no intent standing retired nothing, which is not a keep.
+func billingResolvedLine(console string, r apiBillingResolution, retired, known bool) (string, error) {
+ line := fmt.Sprintf("on %s: resolved %s %s (%s)", console, r.Account, r.Subject, r.Reason)
+ if !known {
+ return line, nil
+ }
+ switch {
+ case r.Outcome == "keep" && retired:
+ return "", errors.New("the console answered that a keep retired the intent. list the reviews and read what it recorded")
+ case r.Outcome == "keep":
+ return line + ": the intent stands, for its purchase to record", nil
+ case retired:
+ return line + ": the intent was retired", nil
}
- return line
+ return line + ": no intent stood, so nothing was retired", nil
+}
+
+func billingHouseLine(console, account string, house bool) string {
+ if house {
+ return fmt.Sprintf("on %s: %s is a house account", console, account)
+ }
+ return fmt.Sprintf("on %s: %s is not a house account", console, account)
}
func billingTime(unix int64) string {
@@ -282,12 +458,20 @@ func printBillingReviews(w io.Writer, reviews []apiBillingReview) {
}
d := r.Disposition
if d == nil {
+ if r.Reason == billingCheckoutRefused {
+ // The console could not read what it recorded of this
+ // refusal: not an ordinary hold with nothing to show.
+ fmt.Fprintf(w, " disposition unavailable: the console did not say what it recorded. list again\n")
+ }
continue
}
fate := d.Fate
if fate == "" {
fate = "not proven"
}
+ if d.KeepHeld == billingNoIntentStands {
+ fate = "no intent stands"
+ }
if d.Disputed {
fate += ", disputed"
}
@@ -300,9 +484,13 @@ func printBillingReviews(w io.Writer, reviews []apiBillingReview) {
}
}
+// billingAvailability words one outcome's state. Not held means not held
+// by what the console has recorded: the resolution still reads the
+// provider (keep requires the session, read then, to be complete, paid
+// and the intent's own purchase) and can still be refused.
func billingAvailability(held string) string {
if held == "" {
- return "available"
+ return "not held by what is recorded (the console still reads the provider when asked)"
}
return "held: " + held
}
diff --git a/billing_cmd_test.go b/billing_cmd_test.go
index 3375aa578..f49b6f1f5 100644
--- a/billing_cmd_test.go
+++ b/billing_cmd_test.go
@@ -4,10 +4,15 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
+ "flag"
"io"
"net/http"
"net/http/httptest"
+ "os"
"path/filepath"
+ "sort"
+ "strconv"
"strings"
"testing"
"time"
@@ -16,41 +21,99 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-// billingConsole is a console that answers the four operator doors with
-// fixed bodies and records what it was asked.
+// billingWire is the operator doors' wire contract, the one list both
+// this test and the console's (internal/console/billingoperatorwire_test.go)
+// read: the console's handlers are held to it there, with real requests,
+// and this client here.
+func billingWire(t *testing.T) map[string][]string {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("internal", "console", "testdata", "billing-operator-wire.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wire map[string][]string
+ if err := json.Unmarshal(raw, &wire); err != nil {
+ t.Fatal(err)
+ }
+ return wire
+}
+
+func bodyKeys(m map[string]any) string {
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return strings.Join(out, ",")
+}
+
+// billingConsole is a console that answers the four operator doors and
+// records what it was asked. Its answers are built from the wire
+// contract's names, as the console's are held to them: a name this
+// client reads differently is an answer it does not find.
type billingConsole struct {
- t *testing.T
- asked []string
- bodies []map[string]any
- bearer string
- resolve func(w http.ResponseWriter)
+ t *testing.T
+ wire map[string][]string
+ asked []string
+ bodies []map[string]any
+ bearer string
+ // retired is the resolve door's retired answer; write, when set,
+ // answers a write in place of the acknowledgement.
+ retired bool
+ write func(w http.ResponseWriter)
+}
+
+// named builds an object with exactly the contract's keys for one shape,
+// taking each value from values.
+func (c *billingConsole) named(shape string, values map[string]any) map[string]any {
+ out := map[string]any{}
+ for _, key := range c.wire[shape] {
+ v, ok := values[key]
+ if !ok {
+ c.t.Fatalf("the fake has no value for %s.%s: the contract gained a key this test does not know", shape, key)
+ }
+ out[key] = v
+ }
+ return out
}
func (c *billingConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.asked = append(c.asked, r.Method+" "+r.URL.Path)
c.bearer = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
+ answer := func(v any) { _ = json.NewEncoder(w).Encode(v) }
switch r.Method + " " + r.URL.Path {
case "GET /api/billing/reviews":
- _, _ = io.WriteString(w, `{"reviews":[
- {"account":"bac","subscription":"ci_1","reason":"billing_checkout_currency_refused","occurrences":2,"created_at":1790000000,"last_seen_at":1790003600,"reference":"cs_test_1",
- "disposition":{"fate":"expired","obligations":["sub_hand"],"retire_held":"a subscription is attached to the purchase (sub_hand), and canceling a subscription is not settling it","keep_held":"its session is not proven complete"}},
- {"account":"bad","subscription":"sub_9","reason":"billing_location_changed","occurrences":1,"created_at":1790000000,"last_seen_at":1790000000}]}`)
+ refusal := c.named("review_row", map[string]any{"account": "bac", "subscription": "ci_1", "reason": "billing_checkout_currency_refused", "occurrences": 2, "created_at": 1790000000, "last_seen_at": 1790003600, "reference": "cs_test_1",
+ "disposition": c.named("disposition", map[string]any{"fate": "expired", "disputed": false, "obligations": []string{"sub_hand"}, "retire_held": "a subscription is attached to the purchase (sub_hand), and canceling a subscription is not settling it", "keep_held": "its session is not proven complete"})})
+ gone := c.named("review_row", map[string]any{"account": "bae", "subscription": "ci_2", "reason": "billing_checkout_currency_refused", "occurrences": 1, "created_at": 1790000000, "last_seen_at": 1790000000, "reference": "cs_test_2",
+ "disposition": c.named("disposition", map[string]any{"fate": "", "disputed": false, "obligations": []string{}, "retire_held": "", "keep_held": c.wire["no_intent_stands"][0]})})
+ unread := map[string]any{"account": "baf", "subscription": "ci_3", "reason": "billing_checkout_currency_refused", "occurrences": 1, "created_at": 1790000000, "last_seen_at": 1790000000}
+ other := map[string]any{"account": "bad", "subscription": "sub_9", "reason": "billing_location_changed", "occurrences": 1, "created_at": 1790000000, "last_seen_at": 1790000000}
+ answer(map[string]any{c.wire["envelopes"][2]: []any{refusal, gone, unread, other}})
case "GET /api/billing/rejected":
- _, _ = io.WriteString(w, `{"rejected":[{"id":"evt_2","type":"customer.subscription.updated","reason":"billing_identity_mismatch","object":"sub_x","subscription":"sub_x","account":"bac","received_at":1790003600}]}`)
+ answer(map[string]any{c.wire["envelopes"][1]: []any{c.named("rejected_row", map[string]any{"id": "evt_2", "type": "customer.subscription.updated", "reason": "billing_identity_mismatch", "object": "sub_x", "subscription": "sub_x", "account": "bac", "received_at": 1790003600})}})
case "GET /api/billing/house":
- _, _ = io.WriteString(w, `{"house":["ops"]}`)
+ answer(map[string]any{c.wire["envelopes"][0]: []string{"ops"}})
case "POST /api/billing/reviews/resolve", "POST /api/billing/house":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
c.t.Errorf("body: %v", err)
}
c.bodies = append(c.bodies, body)
- if c.resolve != nil && r.URL.Path == "/api/billing/reviews/resolve" {
- c.resolve(w)
+ if c.write != nil {
+ c.write(w)
return
}
- _, _ = io.WriteString(w, `{"resolved":true,"retired":true,"house":true}`)
+ if r.URL.Path == "/api/billing/house" {
+ answer(c.named("house_response", map[string]any{"account": body["account"], "house": body["house"]}))
+ return
+ }
+ ack := c.named("resolve_response", map[string]any{"account": body["account"], "subscription": body["subscription"], "reason": body["reason"], "resolved": true, "retired": c.retired})
+ if body["reason"] != c.wire["checkout_refused_reason"][0] {
+ delete(ack, "retired") // the door answers it for a checkout refusal only
+ }
+ answer(ack)
default:
http.NotFound(w, r)
}
@@ -58,19 +121,30 @@ func (c *billingConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func billingTestSession(t *testing.T, c *billingConsole) *apiSession {
t.Helper()
+ c.wire = billingWire(t)
ts := httptest.NewServer(c)
t.Cleanup(ts.Close)
return &apiSession{c: newConsoleClient(ts.URL, "tok_operator"), console: ts.URL, address: "ops@keymail.dev"}
}
+// The client's own constants are the contract's.
+func TestBillingClientConstantsAreTheContracts(t *testing.T) {
+ wire := billingWire(t)
+ if billingCheckoutRefused != wire["checkout_refused_reason"][0] || billingNoIntentStands != wire["no_intent_stands"][0] || strconv.Itoa(billingNoteMax) != wire["note_max"][0] {
+ t.Fatalf("the client's reason %q, no-intent wording %q and note limit %d are not the contract's %v %v %v", billingCheckoutRefused, billingNoIntentStands, billingNoteMax, wire["checkout_refused_reason"], wire["no_intent_stands"], wire["note_max"])
+ }
+}
+
// The listing prints, for each hold, what resolve needs, and for a
// checkout refusal what the console recorded and why each outcome is or
-// is not available, in the console's own words.
+// is not held, in the console's own words; it does not call an outcome
+// available, says when no intent stands, and says when the console sent
+// no disposition for a refusal.
func TestBillingReviewsListing(t *testing.T) {
c := &billingConsole{t: t}
sess := billingTestSession(t, c)
reviews, raw, err := sess.billingReviews(context.Background())
- if err != nil || len(reviews) != 2 || !bytes.Contains(raw, []byte(`"disposition"`)) {
+ if err != nil || len(reviews) != 4 || !bytes.Contains(raw, []byte(`"disposition"`)) {
t.Fatalf("reviews: %+v %v", reviews, err)
}
if c.bearer != "Bearer tok_operator" {
@@ -83,15 +157,20 @@ func TestBillingReviewsListing(t *testing.T) {
"bac ci_1 billing_checkout_currency_refused occurrences 2 last seen " + time.Unix(1790003600, 0).UTC().Format("2006-01-02 15:04"),
"session cs_test_1", "session fate: expired", "attached: sub_hand",
"retire: held: a subscription is attached to the purchase (sub_hand)", "keep: held: its session is not proven complete",
+ "session fate: no intent stands", "retire: not held by what is recorded", "keep: held: no such intent stands",
+ "baf ci_3 billing_checkout_currency_refused", "disposition unavailable",
"bad sub_9 billing_location_changed occurrences 1",
} {
if !strings.Contains(out, want) {
t.Errorf("listing missing %q:\n%s", want, out)
}
}
+ if strings.Contains(out, ": available") {
+ t.Errorf("the listing calls an outcome available, which only the resolution can say:\n%s", out)
+ }
// A hold that is not a checkout refusal has no disposition lines.
- if strings.Count(out, "session fate:") != 1 {
- t.Errorf("disposition printed for a hold that has none:\n%s", out)
+ if strings.Count(out, "session fate:") != 2 || strings.Count(out, "disposition unavailable") != 1 {
+ t.Errorf("disposition lines:\n%s", out)
}
buf.Reset()
printBillingReviews(&buf, nil)
@@ -100,48 +179,167 @@ func TestBillingReviewsListing(t *testing.T) {
}
}
-// resolve sends exactly what was typed: the outcome only when given (the
-// console's default applies otherwise), and the console's refusal comes
-// back as its own words.
-func TestBillingResolveSendsWhatWasAskedAndPrintsWhatWasAnswered(t *testing.T) {
- c := &billingConsole{t: t}
+// What was typed is one unambiguous request or it is refused, before any
+// request: parseBilling has no session to make one with.
+func TestBillingArgumentsAreOneRequestOrRefused(t *testing.T) {
+ long := strings.Repeat("x", billingNoteMax+1)
+ refused := []struct {
+ name string
+ args []string
+ want string
+ }{
+ {"an unquoted note drops its second word and the outcome after it", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", "checked", "provider", "--outcome", "keep"}, `unexpected argument "provider"`},
+ {"set true clear", []string{"house", "--account", "bac", "--note", "audit", "--set", "true", "--clear"}, `unexpected argument "true"`},
+ {"set and clear", []string{"house", "--account", "bac", "--note", "audit", "--set", "--clear"}, "exactly one of --set and --clear"},
+ {"a house write with neither", []string{"house", "--account", "bac", "--note", "audit"}, "exactly one of --set and --clear"},
+ {"a listing that looks filtered", []string{"reviews", "--account", "bac"}, "--account does not apply to billing reviews"},
+ {"json on a resolve", []string{"resolve", "--account", "bac", "--subject", "s", "--reason", "r", "--occurrences", "1", "--note", "n", "--json"}, "--json does not apply to billing resolve"},
+ {"json on a house write", []string{"house", "--account", "bac", "--set", "--note", "n", "--json"}, "--json does not apply to billing house write"},
+ {"an outcome for a reason that has none", []string{"resolve", "--account", "bac", "--subject", "sub_9", "--reason", "billing_location_changed", "--occurrences", "1", "--note", "n", "--outcome", "keep"}, "--outcome applies only to"},
+ {"an outcome that is neither", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "1", "--note", "n", "--outcome", "settle"}, "--outcome is retire or keep"},
+ {"no occurrences", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", "r", "--note", "n"}, "are required"},
+ {"a blank note", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", "r", "--occurrences", "1", "--note", " "}, "--note is required"},
+ {"a note too long", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", "r", "--occurrences", "1", "--note", long}, "at most 256"},
+ {"a note of two lines", []string{"house", "--account", "bac", "--set", "--note", "one\ntwo"}, "plain text on one line"},
+ {"no subcommand", nil, "a subcommand is required"},
+ {"an unknown subcommand", []string{"settle"}, "no such subcommand"},
+ {"the internal form's name", []string{"house write"}, "no such subcommand"},
+ }
+ for _, tc := range refused {
+ if _, err := parseBilling(tc.args); err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("%s: %v; want it refused (%s)", tc.name, err, tc.want)
+ }
+ }
+ // What is sent is what was typed: the outcome only when given, so the
+ // console's own default applies otherwise; --clear sends false.
+ inv, err := parseBilling([]string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", " expired, never paid ", "--console", "https://c.example"})
+ if err != nil || inv.Verb != "resolve" || inv.Console != "https://c.example" || inv.Resolve != (apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: billingCheckoutRefused, Occurrences: 2, Note: "expired, never paid"}) {
+ t.Fatalf("resolve with no outcome: %+v %v", inv, err)
+ }
+ inv, err = parseBilling([]string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", "paid", "--outcome", "keep"})
+ if err != nil || inv.Resolve.Outcome != "keep" {
+ t.Fatalf("resolve with keep: %+v %v", inv, err)
+ }
+ inv, err = parseBilling([]string{"house", "--account", "bac", "--clear", "--note", "left the team"})
+ if err != nil || !inv.HouseWrite || inv.House || inv.HouseAccount != "bac" || inv.HouseNote != "left the team" {
+ t.Fatalf("house clear: %+v %v", inv, err)
+ }
+ inv, err = parseBilling([]string{"house", "--account", "bac", "--set", "--note", "ours"})
+ if err != nil || !inv.HouseWrite || !inv.House {
+ t.Fatalf("house set: %+v %v", inv, err)
+ }
+ inv, err = parseBilling([]string{"house", "--json"})
+ if err != nil || inv.HouseWrite || !inv.JSON {
+ t.Fatalf("house listing: %+v %v", inv, err)
+ }
+ if _, err := parseBilling([]string{"reviews", "--help"}); !errors.Is(err, flag.ErrHelp) {
+ t.Fatalf("--help: %v", err)
+ }
+}
+
+// resolve sends exactly the contract's request, reads the console's
+// acknowledgement, and reports nothing as done without it; the console's
+// refusal comes back as its own words.
+func TestBillingResolveSendsTheContractAndReadsTheAcknowledgement(t *testing.T) {
+ c := &billingConsole{t: t, retired: true}
sess := billingTestSession(t, c)
ctx := context.Background()
- retired, known, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "billing_checkout_currency_refused", Occurrences: 2, Note: "expired, never paid"})
+ asked := apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: billingCheckoutRefused, Occurrences: 2, Note: "expired, never paid"}
+ retired, known, err := sess.billingResolve(ctx, asked)
if err != nil || !retired || !known {
t.Fatalf("resolve: retired %v known %v err %v", retired, known, err)
}
body := c.bodies[0]
- if body["account"] != "bac" || body["subscription"] != "ci_1" || body["reason"] != "billing_checkout_currency_refused" || body["occurrences"] != float64(2) || body["note"] != "expired, never paid" {
+ if body["account"] != "bac" || body["subscription"] != "ci_1" || body["reason"] != billingCheckoutRefused || body["occurrences"] != float64(2) || body["note"] != "expired, never paid" {
t.Fatalf("body %v", body)
}
if _, sent := body["outcome"]; sent {
t.Fatalf("an outcome was sent though none was asked for: %v", body)
}
- if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 2, Note: "n", Outcome: "keep"}); err != nil || c.bodies[1]["outcome"] != "keep" {
- t.Fatalf("keep: %v body %v", err, c.bodies[1])
+ c.retired = false
+ keep := asked
+ keep.Outcome = "keep"
+ if retired, known, err := sess.billingResolve(ctx, keep); err != nil || retired || !known || c.bodies[1]["outcome"] != "keep" {
+ t.Fatalf("keep: retired %v known %v err %v body %v", retired, known, err, c.bodies[1])
+ }
+ if got, want := bodyKeys(c.bodies[1]), strings.Join(c.wire["resolve_request"], ","); got != want {
+ t.Fatalf("the resolve request's keys are %s; the contract says %s", got, want)
}
// Held: the console's message, as it is.
- c.resolve = func(w http.ResponseWriter) {
+ c.write = func(w http.ResponseWriter) {
w.WriteHeader(http.StatusConflict)
_, _ = io.WriteString(w, `{"error":"held: its session is not yet proven expired. the review stays open"}`)
}
- if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 2, Note: "n"}); err == nil || err.Error() != "held: its session is not yet proven expired. the review stays open" {
+ if _, _, err := sess.billingResolve(ctx, asked); err == nil || err.Error() != "held: its session is not yet proven expired. the review stays open" {
t.Fatalf("a held resolution: %v; want the console's own message", err)
}
+ // A 200 that is not the door's acknowledgement is not a resolution.
+ for name, answer := range map[string]string{
+ "an empty object": `{}`,
+ "resolved false": `{"account":"bac","subscription":"ci_1","reason":"billing_checkout_currency_refused","resolved":false,"retired":false}`,
+ "another account": `{"account":"zzz","subscription":"ci_1","reason":"billing_checkout_currency_refused","resolved":true,"retired":true}`,
+ "another subject": `{"account":"bac","subscription":"ci_9","reason":"billing_checkout_currency_refused","resolved":true,"retired":true}`,
+ "no retired for a refusal": `{"account":"bac","subscription":"ci_1","reason":"billing_checkout_currency_refused","resolved":true}`,
+ "a page from something else": `<html><body>welcome</body></html>`,
+ } {
+ c.write = func(w http.ResponseWriter) { _, _ = io.WriteString(w, answer) }
+ if _, _, err := sess.billingResolve(ctx, asked); !errors.Is(err, errBillingUnacknowledged) {
+ t.Errorf("%s: %v; want the write reported as unacknowledged", name, err)
+ }
+ }
// A resolution of another reason has no retired answer.
- c.resolve = func(w http.ResponseWriter) { _, _ = io.WriteString(w, `{"resolved":true}`) }
- if retired, known, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bad", Subject: "sub_9", Reason: "billing_location_changed", Occurrences: 1, Note: "n"}); err != nil || retired || known {
+ c.write = nil
+ other := apiBillingResolution{Account: "bad", Subject: "sub_9", Reason: "billing_location_changed", Occurrences: 1, Note: "n"}
+ if retired, known, err := sess.billingResolve(ctx, other); err != nil || retired || known {
t.Fatalf("another reason: retired %v known %v err %v", retired, known, err)
}
- if got := billingResolvedLine("bac", "ci_1", "r", true, true); !strings.Contains(got, "the intent was retired") {
- t.Errorf("line %q", got)
+ // An answer that never arrives may have been a change.
+ c.write = func(w http.ResponseWriter) {
+ hj, _ := w.(http.Hijacker)
+ conn, _, _ := hj.Hijack()
+ conn.Close()
}
- if got := billingResolvedLine("bac", "ci_1", "r", false, true); !strings.Contains(got, "the intent was kept") {
- t.Errorf("line %q", got)
+ if _, _, err := sess.billingResolve(ctx, asked); err == nil || !strings.Contains(err.Error(), "may have made the change") {
+ t.Fatalf("a dropped answer: %v; want the operator told the change may have been made", err)
}
- if got := billingResolvedLine("bad", "sub_9", "r", false, false); strings.Contains(got, "intent") {
- t.Errorf("line %q", got)
+}
+
+// The line an operator reads after a resolution names the console and
+// says what the console acknowledged in terms of what was asked: a retire
+// that found no intent retired nothing, and is not called a keep.
+func TestBillingResolvedLine(t *testing.T) {
+ refusal := apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: billingCheckoutRefused}
+ keep := refusal
+ keep.Outcome = "keep"
+ explicit := refusal
+ explicit.Outcome = "retire"
+ for _, tc := range []struct {
+ r apiBillingResolution
+ retired, known bool
+ want string
+ }{
+ {refusal, true, true, "on https://c.example: resolved bac ci_1 (billing_checkout_currency_refused): the intent was retired"},
+ {explicit, true, true, "the intent was retired"},
+ {refusal, false, true, "no intent stood, so nothing was retired"},
+ {keep, false, true, "the intent stands, for its purchase to record"},
+ {apiBillingResolution{Account: "bad", Subject: "sub_9", Reason: "billing_location_changed"}, false, false, "on https://c.example: resolved bad sub_9 (billing_location_changed)"},
+ } {
+ got, err := billingResolvedLine("https://c.example", tc.r, tc.retired, tc.known)
+ if err != nil || !strings.Contains(got, tc.want) || strings.Contains(got, "kept") {
+ t.Errorf("%+v retired %v known %v: %q %v; want %q", tc.r, tc.retired, tc.known, got, err, tc.want)
+ }
+ if !tc.known && strings.Contains(got, "intent") {
+ t.Errorf("a reason with no outcome says something about an intent: %q", got)
+ }
+ }
+ if got, err := billingResolvedLine("https://c.example", keep, true, true); err == nil {
+ t.Errorf("a keep answered as retired: %q; want an error", got)
+ }
+ if got := billingHouseLine("https://c.example", "bac", true); got != "on https://c.example: bac is a house account" {
+ t.Errorf("house line %q", got)
+ }
+ if got := billingHouseLine("https://c.example", "bac", false); got != "on https://c.example: bac is not a house account" {
+ t.Errorf("house line %q", got)
}
}
@@ -171,6 +369,47 @@ func TestBillingRejectedAndHouse(t *testing.T) {
if body["account"] != "bac" || body["house"] != false || body["note"] != "left the team" {
t.Fatalf("house body %v", body)
}
+ if got, want := bodyKeys(body), strings.Join(c.wire["house_request"], ","); got != want {
+ t.Fatalf("the house request's keys are %s; the contract says %s", got, want)
+ }
+ // A 200 that does not acknowledge the marker asked for is not a change.
+ for name, answer := range map[string]string{
+ "an empty object": `{}`,
+ "the other marker": `{"account":"bac","house":true}`,
+ "another account": `{"account":"zzz","house":false}`,
+ "a page from something else": `<html><body>welcome</body></html>`,
+ } {
+ c.write = func(w http.ResponseWriter) { _, _ = io.WriteString(w, answer) }
+ if err := sess.billingHouseSet(ctx, "bac", false, "left the team"); !errors.Is(err, errBillingUnacknowledged) {
+ t.Errorf("%s: %v; want the write reported as unacknowledged", name, err)
+ }
+ }
+}
+
+// A plain 404 is the doors' answer to anyone who is not an operator, and
+// a console's from before the doors: it is said as that, with the console
+// and the address, not as advice about --account and --app. The
+// console's own 404 diagnosis passes as it is.
+func TestBillingNotFoundIsSaidAsWhatItIs(t *testing.T) {
+ plain := httptest.NewServer(http.NotFoundHandler())
+ t.Cleanup(plain.Close)
+ sess := &apiSession{c: newConsoleClient(plain.URL, "tok"), console: plain.URL, address: "someone@keymail.dev"}
+ _, _, err := sess.billingReviews(context.Background())
+ if err == nil || !strings.Contains(err.Error(), plain.URL+" has no billing doors for someone@keymail.dev") || strings.Contains(err.Error(), "--app") {
+ t.Fatalf("a plain 404: %v", err)
+ }
+ if err := sess.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || !strings.Contains(err.Error(), "has no billing doors") {
+ t.Fatalf("a plain 404 on a write: %v", err)
+ }
+ c := &billingConsole{t: t}
+ sess = billingTestSession(t, c)
+ c.write = func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = io.WriteString(w, `{"error":"no such billing review"}`)
+ }
+ if _, _, err := sess.billingResolve(context.Background(), apiBillingResolution{Account: "bac", Subject: "s", Reason: "r", Occurrences: 1, Note: "n"}); err == nil || err.Error() != "no such billing review" {
+ t.Fatalf("the console's own 404: %v", err)
+ }
}
// Against the console's own handlers: the doors answer an operator and
@@ -213,11 +452,11 @@ func TestE2EBillingOperatorDoors(t *testing.T) {
sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
ctx := context.Background()
// Not an operator: every door is a 404, which says nothing.
- if _, _, err := sess.billingReviews(ctx); err == nil {
- t.Fatal("reviews answered someone who is not an operator")
+ if _, _, err := sess.billingReviews(ctx); err == nil || !strings.Contains(err.Error(), "has no billing doors for "+addr) {
+ t.Fatalf("reviews for someone who is not an operator: %v; want the doors' plain 404", err)
}
- if err := sess.billingHouseSet(ctx, "ops", true, "x"); err == nil {
- t.Fatal("house set answered someone who is not an operator")
+ if err := sess.billingHouseSet(ctx, "ops", true, "x"); err == nil || !strings.Contains(err.Error(), "has no billing doors for "+addr) {
+ t.Fatalf("house set for someone who is not an operator: %v; want the doors' plain 404", err)
}
if err := cst.SetOperator(addr, true); err != nil {
t.Fatal(err)
@@ -230,7 +469,23 @@ func TestE2EBillingOperatorDoors(t *testing.T) {
t.Fatalf("rejected: %+v %v", rejected, err)
}
// A resolution of a review that does not exist is the console's 404.
- if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "ops", Subject: "sub_none", Reason: "billing_location_changed", Occurrences: 1, Note: "n"}); err == nil {
- t.Fatal("a resolution of no review was accepted")
+ if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "ops", Subject: "sub_none", Reason: "billing_location_changed", Occurrences: 1, Note: "n"}); err == nil || err.Error() != "no such billing review" {
+ t.Fatalf("a resolution of no review: %v; want the console's own 404 (a wrong path or request shape answers something else)", err)
+ }
+ // The house marker, through the real door and read back.
+ if err := cst.CreateAccount(console.OpsAccountID+100, "cdef", "Other", addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := sess.billingHouseSet(ctx, "cdef", true, "ours"); err != nil {
+ t.Fatalf("house set: %v", err)
+ }
+ if house, _, err := sess.billingHouse(ctx); err != nil || len(house) != 1 || house[0] != "cdef" {
+ t.Fatalf("house after set: %v %v", house, err)
+ }
+ if err := sess.billingHouseSet(ctx, "cdef", false, "no longer"); err != nil {
+ t.Fatalf("house clear: %v", err)
+ }
+ if house, _, err := sess.billingHouse(ctx); err != nil || len(house) != 0 {
+ t.Fatalf("house after clear: %v %v", house, err)
}
}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index dba6553c5..a86dd1232 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1254,6 +1254,38 @@ $/GB and absorbed — it appears in nobody's bill.
carlos economics backhaul --period mtd
```
+### carlos billing
+
+The platform operator's view of billing holds, from a terminal. It works
+through a console only, and it decides nothing: it sends what you typed and
+prints what the console answered.
+
+```sh
+carlos billing reviews
+carlos billing resolve --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note "<why>"
+carlos billing rejected
+carlos billing house
+carlos billing house --account <sqid> --set --note "<why>"
+```
+
+`reviews` lists the open review holds, and `resolve` clears one. A hold that
+has recurred since you listed it is refused, so `--occurrences` is the count
+the listing showed. For a refused checkout, `--outcome` is `retire` (the
+default) or `keep`, and the console answers whether what it has recorded
+allows it. A refusal it holds comes back in its own words and stays open.
+
+`rejected` lists the webhook events the console refused, newest first.
+`house` lists the house accounts, or sets or clears the marker on one with
+`--set` or `--clear`.
+
+Every write names the console it acted on. The console is `--console`, else
+`CARLOS_CONSOLE`, else the one your project's `.carlos/config` names, else
+the only one you are logged in to. Quote a note of more than one word: an
+argument the command would otherwise drop is refused before anything is sent.
+
+Operator only, and the console answers everyone else with the same bare 404
+it gives an unknown route.
+
### carlos status
`--live` prints what every box in the fleet last reported about itself:
diff --git a/implementation/carloku-paid-launch/billing-operator.md b/implementation/carloku-paid-launch/billing-operator.md
index b2afa5ba3..c678f390c 100644
--- a/implementation/carloku-paid-launch/billing-operator.md
+++ b/implementation/carloku-paid-launch/billing-operator.md
@@ -1,38 +1,57 @@
# The operator's billing doors, from a terminal
-Written 2026-10-02 on `feat/carloku-billing-operator-cli-20261002` (off the multi-currency branch). Internal: this is not the public CLI reference, and the verb is not in `carlos`'s top-level usage. Billing is OFF everywhere; nothing here has run against a live console.
+Written 2026-10-02 on `feat/carloku-billing-operator-cli-20261002` (off the multi-currency branch); revised the same day after its first review. Internal. The public reference has a short `carlos billing` section (`docs/site/cli.md`), which is new copy and waits for Paul's review before this branch merges. The verb is not in `carlos`'s top-level usage. Billing is OFF everywhere; nothing here has run against a live console.
-`carlos billing` is a thin client for four console API doors that answer only a platform operator (`users.operator`, set on the box with the console's own tooling) and answer 404 to everyone else. It sends what you type and prints what the console answers. It decides nothing.
+`carlos billing` is a client for four console API doors. They answer only a platform operator (`users.operator`, set on the box with the console's own tooling). Any other signed-in address gets a plain 404; no credential, or an expired or revoked one, gets a 401 that says to log in.
+
+The client decides nothing about billing. It does refuse, before sending anything, a command line that is not one unambiguous request: an argument it would otherwise drop (a note typed without quotes loses its second word and every flag after it), a flag the subcommand does not use, a note the console would refuse (empty, over 256 bytes, or more than one line). It reports a write as done only when the console's answer acknowledges it, and says which console it acted on.
```
carlos billing reviews [--console <url>] [--json]
-carlos billing resolve --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note <text> [--outcome retire|keep]
+carlos billing resolve --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note <text> [--outcome retire|keep] [--console <url>]
carlos billing rejected [--console <url>] [--json]
carlos billing house [--console <url>] [--json]
-carlos billing house --account <sqid> (--set | --clear) --note <text>
+carlos billing house --account <sqid> (--set | --clear) --note <text> [--console <url>]
```
+## Which console
+
+`--console`, else `CARLOS_CONSOLE`, else the console a `.carlos/config` above the current directory names, else the only one you are logged in to. With two logins and a project directory that names one of them, a write with no `--console` goes to that one. Sqids are per console: the same sqid can name a different account on another. Every write's confirmation starts `on <console>:`. Read it. For a write, pass `--console`.
+
+With `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` set the verb refuses to run: unset it. Logging in does not change that.
+
## reviews
-One block per open hold. The first line carries what `resolve` needs: the account, the subject (a subscription id; for a checkout refusal, the intent's id), the reason and the occurrences. A hold stops the account's invoices until it is resolved.
+One block per open hold. The first line carries what `resolve` needs: the account, the subject, the reason and the occurrences. The subject is a subscription id; for a checkout refusal it is the intent's id; for a session dispute (`billing_checkout_fate_disputed`) it is the session's id.
-For a checkout refusal (`billing_checkout_currency_refused`) the block also shows what the console has recorded: the session it is about, that session's fate (`expired`, `complete`, or not proven), any subscription attached to the purchase, and for each outcome whether it is available or why it is held, in the console's words.
+What a hold does: while any hold is open on an account, the console authorises no new invoice work for it (no month is frozen, no invoice advanced); it still records the outcome of what the provider has already done. A checkout refusal also stops the account starting any purchase. Resolving one hold releases nothing while another is open on the same account, and repairs nothing about the others.
+
+For a checkout refusal (`billing_checkout_currency_refused`) the block also shows what the console has recorded: the session, its fate (`expired`, `complete`, not proven, or that no intent stands), any subscription attached to the purchase, and for each outcome whether it is held and why, in the console's words. "Not held by what is recorded" is not "will succeed": the resolution reads the provider again and can still refuse. A refusal listed with "disposition unavailable" is one the console could not read its record of; list again.
## resolve
-Clears one hold, and only if it has not recurred since you listed it: `--occurrences` is the number the listing showed, and a hold that has recurred answers that it must be read again. The note is kept with the resolution.
+Resolving is an act, not an acknowledgement. Know what the hold is about first.
+
+- It clears one hold, and only if it has not recurred since you listed it: `--occurrences` is the number the listing showed, and a hold that has recurred answers that it must be read again. The note is kept with the resolution.
+- For a hold on an unsettled quantity operation, resolving it lets the worker mark that operation reviewed and retire it. Settle what was uncertain at the provider before resolving.
+- A session dispute (`billing_checkout_fate_disputed`) cannot be resolved. The console answers that it is held. Nothing adjudicates a dispute yet (decision 3 in `DECISIONS-FOR-PAUL.md`), and the refusal beside it stays held too.
+
+For a checkout refusal the outcome is `retire` (the default) or `keep`. `--outcome` is refused for any other reason.
+
+- **Either outcome can expire the session.** Before it judges the outcome, the console works the refusal toward a proven fate: if the bound session is still open at the provider, it requests its expiry and reads it again. Asking to `keep` an open session expires it and then answers that keep is held.
+- `retire` is accepted for a session the provider shows expired and unpaid, with nothing attached; for an intent that never had a session at all; and for a review whose intent no longer stands (nothing is retired then, and the confirmation says so). While the provider cannot be read the answer is "try again"; while the expiry is not proven it is "held".
+- `keep` is accepted for a completed session, paid or with no payment required, that is the intent's own purchase, with no subscription attached but its own. It leaves the intent standing for the next reconciliation to record the purchase. It does not record it and does not guarantee it: a subscription that is still not the purchase is refused again and the review comes back as a new occurrence.
+- Anything else is **held** and stays under 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 a review opened before the retirement protocol. The answer says which. There is no outcome for these yet; what disposes of them is decisions 1 to 5 in `DECISIONS-FOR-PAUL.md`.
-For a checkout refusal the outcome is `retire` (the default) or `keep`:
+The confirmation says what the console acknowledged in terms of what you asked: the intent was retired; no intent stood, so nothing was retired; or the intent stands for its purchase to record.
-- `retire` is accepted for a session the provider shows expired and unpaid, with nothing attached. The console requests the expiry itself if the session is still open, and answers "try again" while the provider cannot confirm it.
-- `keep` is accepted for a completed, paid session that is the intent's own purchase, with no subscription attached but its own; the purchase then records.
-- Anything else is **held** and stays under 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 (see the `billing_checkout_fate_disputed` hold beside it), and a review opened before the retirement protocol. The answer says which. There is no outcome for these yet; what disposes of them is decided in `DECISIONS-FOR-PAUL.md` (1 to 5).
+Repeating a resolution exactly (the same operator, the same note, the same outcome) answers as the first time. Anything else for an occurrence already resolved is refused, another operator's exact repeat included.
-Repeating a resolution exactly (same note, same outcome) answers as the first time; anything else for an occurrence already resolved is refused.
+If the answer never arrives (a timeout: the console can make three provider calls before it answers), the change may have been made. List again, or repeat the command exactly. A reworded note is not an exact repeat.
## rejected
-The webhook events the console refused as not this console's or not anybody's, newest first, at most 200: the event, its type, the object and subscription it named, and the account where the refusal knew it. These are within the provider's own event window; they are what to look up there.
+The webhook events the console refused as not this console's or not anybody's, newest first, at most 200: the event, its type, the object and subscription it named, and the account where the refusal knew it. Rows are never pruned and the provider keeps an event for thirty days, so an older row's event id may find nothing there; look up the object or the subscription instead.
## house
diff --git a/internal/console/billingoperatorwire_test.go b/internal/console/billingoperatorwire_test.go
index de13ca797..f451343ce 100644
--- a/internal/console/billingoperatorwire_test.go
+++ b/internal/console/billingoperatorwire_test.go
@@ -1,42 +1,182 @@
package console
import (
+ "context"
"encoding/json"
+ "net/http"
+ "os"
"sort"
+ "strconv"
"strings"
"testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
)
-// The operator doors' wire shapes, pinned by key: `carlos billing` (the
-// CLI, package main) decodes these names, and a rename here that it does
-// not follow would print empty columns rather than fail.
-func TestBillingOperatorWireKeys(t *testing.T) {
- keys := func(v any) string {
- raw, err := json.Marshal(v)
- if err != nil {
- t.Fatal(err)
- }
- var m map[string]any
- if err := json.Unmarshal(raw, &m); err != nil {
- t.Fatal(err)
- }
- var out []string
- for k := range m {
- out = append(out, k)
+// The operator doors' wire contract. testdata/billing-operator-wire.json
+// is the one list of the names on the wire, and two tests read it: this
+// one holds the console's handlers to it, with real requests against the
+// real doors, and the CLI's (billing_cmd_test.go, package main) holds
+// `carlos billing` to it. A name changed on either side fails that side's
+// test until the file changes, and the changed file fails the other
+// side's. Without it a renamed request key is silently ignored by the
+// console (an --outcome keep that becomes the default retire), and a
+// renamed response key silently drops what the operator is told.
+func billingWire(t *testing.T) map[string][]string {
+ t.Helper()
+ raw, err := os.ReadFile("testdata/billing-operator-wire.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wire map[string][]string
+ if err := json.Unmarshal(raw, &wire); err != nil {
+ t.Fatal(err)
+ }
+ return wire
+}
+
+func wireKeys(m map[string]any) string {
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return strings.Join(out, ",")
+}
+
+func TestBillingOperatorWireContract(t *testing.T) {
+ wire := billingWire(t)
+ want := func(name string) string { return strings.Join(wire[name], ",") }
+ if got := []string{billingCheckoutRefusedReason}; got[0] != wire["checkout_refused_reason"][0] {
+ t.Errorf("the checkout refusal's reason is %q; the contract says %q", got[0], wire["checkout_refused_reason"][0])
+ }
+ if got := strconv.Itoa(billingReviewNoteMax); got != wire["note_max"][0] {
+ t.Errorf("the note limit is %s; the contract says %s", got, wire["note_max"][0])
+ }
+ if got := (refusalState{}).held("keep"); got != wire["no_intent_stands"][0] {
+ t.Errorf("keep of an intent that is gone is held as %q; the contract says %q", got, wire["no_intent_stands"][0])
+ }
+
+ // A refusal whose session's expiry is proven: listed, then retired
+ // through the door with exactly the contract's request keys.
+ 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)
+ }
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-wire")
+ status, listing := getJSON(t, fx.ts.URL+"/api/billing/reviews", token)
+ if status != http.StatusOK {
+ t.Fatalf("reviews: %d %v", status, listing)
+ }
+ rows, _ := listing["reviews"].([]any)
+ if len(rows) != 1 {
+ t.Fatalf("reviews envelope: %v; want one row under \"reviews\"", listing)
+ }
+ row, _ := rows[0].(map[string]any)
+ if got := wireKeys(row); got != want("review_row") {
+ t.Errorf("review row keys %s; the contract says %s", got, want("review_row"))
+ }
+ // Every disposition key, including the ones a recorded state omits.
+ raw, _ := json.Marshal(refusalDisposition{Fate: "expired", Disputed: true, Obligations: []string{"sub"}, Retire: "x", Keep: "y"})
+ var disposition map[string]any
+ _ = json.Unmarshal(raw, &disposition)
+ if got := wireKeys(disposition); got != want("disposition") {
+ t.Errorf("disposition keys %s; the contract says %s", got, want("disposition"))
+ }
+ request := map[string]any{"account": acct.Sqid, "subscription": intent.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "expired, never paid", "outcome": "retire"}
+ if got := wireKeys(request); got != want("resolve_request") {
+ t.Fatalf("this test's resolve request has keys %s; the contract says %s", got, want("resolve_request"))
+ }
+ status, answer := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", request, token)
+ if status != http.StatusOK {
+ t.Fatalf("resolve: %d %v", status, answer)
+ }
+ if got := wireKeys(answer); got != want("resolve_response") {
+ t.Errorf("resolve response keys %s; the contract says %s", got, want("resolve_response"))
+ }
+ if answer["resolved"] != true || answer["retired"] != true || answer["account"] != acct.Sqid || answer["subscription"] != intent.ID || answer["reason"] != billingCheckoutRefusedReason {
+ t.Errorf("resolve answer %v", answer)
+ }
+
+ // The outcome key is read: keep, under that name, keeps. (A console
+ // that read another name would take the default and retire, or
+ // refuse a complete session's retire.)
+ fx2, f2, acct2 := newCurrencyFixture(t)
+ f2.customerCountry = "GB"
+ if dest, err := fx2.srv.startBillingCheckout(context.Background(), acct2, plans.PlanPersonal, "GB", "", "", f2.now); err != nil || dest == "" {
+ t.Fatalf("first click: dest %q err %v", dest, err)
+ }
+ intent2, _ := fx2.st.billingCheckoutIntent(acct2.ID)
+ f2.mu.Lock()
+ f2.sessionCustomer, f2.failExpire = "cus_other", 100
+ f2.mu.Unlock()
+ _, _ = fx2.srv.startBillingCheckout(context.Background(), acct2, plans.PlanPersonal, "GB", "", "", f2.now.Add(time.Minute))
+ f2.mu.Lock()
+ f2.sessionCustomer, f2.sessionStatus, f2.subscriptionPrice, f2.failExpire = "", "complete", intent2.Price, 0
+ f2.mu.Unlock()
+ token2 := billingOperatorToken(t, fx2, "ops@keymail.dev", "code-billing-op-wire-2")
+ status, answer = postJSON(t, fx2.ts.URL+"/api/billing/reviews/resolve", map[string]any{"account": acct2.Sqid, "subscription": intent2.ID, "reason": billingCheckoutRefusedReason, "occurrences": 1, "note": "paid", "outcome": "keep"}, token2)
+ if status != http.StatusOK || answer["retired"] != false {
+ t.Fatalf("keep: %d %v; want 200 with retired false", status, answer)
+ }
+ if standing, err := fx2.st.billingCheckoutIntent(acct2.ID); err != nil || standing.ID != intent2.ID {
+ t.Fatalf("after keep the intent: %+v %v; want it standing", standing, err)
+ }
+
+ // The rejected listing and its envelope.
+ if err := fx.st.rejectBillingEvent("evt_wire", "customer.subscription.updated", "sub_x", "sub_x", acct.ID, f.now); err != nil {
+ t.Fatal(err)
+ }
+ status, listing = getJSON(t, fx.ts.URL+"/api/billing/rejected", token)
+ rows, _ = listing["rejected"].([]any)
+ if status != http.StatusOK || len(rows) == 0 {
+ t.Fatalf("rejected: %d %v; want rows under \"rejected\"", status, listing)
+ }
+ found := false
+ for _, r := range rows {
+ row, _ := r.(map[string]any)
+ if row["id"] == "evt_wire" {
+ found = true
+ if got := wireKeys(row); got != want("rejected_row") {
+ t.Errorf("rejected row keys %s; the contract says %s", got, want("rejected_row"))
+ }
}
- sort.Strings(out)
- return strings.Join(out, ",")
}
- review := billingReviewRow{Account: "a", Subscription: "s", Reason: "r", Occurrences: 1, CreatedAt: 1, LastSeenAt: 1, Reference: "cs", Disposition: &refusalDisposition{}}
- if got, want := keys(review), "account,created_at,disposition,last_seen_at,occurrences,reason,reference,subscription"; got != want {
- t.Errorf("review row keys %s; want %s", got, want)
+ if !found {
+ t.Errorf("the rejected event is not listed: %v", rows)
+ }
+
+ // The house marker: the request keys, the acknowledgement, the
+ // listing's envelope. On an account with no subscription and no
+ // checkout in progress.
+ if err := fx.st.CreateAccount(acct.ID+100, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ houseRequest := map[string]any{"account": "cdef", "house": true, "note": "ours"}
+ if got := wireKeys(houseRequest); got != want("house_request") {
+ t.Fatalf("this test's house request has keys %s; the contract says %s", got, want("house_request"))
+ }
+ status, answer = postJSON(t, fx.ts.URL+"/api/billing/house", houseRequest, token)
+ if status != http.StatusOK || answer["account"] != "cdef" || answer["house"] != true {
+ t.Fatalf("house set: %d %v", status, answer)
+ }
+ if got := wireKeys(answer); got != want("house_response") {
+ t.Errorf("house response keys %s; the contract says %s", got, want("house_response"))
+ }
+ status, listing = getJSON(t, fx.ts.URL+"/api/billing/house", token)
+ house, _ := listing["house"].([]any)
+ if status != http.StatusOK || len(house) != 1 || house[0] != "cdef" {
+ t.Fatalf("house listing: %d %v; want cdef under \"house\"", status, listing)
}
- disposition := refusalDisposition{Fate: "expired", Disputed: true, Obligations: []string{"sub"}, Retire: "x", Keep: "y"}
- if got, want := keys(disposition), "disputed,fate,keep_held,obligations,retire_held"; got != want {
- t.Errorf("disposition keys %s; want %s", got, want)
+ status, answer = postJSON(t, fx.ts.URL+"/api/billing/house", map[string]any{"account": "cdef", "house": false, "note": "no longer"}, token)
+ if status != http.StatusOK || answer["house"] != false {
+ t.Fatalf("house clear: %d %v", status, answer)
}
- rejected := billingRejectedEventRow{ID: "e", Type: "t", Reason: "r", Object: "o", Subscription: "s", Account: "a", ReceivedAt: 1}
- if got, want := keys(rejected), "account,id,object,reason,received_at,subscription,type"; got != want {
- t.Errorf("rejected row keys %s; want %s", got, want)
+ if got := "house,rejected,reviews"; got != want("envelopes") {
+ t.Errorf("the envelopes asserted above are %s; the contract says %s", got, want("envelopes"))
}
}
diff --git a/internal/console/testdata/billing-operator-wire.json b/internal/console/testdata/billing-operator-wire.json
new file mode 100644
index 000000000..2bc7ae1e1
--- /dev/null
+++ b/internal/console/testdata/billing-operator-wire.json
@@ -0,0 +1,13 @@
+{
+ "envelopes": ["house", "rejected", "reviews"],
+ "review_row": ["account", "created_at", "disposition", "last_seen_at", "occurrences", "reason", "reference", "subscription"],
+ "disposition": ["disputed", "fate", "keep_held", "obligations", "retire_held"],
+ "rejected_row": ["account", "id", "object", "reason", "received_at", "subscription", "type"],
+ "resolve_request": ["account", "note", "occurrences", "outcome", "reason", "subscription"],
+ "resolve_response": ["account", "reason", "resolved", "retired", "subscription"],
+ "house_request": ["account", "house", "note"],
+ "house_response": ["account", "house"],
+ "no_intent_stands": ["no such intent stands"],
+ "checkout_refused_reason": ["billing_checkout_currency_refused"],
+ "note_max": ["256"]
+}