carlos / platform Public

carlos billing: the third review's twelve P3s (a write names its console; refusals from in front of the console; the moved console through the offer; the whole command tested)

Opus's second reading and Astra's third of 31731fdd: both accept with
fixes, six P3s each. Answered here:

- A write (resolve; house --set or --clear) is refused unless --console
  or CARLOS_CONSOLE names its console; a listing may use the
  configuration's default.
- A refusal with a status and no JSON error (a 502 or 504 from the edge)
  on a write may have been a change, and says so; the console's own
  refusal and a plain 404 are definite.
- The console-moved error survives the interactive offer (wrapped, not
  replaced) and a certificate the client refused is a pre-send failure;
  neither is told "may have made the change".
- Session-selection failures name billing's one route: a console, with
  the deployment-store variables unset.
- runBillingWith is the whole command with the session's source as a
  parameter, and is tested: the console the arguments name is the one
  asked for, a refused line asks for no session, help is help.
- The wire tests use distinct object and subscription ids on both sides
  and assert every decoded value.
- The guide: a checkout refusal stops new purchases only on the standing
  checkout; an exact repeat answers as the first did only while the
  occurrence is current; up to four provider calls before a keep answers.
- The public section's facts corrected (the door can expire an open
  session; a hold on a seat change retires it; no credential gets a 401).
  It is still unreviewed copy and does not ship before Paul reads it.

Not done: a seeded resolution through package main against the real
handlers; main.go's dispatch line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f9683fbe42e4f04e1743be58c5624a4858e88f51 parent 31731fd
6 files changed, +209 −50
  • apimode.go +5 −2
  • billing_cmd.go +72 −22
  • billing_cmd_test.go +98 −2
  • docs/site/cli.md +23 −18
  • implementation/carloku-paid-launch/billing-operator.md +5 −5
  • internal/console/billingoperatorwire_test.go +6 −1
