carlos / platform Public

Merge the billing operator CLI (feat/carloku-billing-operator-cli-20261002 at fc7daebd): carlos billing, a client for the four operator doors; four reviews, the last two both accept with fixes answered

Strict parsing with per-form allowlists, writes only with --console, a wire
contract both sides' tests read, advice after a refused write. Gate passed
alone on fc7daebd (01:04Z) and CI passed. Its public reference section in
docs/site/cli.md is unreviewed copy: it must be read before anything
publishes the site's CLI page; merging here publishes nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 1cd5a0d8f8396bb91f0d206db42ee8090a0322ab parent 404e699parent fc7daeb
10 files changed, +1839 −3
  • apimode.go +18 −2
  • apimode_test.go +9 −0
  • billing_cmd.go +681 −0
  • billing_cmd_test.go +824 −0
  • docs/site/cli.md +40 −0
  • implementation/carloku-paid-launch/billing-operator.md +62 −0
  • internal/console/billingoperator.go +1 −1
  • internal/console/billingoperatorwire_test.go +187 −0
  • internal/console/testdata/billing-operator-wire.json +13 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index 5b7b0cec7..86d20006b 100644
--- a/apimode.go
+++ b/apimode.go
@@ -206,7 +206,10 @@ func offerConsoleMoved(e *consoleMovedError) error {
return e
}
if err := authLogin(stdout, []string{"--console", e.To}); err != nil {
- return err
+ // The move is still the fact a caller may need to recognise (a
+ // write that was never carried out); the login's failure is why
+ // nothing more happened.
+ return &consoleMovedAfterOffer{moved: e, msg: fmt.Sprintf("this console has moved: %s -> %s; logging in to %s failed: %v", e.From, e.To, e.To, err)}
}
if cfgConsole, src, err := configDefault(); err == nil && cfgConsole == e.From && src != "" {
// The destination is named in the prompt, not only the file: a y
@@ -220,9 +223,22 @@ func offerConsoleMoved(e *consoleMovedError) error {
}
}
}
- return fmt.Errorf("logged in to %s — re-run your command against it", e.To)
+ return &consoleMovedAfterOffer{moved: e, msg: fmt.Sprintf("logged in to %s — re-run your command against it", e.To)}
+}
+
+// consoleMovedAfterOffer is the console-moved error once the interactive
+// offer has run: worded for someone who has just been through it (no
+// second instruction to log in), and still the move for any caller that
+// asks (errors.As on *consoleMovedError), since a request answered with
+// a move was never carried out.
+type consoleMovedAfterOffer struct {
+ moved *consoleMovedError
+ msg string
}
+func (e *consoleMovedAfterOffer) Error() string { return e.msg }
+func (e *consoleMovedAfterOffer) Unwrap() error { return e.moved }
+
// errNoDeploymentTarget is resolveMode's "nothing configured at all"
// refusal — a var so offerLogin can recognize it by identity rather than
// by matching its wording.
diff --git a/apimode_test.go b/apimode_test.go
index 681094f26..0afdfb175 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -3435,6 +3435,15 @@ func TestConsoleMovedOfferLogsInAndRepointsConfig(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "re-run your command") {
t.Fatalf("offer accepted = %v, want the re-run message", err)
}
+ // Worded for someone who has just logged in (no second instruction to
+ // log in), and still the move for a caller that asks: a request
+ // answered with a move was never carried out.
+ if strings.Contains(err.Error(), "run: carlos auth login") {
+ t.Fatalf("after the login the message still says to log in: %v", err)
+ }
+ if !errors.As(err, new(*consoleMovedError)) {
+ t.Fatalf("after the offer the error is no longer the move: %T %v", err, err)
+ }
// The device flow really ran against the new host…
f, ferr := loadCredentials()
if ferr != nil || f.find(fc.ts.URL) == nil {
diff --git a/billing_cmd.go b/billing_cmd.go
new file mode 100644
index 000000000..ff4c6749e
--- /dev/null
+++ b/billing_cmd.go
@@ -0,0 +1,681 @@
+// `carlos billing` — the operator's doors onto billing, from a terminal.
+//
+// Four console API doors exist for a platform operator (users.operator)
+// 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.
+package main
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+const billingUsage = `usage: carlos billing reviews [--console <url>] [--json]
+ or: carlos billing resolve --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note <text> [--outcome retire|keep] [--console <url>]
+ or: carlos billing rejected [--console <url>] [--json]
+ or: carlos billing house [--console <url>] [--json]
+ or: carlos billing house --account <sqid> (--set | --clear) --note <text> [--console <url>]
+
+Operator only. reviews lists the open billing review holds; resolve clears
+one, and only if it has not recurred since it was listed (--occurrences is
+the count the listing showed). Resolving is an act: a hold on an unsettled
+seat change, once resolved, lets that change be retired. For a checkout
+refusal, --outcome is retire (the default) or keep; the console first works
+the checkout toward a proven outcome, which can expire a session still open
+at the provider even when it then refuses, and answers whether what it has
+recorded allows the outcome. 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 your
+configuration names (the file CARLOS_CONFIG names; else a .carlos/config
+above the current directory; else ~/.carlos/config), else the only one
+logged in. A write (resolve; house --set or --clear) names its console:
+pass --console, or set CARLOS_CONSOLE. Quote a note of more than one word,
+and give no flag twice.
+`
+
+// 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"`
+ Subscription string `json:"subscription"`
+ Reason string `json:"reason"`
+ Occurrences int64 `json:"occurrences"`
+ CreatedAt int64 `json:"created_at"`
+ LastSeenAt int64 `json:"last_seen_at"`
+ Reference string `json:"reference,omitempty"`
+ // AccountDeleted: the account was deleted after the hold opened, or
+ // the hold is evidence that landed after its retirement was
+ // committed. Still a person's to see.
+ AccountDeleted bool `json:"account_deleted"`
+ Disposition *struct {
+ Fate string `json:"fate,omitempty"`
+ Disputed bool `json:"disputed,omitempty"`
+ Obligations []string `json:"obligations,omitempty"`
+ RetireHeld string `json:"retire_held,omitempty"`
+ KeepHeld string `json:"keep_held,omitempty"`
+ } `json:"disposition,omitempty"`
+}
+
+// apiBillingRejected is one webhook event the console refused.
+type apiBillingRejected struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Reason string `json:"reason"`
+ Object string `json:"object"`
+ Subscription string `json:"subscription,omitempty"`
+ Account string `json:"account,omitempty"`
+ 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)
+}
+
+// errBillingNotAListing is a 200 that is not the door's listing: something
+// between here and the console answered, or the console is not the build
+// this client knows. It is not an empty listing.
+var errBillingNotAListing = errors.New("the console's answer is not the listing this command reads. nothing is known from it")
+
+// billingList reads one listing door: a JSON object whose key holds an
+// array. An object without the key, or with anything but an array under
+// it, is refused, not read as "none": an operator told there are no open
+// reviews must be able to rely on it.
+func (s *apiSession) billingList(ctx context.Context, path, key string, out any) ([]byte, error) {
+ status, body, err := s.getJSON(ctx, path)
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, s.billingError(status, body)
+ }
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ return nil, errBillingNotAListing
+ }
+ rows, ok := envelope[key]
+ if !ok || len(rows) == 0 || rows[0] != '[' {
+ return nil, errBillingNotAListing
+ }
+ if err := json.Unmarshal(rows, out); err != nil {
+ return nil, errBillingNotAListing
+ }
+ return body, nil
+}
+
+func (s *apiSession) billingReviews(ctx context.Context) ([]apiBillingReview, []byte, error) {
+ var rows []apiBillingReview
+ raw, err := s.billingList(ctx, "/api/billing/reviews", "reviews", &rows)
+ return rows, raw, err
+}
+
+func (s *apiSession) billingRejected(ctx context.Context) ([]apiBillingRejected, []byte, error) {
+ var rows []apiBillingRejected
+ raw, err := s.billingList(ctx, "/api/billing/rejected", "rejected", &rows)
+ return rows, raw, err
+}
+
+func (s *apiSession) billingHouse(ctx context.Context) ([]string, []byte, error) {
+ var rows []string
+ raw, err := s.billingList(ctx, "/api/billing/house", "house", &rows)
+ return rows, raw, err
+}
+
+// apiBillingResolution is what the operator asked for; outcome is sent
+// only when given, so the console's own default applies otherwise.
+type apiBillingResolution struct {
+ Account string `json:"account"`
+ Subject string `json:"subscription"`
+ Reason string `json:"reason"`
+ Occurrences int64 `json:"occurrences"`
+ Note string `json:"note"`
+ Outcome string `json:"outcome,omitempty"`
+}
+
+// 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) {
+ const next = "list the reviews again, or repeat this exact command on the same console (an exact repeat by the same operator answers as the first did while the occurrence is still the current one; a changed note is not one)"
+ status, body, err := s.postJSON(ctx, "/api/billing/reviews/resolve", r)
+ if err != nil {
+ return false, false, billingWriteUnknown(err, next)
+ }
+ if status != http.StatusOK {
+ return false, false, s.billingWriteError(status, body, next)
+ }
+ var res struct {
+ 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 || res.Resolved == nil || !*res.Resolved || res.Account != r.Account || res.Subject != r.Subject || res.Reason != r.Reason {
+ return false, false, errBillingUnacknowledged
+ }
+ if r.Reason == billingCheckoutRefused {
+ if res.Retired == nil {
+ return false, false, errBillingUnacknowledged
+ }
+ return *res.Retired, true, nil
+ }
+ return false, false, nil
+}
+
+func (s *apiSession) billingHouseSet(ctx context.Context, account string, house bool, note string) error {
+ const next = "list the house accounts before deciding whether to repeat it (a repeat sets the marker to what this command says, whatever anyone has done since)"
+ status, body, err := s.postJSON(ctx, "/api/billing/house", map[string]any{"account": account, "house": house, "note": note})
+ if err != nil {
+ return billingWriteUnknown(err, next)
+ }
+ if status != http.StatusOK {
+ return s.billingWriteError(status, body, next)
+ }
+ 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
+}
+
+// billingWriteError words a write the console, or something in front of
+// it, refused with a status. The console's own refusal below 500 carries
+// a JSON error and is definite: it is passed on as it is; its own 5xx is
+// its words, and may follow a committed transaction. A plain 404 is the
+// doors' answer to anyone who is not an operator, and is definite too.
+// Any other answer with no JSON error (a 502 or 504 from the edge, a
+// page from a proxy) came from something other than the door, after the
+// request went: the console may have carried the write out, and the
+// operator is told so.
+func (s *apiSession) billingWriteError(status int, body []byte, next string) error {
+ if hasJSONError(body) && status >= http.StatusInternalServerError {
+ // The console's own diagnosis of a failure that may have come
+ // after its transaction committed (a resolution recorded, then
+ // the retirement not read back): the words are its, and the
+ // change may stand.
+ return fmt.Errorf("%w. the console may have made the change before it failed: %s", s.billingError(status, body), next)
+ }
+ if hasJSONError(body) || status == http.StatusNotFound {
+ return s.billingError(status, body)
+ }
+ return fmt.Errorf("%s answered %d without the door's own error. the console may have made the change: %s", s.console, status, next)
+}
+
+// billingWriteUnknown words a write that returned an error instead of an
+// answer. Errors known not to have changed anything pass as they are: the
+// console saying it has moved (a definite answer, with its own remedy,
+// whatever the interactive offer that follows it returns), a connection
+// that was never made, and a certificate the client refused before it
+// sent anything. Anything else (a timeout, a connection lost after the
+// request went) may have been a change, and the operator is told so,
+// with what to do for this kind of write.
+func billingWriteUnknown(err error, next string) error {
+ var moved *consoleMovedError
+ if errors.As(err, &moved) {
+ return err
+ }
+ var op *net.OpError
+ if errors.As(err, &op) && op.Op == "dial" {
+ return err
+ }
+ var cert *tls.CertificateVerificationError
+ if errors.As(err, &cert) {
+ return err
+ }
+ return fmt.Errorf("%w. the console may have made the change: %s", err, next)
+}
+
+// billingSession resolves the console for the verb. It is a console or
+// nothing: the direct bucket mode other verbs have is no use here, and
+// the remedies name the one route that is.
+func billingSession(consoleFlag string) (*apiSession, error) {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ if errors.Is(err, errNoDeploymentTarget) || strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
+ return nil, errors.New("billing: this verb works only through a console: unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR and run: carlos auth login --console <url>")
+ }
+ return nil, err
+ }
+ if sess == nil {
+ return nil, errors.New("billing: this verb works only 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, nil
+}
+
+// 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 ASCII control character.
+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):
+ return "", errors.New("--note is not valid text")
+ }
+ // The console's own rule (internal/console: validReviewText): no
+ // control character below U+0020, and not U+007F. That excludes a
+ // tab and a line feed; it is not a promise that the note is one line
+ // in every sense.
+ for _, r := range note {
+ if r < 0x20 || r == 0x7f {
+ return "", errors.New("--note contains a control character (a tab or a line break, perhaps); the console refuses those")
+ }
+ }
+ 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 {
+ 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)
+ }
+ if err := billingArgsAreUnambiguous(args[1:]); err != nil {
+ return inv, err
+ }
+ 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; 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")
+ 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 })
+ // A flag given with nothing in it is not the flag left out: an unset
+ // shell variable in --console "$TARGET" would otherwise fall through
+ // to whichever console is configured, and --outcome "" to the
+ // console's default, which is retire.
+ for name, value := range map[string]string{"console": *consoleFlag, "outcome": *outcome, "account": *account, "subject": *subject, "reason": *reason} {
+ if given[name] && strings.TrimSpace(value) == "" {
+ return inv, fmt.Errorf("--%s was given with no value", name)
+ }
+ }
+ 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
+ // A write names its console: --console, or CARLOS_CONSOLE. The
+ // configuration's default is what a reading may use; a write that
+ // changes an account on whichever console a file happens to name is
+ // not one an operator meant.
+ if (form == "resolve" || form == "house write") && inv.Console == "" && os.Getenv("CARLOS_CONSOLE") == "" {
+ return inv, errors.New("a write names its console: pass --console <url> (or set CARLOS_CONSOLE)")
+ }
+ 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
+}
+
+// billingBoolFlags are the flags that take no value.
+var billingBoolFlags = map[string]bool{"json": true, "set": true, "clear": true, "help": true, "h": true}
+
+// billingArgsAreUnambiguous refuses two things the flag package accepts
+// and that change what is sent. A flag given twice: the second silently
+// wins, so a line that ends "--outcome keep --outcome retire" retires.
+// And a value that looks like a flag, taken as the value of the flag
+// before it: "--set --note --clear" sets the marker with the note
+// "--clear", and "--note --help" writes instead of helping. A value that
+// really does begin with a dash is written --note=<value>.
+func billingArgsAreUnambiguous(args []string) error {
+ seen := map[string]bool{}
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if arg == "--" || !strings.HasPrefix(arg, "-") || arg == "-" {
+ // The first argument that is not a flag ends the flags; what
+ // follows is refused by the caller as unexpected.
+ return nil
+ }
+ name, _, inline := strings.Cut(strings.TrimLeft(arg, "-"), "=")
+ if seen[name] {
+ return fmt.Errorf("--%s was given twice", name)
+ }
+ seen[name] = true
+ if inline || billingBoolFlags[name] {
+ continue
+ }
+ if i+1 < len(args) && strings.HasPrefix(args[i+1], "-") && args[i+1] != "-" {
+ return fmt.Errorf("the value of --%s is %q, which looks like a flag; if it is the value, write --%s=%s", name, args[i+1], name, args[i+1])
+ }
+ i++
+ }
+ return nil
+}
+
+func runBilling(args []string) {
+ if err := runBillingWith(args, billingSession, stdout); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ fmt.Fprint(os.Stderr, billingUsage)
+ os.Exit(0)
+ }
+ log.Fatal(err)
+ }
+}
+
+// runBillingWith is runBilling with the session's source as a parameter:
+// the whole of the command, from the arguments to the answer, with the
+// one thing a test cannot have (a logged-in console) handed in.
+func runBillingWith(args []string, session func(console string) (*apiSession, error), w io.Writer) error {
+ inv, err := parseBilling(args)
+ if err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return err
+ }
+ return fmt.Errorf("billing: %w\n%s", err, billingUsage)
+ }
+ sess, err := session(inv.Console)
+ if err != nil {
+ return err
+ }
+ return billingRun(context.Background(), sess, inv, w)
+}
+
+// billingRun carries out one understood invocation against one console
+// and prints what the console answered. Everything between the parsed
+// command line and the doors is here, so a test can drive it.
+func billingRun(ctx context.Context, sess *apiSession, inv billingInvocation, w io.Writer) error {
+ switch inv.Verb {
+ case "reviews":
+ reviews, raw, err := sess.billingReviews(ctx)
+ if err != nil {
+ return fmt.Errorf("billing reviews on %s: %w", sess.console, err)
+ }
+ if inv.JSON {
+ fmt.Fprintln(w, string(raw))
+ return nil
+ }
+ printBillingReviews(w, reviews)
+ case "rejected":
+ rejected, raw, err := sess.billingRejected(ctx)
+ if err != nil {
+ return fmt.Errorf("billing rejected on %s: %w", sess.console, err)
+ }
+ if inv.JSON {
+ fmt.Fprintln(w, string(raw))
+ return nil
+ }
+ printBillingRejected(w, rejected)
+ case "resolve":
+ retired, known, err := sess.billingResolve(ctx, inv.Resolve)
+ if err != nil {
+ return fmt.Errorf("billing resolve on %s: %w", sess.console, err)
+ }
+ line, err := billingResolvedLine(sess.console, inv.Resolve, retired, known)
+ if err != nil {
+ return fmt.Errorf("billing resolve on %s: %w", sess.console, err)
+ }
+ fmt.Fprintln(w, line)
+ case "house":
+ if inv.HouseWrite {
+ if err := sess.billingHouseSet(ctx, inv.HouseAccount, inv.House, inv.HouseNote); err != nil {
+ return fmt.Errorf("billing house on %s: %w", sess.console, err)
+ }
+ fmt.Fprintln(w, billingHouseLine(sess.console, inv.HouseAccount, inv.House))
+ return nil
+ }
+ house, raw, err := sess.billingHouse(ctx)
+ if err != nil {
+ return fmt.Errorf("billing house on %s: %w", sess.console, err)
+ }
+ if inv.JSON {
+ fmt.Fprintln(w, string(raw))
+ return nil
+ }
+ for _, sqid := range house {
+ fmt.Fprintln(w, sqid)
+ }
+ if len(house) == 0 {
+ mutedf(w, "no house accounts")
+ }
+ default:
+ return fmt.Errorf("billing: no such subcommand: %s", inv.Verb)
+ }
+ return nil
+}
+
+// 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: a retire that
+// found no intent standing retired nothing, which is not a keep; and a
+// keep says what was decided, not that the intent still stands (the
+// answer to an exact repeat is the same after its purchase recorded).
+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":
+ // Not "the intent stands": an exact repeat of a keep answers the
+ // same after the purchase has recorded and the intent has gone.
+ return line + ": kept (nothing was retired)", nil
+ case retired:
+ return line + ": the intent was retired", nil
+ }
+ 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 {
+ if unix <= 0 {
+ return "-"
+ }
+ return time.Unix(unix, 0).UTC().Format("2006-01-02 15:04")
+}
+
+// printBillingReviews prints one block per open hold: what resolve needs
+// on the first line, and for a checkout refusal what the console has
+// recorded and whether each outcome is available, in the console's words.
+func printBillingReviews(w io.Writer, reviews []apiBillingReview) {
+ if len(reviews) == 0 {
+ mutedf(w, "no open billing reviews")
+ return
+ }
+ for _, r := range reviews {
+ fmt.Fprintf(w, "%s %s %s occurrences %d last seen %s\n", dashIfEmpty(r.Account), r.Subscription, r.Reason, r.Occurrences, billingTime(r.LastSeenAt))
+ if r.AccountDeleted {
+ fmt.Fprintf(w, " account deleted: this hold is evidence about a purchase of an account that no longer exists\n")
+ }
+ if r.Reference != "" {
+ fmt.Fprintf(w, " session %s\n", r.Reference)
+ }
+ 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"
+ }
+ fmt.Fprintf(w, " session fate: %s\n", fate)
+ if len(d.Obligations) > 0 {
+ fmt.Fprintf(w, " attached: %s\n", strings.Join(d.Obligations, ", "))
+ }
+ fmt.Fprintf(w, " retire: %s\n", billingAvailability(d.RetireHeld))
+ fmt.Fprintf(w, " keep: %s\n", billingAvailability(d.KeepHeld))
+ }
+}
+
+// billingAvailability words one outcome's state. Not held means not held
+// by what the console has recorded. A keep reads the session then and
+// requires it complete, paid and the intent's own purchase; a retire of a
+// session already recorded expired reads nothing more. Either can still
+// be refused.
+func billingAvailability(held string) string {
+ if held == "" {
+ return "not held by what is recorded (the resolution may still read the provider, and refuse)"
+ }
+ return "held: " + held
+}
+
+func printBillingRejected(w io.Writer, rejected []apiBillingRejected) {
+ if len(rejected) == 0 {
+ mutedf(w, "no rejected billing events")
+ return
+ }
+ for _, e := range rejected {
+ fmt.Fprintf(w, "%s %s %s %s object %s", billingTime(e.ReceivedAt), e.ID, e.Type, e.Reason, dashIfEmpty(e.Object))
+ if e.Subscription != "" {
+ fmt.Fprintf(w, " subscription %s", e.Subscription)
+ }
+ if e.Account != "" {
+ fmt.Fprintf(w, " account %s", e.Account)
+ }
+ fmt.Fprintln(w)
+ }
+}
diff --git a/billing_cmd_test.go b/billing_cmd_test.go
new file mode 100644
index 000000000..8c5ddc3a6
--- /dev/null
+++ b/billing_cmd_test.go
@@ -0,0 +1,824 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// 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
+ 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":
+ refusal := c.named("review_row", map[string]any{"account": "bac", "account_deleted": false, "subscription": "ci_1", "reason": "billing_checkout_currency_refused", "occurrences": 2, "created_at": 1789990000, "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", "account_deleted": true, "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": true, "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":
+ answer(map[string]any{c.wire["envelopes"][1]: []any{c.named("rejected_row", map[string]any{"id": "evt_2", "type": "invoice.paid", "reason": "billing_identity_mismatch", "object": "in_x", "subscription": "sub_x", "account": "bac", "received_at": 1790003600})}})
+ case "GET /api/billing/house":
+ 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.write != nil {
+ c.write(w)
+ return
+ }
+ 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)
+ }
+}
+
+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 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) != 4 || !bytes.Contains(raw, []byte(`"disposition"`)) {
+ t.Fatalf("reviews: %+v %v", reviews, err)
+ }
+ if c.bearer != "Bearer tok_operator" {
+ t.Fatalf("authorization %q", c.bearer)
+ }
+ // Every field the client decodes, against a value that is not its
+ // zero: a tag renamed on the client alone reads as the zero value.
+ first := reviews[0]
+ if first.Account != "bac" || first.Subscription != "ci_1" || first.Reason != billingCheckoutRefused || first.Occurrences != 2 || first.CreatedAt != 1789990000 || first.LastSeenAt != 1790003600 || first.Reference != "cs_test_1" ||
+ first.Disposition == nil || first.Disposition.Fate != "expired" || len(first.Disposition.Obligations) != 1 || first.Disposition.RetireHeld == "" || first.Disposition.KeepHeld == "" {
+ t.Fatalf("the first review as decoded: %+v %+v", first, first.Disposition)
+ }
+ if second := reviews[1]; second.Disposition == nil || !second.Disposition.Disputed || !second.AccountDeleted || first.AccountDeleted {
+ t.Fatalf("the disputed, deleted account's review as decoded: %+v %+v", second, second.Disposition)
+ }
+ var buf bytes.Buffer
+ printBillingReviews(&buf, reviews)
+ out := buf.String()
+ for _, want := range []string{
+ "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, disputed", "account deleted: this hold is evidence about a purchase of an account that no longer exists", "retire: not held by what is recorded (the resolution may still read the provider, and refuse)", "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:") != 2 || strings.Count(out, "disposition unavailable") != 1 || strings.Count(out, "account deleted") != 1 {
+ t.Errorf("disposition lines:\n%s", out)
+ }
+ buf.Reset()
+ printBillingReviews(&buf, nil)
+ if !strings.Contains(buf.String(), "no open billing reviews") {
+ t.Errorf("empty listing: %q", buf.String())
+ }
+}
+
+// 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) {
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
+ 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"}, "control character"},
+ {"a flag taken as the note", []string{"house", "--account", "bac", "--set", "--note", "--clear"}, `the value of --note is "--clear"`},
+ {"help taken as the note", []string{"house", "--account", "bac", "--set", "--note", "--help"}, `the value of --note is "--help"`},
+ {"a flag taken as the account", []string{"resolve", "--account", "--subject", "ci_1", "--reason", "r", "--occurrences", "1", "--note", "n"}, `the value of --account is "--subject"`},
+ {"the outcome given twice", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "1", "--note", "n", "--outcome", "keep", "--outcome", "retire"}, "--outcome was given twice"},
+ {"the account given twice", []string{"house", "--account", "bac", "--account", "bad", "--set", "--note", "n"}, "--account was given twice"},
+ {"the console given twice, once inline", []string{"reviews", "--console=https://a.example", "--console", "https://b.example"}, "--console was given twice"},
+ {"an empty console", []string{"reviews", "--console", ""}, "--console was given with no value"},
+ {"an empty console on a write", []string{"house", "--account", "bac", "--set", "--note", "n", "--console", ""}, "--console was given with no value"},
+ {"an empty outcome", []string{"resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "1", "--note", "n", "--outcome", ""}, "--outcome was given with no value"},
+ {"an empty outcome for another reason", []string{"resolve", "--account", "bac", "--subject", "sub_9", "--reason", "billing_location_changed", "--occurrences", "1", "--note", "n", "--outcome="}, "--outcome was given with no value"},
+ {"a note with a tab", []string{"house", "--account", "bac", "--set", "--note", "one\ttwo"}, "control character"},
+ {"a note that is not text", []string{"house", "--account", "bac", "--set", "--note", "\xff\xfe"}, "not valid text"},
+ {"no subcommand", nil, "a subcommand is required"},
+ {"a value for a boolean flag", []string{"house", "--account", "bac", "--set", "yes", "--note", "n"}, `unexpected argument "yes"`},
+ {"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)
+ }
+ // A write names its console: the flag, or the environment; a listing
+ // may use the configuration's default.
+ t.Setenv("CARLOS_CONSOLE", "")
+ for _, args := range [][]string{
+ {"resolve", "--account", "bac", "--subject", "ci_1", "--reason", "r", "--occurrences", "1", "--note", "n"},
+ {"house", "--account", "bac", "--set", "--note", "n"},
+ } {
+ if _, err := parseBilling(args); err == nil || !strings.Contains(err.Error(), "a write names its console") {
+ t.Errorf("%v with no console named: %v; want it refused", args, err)
+ }
+ if _, err := parseBilling(append(args, "--console", "https://c.example")); err != nil {
+ t.Errorf("%v with --console: %v", args, err)
+ }
+ }
+ if _, err := parseBilling([]string{"reviews"}); err != nil {
+ t.Errorf("a listing with no console named: %v; want it allowed", err)
+ }
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
+ // A value that really begins with a dash is given inline.
+ inv, err = parseBilling([]string{"house", "--account", "bac", "--set", "--note=--clear the old marker"})
+ if err != nil || inv.HouseNote != "--clear the old marker" || !inv.House {
+ t.Fatalf("an inline note beginning with a dash: %+v %v", inv, err)
+ }
+ // The note's limit is the console's: bytes, at most 256, and its own
+ // excluded characters (below U+0020, and U+007F), no others.
+ exact := strings.Repeat("é", billingNoteMax/2)
+ if inv, err = parseBilling([]string{"house", "--account", "bac", "--set", "--note", exact}); err != nil || inv.HouseNote != exact {
+ t.Fatalf("a note of exactly the limit in bytes: %v", err)
+ }
+ if _, err = parseBilling([]string{"house", "--account", "bac", "--set", "--note", exact + "é"}); err == nil || !strings.Contains(err.Error(), "at most 256") {
+ t.Fatalf("a note two bytes over the limit: %v", err)
+ }
+ for name, note := range map[string]string{"a C1 control": "one\u0085two", "a line separator": "one\u2028two"} {
+ if _, err := parseBilling([]string{"house", "--account", "bac", "--set", "--note", note}); err != nil {
+ t.Errorf("a note with %s, which the console accepts: %v", name, err)
+ }
+ }
+ if _, err = parseBilling([]string{"house", "--account", "bac", "--set", "--note", "one\x7ftwo"}); err == nil {
+ t.Fatal("a note with U+007F was accepted")
+ }
+}
+
+// billingRun connects a parsed command line to the doors: what the parser
+// understood is what is sent, on the session it was given, and what is
+// printed is the console's answer.
+func TestBillingRunSendsWhatWasParsedAndPrintsTheAnswer(t *testing.T) {
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
+ c := &billingConsole{t: t}
+ sess := billingTestSession(t, c)
+ run := func(args ...string) (string, error) {
+ t.Helper()
+ inv, err := parseBilling(args)
+ if err != nil {
+ t.Fatalf("parse %v: %v", args, err)
+ }
+ var buf bytes.Buffer
+ err = billingRun(context.Background(), sess, inv, &buf)
+ return buf.String(), err
+ }
+ out, err := run("house", "--account", "bac", "--clear", "--note", "left the team")
+ if err != nil || out != "on "+sess.console+": bac is not a house account\n" {
+ t.Fatalf("house clear: %q %v", out, err)
+ }
+ if body := c.bodies[len(c.bodies)-1]; body["house"] != false || body["account"] != "bac" || body["note"] != "left the team" {
+ t.Fatalf("house clear sent %v", body)
+ }
+ out, err = run("house", "--account", "bac", "--set", "--note", "ours")
+ if err != nil || out != "on "+sess.console+": bac is a house account\n" || c.bodies[len(c.bodies)-1]["house"] != true {
+ t.Fatalf("house set: %q %v %v", out, err, c.bodies[len(c.bodies)-1])
+ }
+ out, err = run("resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", "paid", "--outcome", "keep")
+ if err != nil || out != "on "+sess.console+": resolved bac ci_1 ("+billingCheckoutRefused+"): kept (nothing was retired)\n" {
+ t.Fatalf("keep: %q %v", out, err)
+ }
+ if body := c.bodies[len(c.bodies)-1]; body["outcome"] != "keep" || body["subscription"] != "ci_1" || body["occurrences"] != float64(2) {
+ t.Fatalf("keep sent %v", body)
+ }
+ c.retired = true
+ out, err = run("resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", "expired")
+ if err != nil || !strings.HasSuffix(out, ": the intent was retired\n") {
+ t.Fatalf("retire: %q %v", out, err)
+ }
+ if _, sent := c.bodies[len(c.bodies)-1]["outcome"]; sent {
+ t.Fatalf("retire with no --outcome sent one: %v", c.bodies[len(c.bodies)-1])
+ }
+ writes := len(c.bodies)
+ out, err = run("reviews", "--json")
+ var listing map[string]any
+ if err != nil || json.Unmarshal([]byte(out), &listing) != nil || listing["reviews"] == nil {
+ t.Fatalf("reviews --json: %q %v", out, err)
+ }
+ if out, err = run("reviews"); err != nil || !strings.Contains(out, "bac ci_1") || strings.HasPrefix(strings.TrimSpace(out), "{") {
+ t.Fatalf("reviews: %q %v", out, err)
+ }
+ if out, err = run("rejected"); err != nil || !strings.Contains(out, time.Unix(1790003600, 0).UTC().Format("2006-01-02 15:04")+" evt_2") {
+ t.Fatalf("rejected: %q %v; want the row with the time it was received", out, err)
+ }
+ if out, err = run("house"); err != nil || out != "ops\n" {
+ t.Fatalf("house: %q %v", out, err)
+ }
+ if out, err = run("house", "--json"); err != nil || !strings.Contains(out, `"house"`) {
+ t.Fatalf("house --json: %q %v", out, err)
+ }
+ if out, err = run("rejected", "--json"); err != nil || json.Unmarshal([]byte(out), &listing) != nil || listing["rejected"] == nil {
+ t.Fatalf("rejected --json: %q %v", out, err)
+ }
+ if len(c.bodies) != writes {
+ t.Fatalf("a listing made %d writes", len(c.bodies)-writes)
+ }
+ // A refusal by the console is this command's failure, named with the
+ // console, and prints nothing as done.
+ 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"}`)
+ }
+ out, err = run("resolve", "--account", "bac", "--subject", "ci_1", "--reason", billingCheckoutRefused, "--occurrences", "2", "--note", "x")
+ if err == nil || out != "" || err.Error() != "billing resolve on "+sess.console+": held: its session is not yet proven expired. the review stays open" {
+ t.Fatalf("a held resolution: %q %v", out, err)
+ }
+}
+
+// The whole command, from the arguments to the answer: the console the
+// arguments name is the one asked for, a write is carried out on it, a
+// refusal of the arguments asks for no session, and help is help.
+func TestBillingCommandAsksForTheConsoleItWasGivenAndNothingElse(t *testing.T) {
+ c := &billingConsole{t: t}
+ real := billingTestSession(t, c)
+ var asked []string
+ session := func(console string) (*apiSession, error) {
+ asked = append(asked, console)
+ if console == "" {
+ return nil, errors.New("the configured console")
+ }
+ return real, nil
+ }
+ var out bytes.Buffer
+ err := runBillingWith([]string{"house", "--account", "bac", "--clear", "--note", "left", "--console", "https://x.example"}, session, &out)
+ if err != nil || !reflect.DeepEqual(asked, []string{"https://x.example"}) || out.String() != "on "+real.console+": bac is not a house account\n" {
+ t.Fatalf("a write with --console: err %v asked %v out %q", err, asked, out.String())
+ }
+ if body := c.bodies[len(c.bodies)-1]; body["house"] != false {
+ t.Fatalf("sent %v", body)
+ }
+ asked, out = nil, bytes.Buffer{}
+ t.Setenv("CARLOS_CONSOLE", "")
+ if err := runBillingWith([]string{"house", "--account", "bac", "--clear", "--note", "left"}, session, &out); err == nil || len(asked) != 0 || !strings.Contains(err.Error(), "a write names its console") {
+ t.Fatalf("a write with no console named: err %v asked %v", err, asked)
+ }
+ if err := runBillingWith([]string{"house", "--account", "bac", "--set", "--note", "n", "--set"}, session, &out); err == nil || len(asked) != 0 {
+ t.Fatalf("refused arguments asked for a session: err %v asked %v", err, asked)
+ }
+ writes := len(c.bodies)
+ if err := runBillingWith([]string{"reviews"}, session, &out); err == nil || !reflect.DeepEqual(asked, []string{""}) {
+ t.Fatalf("a listing with no console named: err %v asked %v; want the configured console asked for", err, asked)
+ }
+ asked = nil
+ if err := runBillingWith([]string{"reviews", "--help"}, session, &out); !errors.Is(err, flag.ErrHelp) || len(asked) != 0 {
+ t.Fatalf("--help: err %v asked %v", err, asked)
+ }
+ if len(c.bodies) != writes {
+ t.Fatal("a refused command wrote something")
+ }
+}
+
+// A 200 that is not the door's listing is not an empty listing.
+func TestBillingListingsRequireTheirEnvelope(t *testing.T) {
+ for name, body := range map[string]string{
+ "an empty object": `{}`,
+ "null": `null`,
+ "an error under a 200": `{"error":"unavailable"}`,
+ "the key with null": `{"reviews":null,"rejected":null,"house":null}`,
+ "the key with an object": `{"reviews":{},"rejected":{},"house":{}}`,
+ "a page from something else": `<html><body>welcome</body></html>`,
+ } {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, body) }))
+ sess := &apiSession{c: newConsoleClient(srv.URL, "tok"), console: srv.URL, address: "ops@keymail.dev"}
+ if rows, _, err := sess.billingReviews(context.Background()); !errors.Is(err, errBillingNotAListing) {
+ t.Errorf("%s: reviews %v %v; want it refused as not a listing", name, rows, err)
+ }
+ if rows, _, err := sess.billingRejected(context.Background()); !errors.Is(err, errBillingNotAListing) {
+ t.Errorf("%s: rejected %v %v", name, rows, err)
+ }
+ if rows, _, err := sess.billingHouse(context.Background()); !errors.Is(err, errBillingNotAListing) {
+ t.Errorf("%s: house %v %v", name, rows, err)
+ }
+ srv.Close()
+ }
+ // The doors' own empty answers are empty listings.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.WriteString(w, `{"reviews":[],"rejected":[],"house":[]}`)
+ }))
+ defer srv.Close()
+ sess := &apiSession{c: newConsoleClient(srv.URL, "tok"), console: srv.URL, address: "ops@keymail.dev"}
+ if rows, _, err := sess.billingReviews(context.Background()); err != nil || len(rows) != 0 {
+ t.Fatalf("an empty listing: %v %v", rows, err)
+ }
+ if rows, _, err := sess.billingHouse(context.Background()); err != nil || len(rows) != 0 {
+ t.Fatalf("an empty house listing: %v %v", rows, err)
+ }
+}
+
+// A write that errors instead of answering: a console that says it has
+// moved, and a connection never made, changed nothing and say so as they
+// are; anything else may have been a change, with advice that fits the
+// write.
+func TestBillingWriteErrorsSayWhatIsKnown(t *testing.T) {
+ moved := &consoleMovedError{From: "https://old.example", To: "https://new.example"}
+ if err := billingWriteUnknown(moved, "x"); err != error(moved) {
+ t.Fatalf("a console that moved: %v; want it as it is", err)
+ }
+ afterOffer := fmt.Errorf("%w; logged in to %s — re-run your command against it", moved, moved.To)
+ if err := billingWriteUnknown(afterOffer, "x"); err != afterOffer || strings.Contains(err.Error(), "may have made") {
+ t.Fatalf("a console that moved, after the interactive offer: %v; want it as it is", err)
+ }
+ cert := &tls.CertificateVerificationError{Err: errors.New("x509: certificate signed by unknown authority")}
+ if err := billingWriteUnknown(cert, "x"); err != error(cert) {
+ t.Fatalf("a certificate refused before anything was sent: %v; want it as it is", err)
+ }
+ dial := &net.OpError{Op: "dial", Err: errors.New("connection refused")}
+ if err := billingWriteUnknown(dial, "x"); err != error(dial) {
+ t.Fatalf("a connection never made: %v; want it as it is", err)
+ }
+ lost := &net.OpError{Op: "read", Err: errors.New("connection reset")}
+ if err := billingWriteUnknown(lost, "list the house accounts"); err == nil || !strings.Contains(err.Error(), "may have made the change: list the house accounts") || !errors.Is(err, lost) {
+ t.Fatalf("an answer lost: %v", err)
+ }
+ // A refusal with a status and no JSON error, from something in front
+ // of the console, may have been a change; the console's own refusal
+ // and a plain 404 are definite.
+ c0 := &billingConsole{t: t}
+ sess0 := billingTestSession(t, c0)
+ for status, want := range map[int]string{http.StatusBadGateway: "may have made the change: list the house accounts", http.StatusGatewayTimeout: "may have made the change", http.StatusNotFound: "has no billing doors"} {
+ c0.write = func(w http.ResponseWriter) { w.WriteHeader(status); _, _ = io.WriteString(w, "<html>gateway</html>") }
+ if err := sess0.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("a %d with no JSON error on a write: %v; want %q", status, err, want)
+ }
+ }
+ c0.write = func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = io.WriteString(w, `{"error":"the account has a subscription. the house marker can change only when it has none"}`)
+ }
+ if err := sess0.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || err.Error() != "the account has a subscription. the house marker can change only when it has none" {
+ t.Errorf("the console's own 409: %v; want it as it is", err)
+ }
+ // The console's own 5xx is its words, and may follow a committed
+ // transaction: the words, and the advice.
+ c0.write = func(w http.ResponseWriter) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = io.WriteString(w, `{"error":"the retirement could not be read back"}`)
+ }
+ if _, _, err := sess0.billingResolve(context.Background(), apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 1, Note: "n"}); err == nil || !strings.Contains(err.Error(), "the retirement could not be read back") || !strings.Contains(err.Error(), "may have made the change before it failed: list the reviews again") {
+ t.Errorf("the console's own 500 on a resolve: %v; want its words and the advice", err)
+ }
+ // Through the session, against a console that is not there.
+ gone := httptest.NewServer(http.NotFoundHandler())
+ url := gone.URL
+ gone.Close()
+ sess := &apiSession{c: newConsoleClient(url, "tok"), console: url, address: "ops@keymail.dev"}
+ if err := sess.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || strings.Contains(err.Error(), "may have made the change") {
+ t.Fatalf("a house write to a console that is not listening: %v; want the failure with no suggestion that it changed anything", err)
+ }
+ // And against one that drops the connection after the request: the
+ // two writes give their own advice.
+ c := &billingConsole{t: t}
+ sess = billingTestSession(t, c)
+ c.write = func(w http.ResponseWriter) {
+ hj, _ := w.(http.Hijacker)
+ conn, _, _ := hj.Hijack()
+ conn.Close()
+ }
+ if err := sess.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || !strings.Contains(err.Error(), "list the house accounts before deciding") {
+ t.Fatalf("a house write whose answer was lost: %v", err)
+ }
+ if _, _, err := sess.billingResolve(context.Background(), apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 1, Note: "n"}); err == nil || !strings.Contains(err.Error(), "repeat this exact command on the same console") {
+ t.Fatalf("a resolution whose answer was lost: %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()
+ 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"] != 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)
+ }
+ 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.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, 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}`,
+ "another reason": `{"account":"bac","subscription":"ci_1","reason":"billing_location_changed","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.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)
+ }
+ // 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 _, _, 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)
+ }
+}
+
+// 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, "): kept (nothing was retired)"},
+ {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, "stands") || (tc.r.Outcome != "keep" && 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)
+ }
+}
+
+func TestBillingRejectedAndHouse(t *testing.T) {
+ c := &billingConsole{t: t}
+ sess := billingTestSession(t, c)
+ ctx := context.Background()
+ rejected, _, err := sess.billingRejected(ctx)
+ if err != nil || len(rejected) != 1 {
+ t.Fatalf("rejected: %+v %v", rejected, err)
+ }
+ if e := rejected[0]; e.ID != "evt_2" || e.Type != "invoice.paid" || e.Reason != "billing_identity_mismatch" || e.Object != "in_x" || e.Subscription != "sub_x" || e.Account != "bac" || e.ReceivedAt != 1790003600 {
+ t.Fatalf("the rejected event as decoded: %+v", e)
+ }
+ var buf bytes.Buffer
+ printBillingRejected(&buf, rejected)
+ for _, want := range []string{"evt_2", "invoice.paid", "billing_identity_mismatch", "object in_x", "subscription sub_x", "account bac"} {
+ if !strings.Contains(buf.String(), want) {
+ t.Errorf("rejected listing missing %q: %s", want, buf.String())
+ }
+ }
+ house, _, err := sess.billingHouse(ctx)
+ if err != nil || len(house) != 1 || house[0] != "ops" {
+ t.Fatalf("house: %v %v", house, err)
+ }
+ if err := sess.billingHouseSet(ctx, "bac", false, "left the team"); err != nil {
+ t.Fatal(err)
+ }
+ body := c.bodies[len(c.bodies)-1]
+ 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}`,
+ "no marker at all": `{"account":"bac"}`,
+ "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)
+ }
+ }
+}
+
+// The session: billing's one route is a console, and the refusals say so.
+func TestBillingSessionNamesItsOneRoute(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ // No configuration of this machine's reaches the test: not the home
+ // directory's, not one above the working directory.
+ empty := filepath.Join(t.TempDir(), "config")
+ if err := os.WriteFile(empty, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_CONFIG", empty)
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+ if _, err := billingSession(""); err == nil || !strings.Contains(err.Error(), "unset it to use the console you are logged in to") {
+ t.Fatalf("direct mode: %v", err)
+ }
+ if _, err := billingSession("https://c.example"); err == nil || !strings.Contains(err.Error(), "works only through a console") || !strings.Contains(err.Error(), "unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR") {
+ t.Fatalf("--console with direct mode set: %v", err)
+ }
+ clearDeploymentEnv(t)
+ if _, err := billingSession(""); err == nil || !strings.Contains(err.Error(), "works only through a console") || strings.Contains(err.Error(), "direct bucket access") {
+ t.Fatalf("nothing configured: %v; want billing's own route, not direct-mode advice", 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
+// confirm nothing to anyone else, and the client's shapes are the
+// console's.
+func TestE2EBillingOperatorDoors(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ tmp := t.TempDir()
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{Origin: ts.URL, Keymail: "http://keymail.invalid", Registry: filepath.Join(tmp, "registry.db"),
+ Releases: release.NewDirStore(filepath.Join(tmp, "store")), ReleasePub: pub, Key: priv, AppScheme: "http"}, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const addr = "paul@keymail.dev"
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token, _ := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ 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 || !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 || !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)
+ }
+ reviews, _, err := sess.billingReviews(ctx)
+ if err != nil || len(reviews) != 0 {
+ t.Fatalf("reviews: %+v %v", reviews, err)
+ }
+ if rejected, _, err := sess.billingRejected(ctx); err != nil || len(rejected) != 0 {
+ 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 || 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..ace76d4c2 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1254,6 +1254,46 @@ $/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. It decides nothing about billing: it sends what you
+typed and prints what the console answered, and it refuses a command line
+that could mean two things before anything is sent.
+
+```sh
+carlos billing reviews
+carlos billing resolve --console <url> --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note "<why>"
+carlos billing rejected
+carlos billing house
+carlos billing house --console <url> --account <sqid> --set --note "<why>"
+```
+
+`reviews` lists the open review holds, and `resolve` clears one. Resolving
+is an act, not an acknowledgement: a hold on an unsettled seat change, once
+resolved, lets that change be retired. 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`; the
+console first works the checkout toward a proven outcome, which can mean
+expiring a session still open at the provider, and then answers whether what
+it has recorded allows the outcome. 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`.
+
+A write (`resolve`, `house --set`, `house --clear`) is refused unless
+`--console` or `CARLOS_CONSOLE` names its console, and its confirmation names
+the console it acted on. A listing may use the configuration's default (the
+file `CARLOS_CONFIG` names, else a `.carlos/config` above the current
+directory, else `~/.carlos/config`), else the only console you are logged in
+to. Quote a note of more than one word, and give no flag twice.
+
+Operator only. Any other signed-in address gets the same bare 404 the console
+gives an unknown route; with no credential, or an expired one, you are told
+to log in.
+
### 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
new file mode 100644
index 000000000..724446b2f
--- /dev/null
+++ b/implementation/carloku-paid-launch/billing-operator.md
@@ -0,0 +1,62 @@
+# The operator's billing doors, from a terminal
+
+Written 2026-10-02 on `feat/carloku-billing-operator-cli-20261002` (off the multi-currency branch); revised the same day after its first, second and third reviews. 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 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 flag given twice; a flag given with an empty value (an unset shell variable in `--console "$TARGET"`); a value that looks like a flag (write `--note=--like-this` if it really is the value); a note the console would refuse (empty, over 256 bytes, or containing a control character such as a tab or a line break). 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] [--console <url>]
+carlos billing rejected [--console <url>] [--json]
+carlos billing house [--console <url>] [--json]
+carlos billing house --account <sqid> (--set | --clear) --note <text> [--console <url>]
+```
+
+## Which console
+
+`--console`, else `CARLOS_CONSOLE`, else the console your configuration names (the file `CARLOS_CONFIG` names, and nothing else if that is set; else a `.carlos/config` above the current directory; else `~/.carlos/config`), else the only one you are logged in to. A write (resolve; house --set or --clear) is refused unless `--console` or `CARLOS_CONSOLE` names its console: with two logins, a configuration file would otherwise choose, and a write on the wrong console acts on whatever account that sqid decodes to there. Sqids are per console: the same sqid can name a different account on another. Every write's confirmation starts `on <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, 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.
+
+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 on the account's standing checkout also stops the account starting any purchase (an older refusal whose checkout is gone holds invoices, not new checkouts). 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": a keep reads the session again and can refuse; a retire of a session already recorded expired reads nothing more. A refusal listed with "disposition unavailable" is one the console could not read its record of; list again. A hold marked "account deleted" is evidence about a purchase of an account that no longer exists (it landed after the account's retirement was committed, or the account went since): it holds nothing a person can release in stage 1 and is kept for the record.
+
+## resolve
+
+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). Where the console has to read the provider and cannot, the answer is "try again"; where the expiry is already recorded it reads nothing and the retire goes through; 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`.
+
+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 kept (nothing was retired). "Kept" is the decision, not the present state: an exact repeat of a keep answers the same after the purchase has recorded and the intent has gone.
+
+Repeating a resolution exactly (the same operator, the same note, the same outcome) answers as the first time, while the occurrence it resolved is still the current one. If the hold has recurred since (a keep whose purchase was refused again), the repeat is refused as recurred, and the new occurrence needs its own resolution. Anything else for an occurrence already resolved is refused, another operator's exact repeat included.
+
+If the answer never arrives (a timeout: the console can make up to four provider calls before it answers a keep), or something in front of the console answers with a status and none of the console's own words (a 502 from the edge), or the console's own answer is a 5xx in its own words (a resolution recorded, then the retirement not read back), the change may have been made. For a resolution: list again, or repeat the command exactly, on the same console, as the same operator. A reworded note is not an exact repeat. For the house marker: list the house accounts first. A repeat sets the marker to what the command says whatever anyone has done since. A console that says it has moved, or that could not be reached at all, changed nothing, and the error says that instead.
+
+## 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. 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
+
+Lists the house accounts, or sets or clears the marker on one. The console refuses a change while the account has a subscription that is not over or a checkout in progress, and says so.
+
+## What is not here
+
+A door for a quantity-history gap; a statement rebuild for a closed month; a verb to backfill bucket ownership (`OUTSTANDING.md` 18). The `settled` outcome and the adjudication of a dispute (not built; decisions 1 to 4).
diff --git a/internal/console/billingoperator.go b/internal/console/billingoperator.go
index 4efddb625..cac6449fc 100644
--- a/internal/console/billingoperator.go
+++ b/internal/console/billingoperator.go
@@ -184,7 +184,7 @@ type billingReviewRow struct {
Reference string `json:"reference,omitempty"`
// AccountDeleted: the review is a deleted account's (evidence that
// landed after its retirement was committed). Still a person's to see.
- AccountDeleted bool `json:"account_deleted,omitempty"`
+ AccountDeleted bool `json:"account_deleted"`
// 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"`
diff --git a/internal/console/billingoperatorwire_test.go b/internal/console/billingoperatorwire_test.go
new file mode 100644
index 000000000..8844ba43c
--- /dev/null
+++ b/internal/console/billingoperatorwire_test.go
@@ -0,0 +1,187 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/plans"
+)
+
+// 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", "invoice.paid", "in_wire", "sub_wire", 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"))
+ }
+ // Each value under its own key: an object and a subscription
+ // that differ, so an exchange of the two names shows.
+ if row["object"] != "in_wire" || row["subscription"] != "sub_wire" || row["type"] != "invoice.paid" || row["account"] != acct.Sqid || row["received_at"] != float64(f.now.Unix()) {
+ t.Errorf("rejected row values %v", row)
+ }
+ }
+ }
+ 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)
+ }
+ 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)
+ }
+ 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..43a982aa3
--- /dev/null
+++ b/internal/console/testdata/billing-operator-wire.json
@@ -0,0 +1,13 @@
+{
+ "envelopes": ["house", "rejected", "reviews"],
+ "review_row": ["account", "account_deleted", "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"]
+}
diff --git a/main.go b/main.go
index cb75ccf2d..348a25df2 100644
--- a/main.go
+++ b/main.go
@@ -89,6 +89,10 @@ func main() {
runLogs(args)
case "errors":
runErrors(args)
+ case "billing":
+ // The operator's billing doors (billing_cmd.go). Not in the usage
+ // below: the doors answer 404 to anyone who is not an operator.
+ runBilling(args)
case "analytics":
runAnalytics(args)
case "channels":