diff --git a/apimode.go b/apimode.go
index 5b7b0cec7..85086aec5 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 fmt.Errorf("%w; logging in to %s failed: %v", e, 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,7 +223,7 @@ func offerConsoleMoved(e *consoleMovedError) error {
}
}
}
- return fmt.Errorf("logged in to %s — re-run your command against it", e.To)
+ return fmt.Errorf("%w; logged in to %s — re-run your command against it", e, e.To)
}
// errNoDeploymentTarget is resolveMode's "nothing configured at all"
diff --git a/billing_cmd.go b/billing_cmd.go
index da50ed0c8..9e6221653 100644
--- a/billing_cmd.go
+++ b/billing_cmd.go
@@ -22,6 +22,7 @@ package main
import (
"context"
+ "crypto/tls"
"encoding/json"
"errors"
"flag"
@@ -52,9 +53,9 @@ 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 says which console it acted on, after it has acted:
-pass --console for a write. Quote a note of more than one word, and give
-no flag twice.
+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
@@ -180,12 +181,13 @@ var errBillingUnacknowledged = errors.New("the console answered without acknowle
// 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, "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; a changed note is not one)")
+ return false, false, billingWriteUnknown(err, next)
}
if status != http.StatusOK {
- return false, false, s.billingError(status, body)
+ return false, false, s.billingWriteError(status, body, next)
}
var res struct {
Account string `json:"account"`
@@ -207,12 +209,13 @@ func (s *apiSession) billingResolve(ctx context.Context, r apiBillingResolution)
}
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, "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)")
+ return billingWriteUnknown(err, next)
}
if status != http.StatusOK {
- return s.billingError(status, body)
+ return s.billingWriteError(status, body, next)
}
var res struct {
Account string `json:"account"`
@@ -224,13 +227,29 @@ func (s *apiSession) billingHouseSet(ctx context.Context, account string, house
return nil
}
+// billingWriteError words a write the console, or something in front of
+// it, refused with a status. The console's own refusal carries a JSON
+// error and is definite: it is passed on as it is. 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.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. Two kinds of error are known not to have changed anything and
-// pass as they are: the console saying it has moved (a definite answer,
-// with its own remedy), and a connection that was never made. 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.
+// 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) {
@@ -240,18 +259,28 @@ func billingWriteUnknown(err error, next string) error {
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)
}
-func billingSession(consoleFlag string) *apiSession {
+// 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 {
- log.Fatal(err)
+ 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 {
- log.Fatal("billing: this verb works through a console, and CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set. unset it to use the console you are logged in to")
+ return 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
+ return sess, nil
}
// billingInvocation is one `carlos billing` command line, understood:
@@ -360,6 +389,13 @@ func parseBilling(args []string) (billingInvocation, error) {
}
}
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 {
@@ -427,19 +463,33 @@ func billingArgsAreUnambiguous(args []string) error {
}
func runBilling(args []string) {
- inv, err := parseBilling(args)
- if err != nil {
+ if err := runBillingWith(args, billingSession, stdout); err != nil {
if errors.Is(err, flag.ErrHelp) {
fmt.Fprint(os.Stderr, billingUsage)
os.Exit(0)
}
- log.Fatalf("billing: %v\n%s", err, billingUsage)
- }
- if err := billingRun(context.Background(), billingSession(inv.Console), inv, stdout); err != nil {
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.
diff --git a/billing_cmd_test.go b/billing_cmd_test.go
index f019ddc0c..459b36279 100644
--- a/billing_cmd_test.go
+++ b/billing_cmd_test.go
@@ -3,15 +3,18 @@ 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"
@@ -93,7 +96,7 @@ func (c *billingConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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": "customer.subscription.updated", "reason": "billing_identity_mismatch", "object": "sub_x", "subscription": "sub_x", "account": "bac", "received_at": 1790003600})}})
+ answer(map[string]any{c.wire["envelopes"][1]: []any{c.named("rejected_row", map[string]any{"id": "evt_2", "type": "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":
@@ -193,6 +196,7 @@ func TestBillingReviewsListing(t *testing.T) {
// What was typed is one unambiguous request or it is refused, before any
// request: parseBilling has no session to make one with.
func TestBillingArgumentsAreOneRequestOrRefused(t *testing.T) {
+ t.Setenv("CARLOS_CONSOLE", "https://c.example")
long := strings.Repeat("x", billingNoteMax+1)
refused := []struct {
name string
@@ -225,6 +229,7 @@ func TestBillingArgumentsAreOneRequestOrRefused(t *testing.T) {
{"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"},
}
@@ -258,6 +263,24 @@ func TestBillingArgumentsAreOneRequestOrRefused(t *testing.T) {
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 {
@@ -286,6 +309,7 @@ func TestBillingArgumentsAreOneRequestOrRefused(t *testing.T) {
// 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) {
@@ -357,6 +381,49 @@ func TestBillingRunSendsWhatWasParsedAndPrintsTheAnswer(t *testing.T) {
}
}
+// 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{
@@ -403,6 +470,14 @@ func TestBillingWriteErrorsSayWhatIsKnown(t *testing.T) {
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)
@@ -411,6 +486,24 @@ func TestBillingWriteErrorsSayWhatIsKnown(t *testing.T) {
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.StatusServiceUnavailable)
+ _, _ = io.WriteString(w, `{"error":"the intent's session could not be read. try again"}`)
+ }
+ if err := sess0.billingHouseSet(context.Background(), "bac", true, "n"); err == nil || err.Error() != "the intent's session could not be read. try again" {
+ t.Errorf("the console's own 503: %v; want it as it is", err)
+ }
// Through the session, against a console that is not there.
gone := httptest.NewServer(http.NotFoundHandler())
url := gone.URL
@@ -551,9 +644,12 @@ func TestBillingRejectedAndHouse(t *testing.T) {
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", "customer.subscription.updated", "billing_identity_mismatch", "object sub_x", "subscription sub_x", "account bac"} {
+ 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())
}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index ce736fbfa..ace76d4c2 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1257,37 +1257,42 @@ carlos economics backhaul --period mtd
### carlos billing
The platform operator's view of billing holds, from a terminal. It works
-through a console only, and it decides nothing: it sends what you typed and
-prints what the console answered.
+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 --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note "<why>"
+carlos billing resolve --console <url> --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note "<why>"
carlos billing rejected
carlos billing house
-carlos billing house --account <sqid> --set --note "<why>"
+carlos billing house --console <url> --account <sqid> --set --note "<why>"
```
-`reviews` lists the open review holds, and `resolve` clears one. A hold that
-has recurred since you listed it is refused, so `--occurrences` is the count
-the listing showed. For a refused checkout, `--outcome` is `retire` (the
-default) or `keep`, and the console answers whether what it has recorded
-allows it. A refusal it holds comes back in its own words and stays open.
+`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`.
-Every write names the console it acted on. 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 you are logged in to. For a
-write, pass `--console`. Quote a note of more than one word and give no flag
-twice: a command line that could mean two things is refused before anything
-is sent.
+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, and the console answers everyone else with the same bare 404
-it gives an unknown route.
+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
diff --git a/implementation/carloku-paid-launch/billing-operator.md b/implementation/carloku-paid-launch/billing-operator.md
index d7ec51365..9fcc46783 100644
--- a/implementation/carloku-paid-launch/billing-operator.md
+++ b/implementation/carloku-paid-launch/billing-operator.md
@@ -1,6 +1,6 @@
# 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 and second 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.
+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.
@@ -16,7 +16,7 @@ carlos billing house --account <sqid> (--set | --clear) --note <text> [--cons
## 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. With two logins and a configuration that names one of them, from anywhere, a write with no `--console` goes to that one. Sqids are per console: the same sqid can name a different account on another. Every write's confirmation starts `on <console>:`, which is after the write. For a write, pass `--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.
@@ -24,7 +24,7 @@ With `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` set the verb refuses
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 also stops the account starting any purchase. Resolving one hold releases nothing while another is open on the same account, and repairs nothing about the others.
+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.
@@ -45,9 +45,9 @@ For a checkout refusal the outcome is `retire` (the default) or `keep`. `--outco
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. Anything else for an occurrence already resolved is refused, another operator's exact repeat included.
+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 three provider calls before it answers), 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.
+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), 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
diff --git a/internal/console/billingoperatorwire_test.go b/internal/console/billingoperatorwire_test.go
index f451343ce..8844ba43c 100644
--- a/internal/console/billingoperatorwire_test.go
+++ b/internal/console/billingoperatorwire_test.go
@@ -128,7 +128,7 @@ func TestBillingOperatorWireContract(t *testing.T) {
}
// The rejected listing and its envelope.
- if err := fx.st.rejectBillingEvent("evt_wire", "customer.subscription.updated", "sub_x", "sub_x", acct.ID, f.now); err != nil {
+ 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)
@@ -144,6 +144,11 @@ func TestBillingOperatorWireContract(t *testing.T) {
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 {