carlos billing: the operator's billing doors from a terminal (reviews, resolve, rejected, house)
OUTSTANDING 18 asks for a carlos verb and documentation for the operator doors. This is a thin client for the four console API doors that answer only a platform operator: it sends what was typed and prints what the console answered, and decides nothing. A checkout refusal's listing shows what the console recorded (the session's fate, what is attached) and why retire or keep is available or held, in the console's words; resolve sends an outcome only when one was given; a held resolution comes back as the console's own message. The verb is not in the top-level usage. Internal documentation in implementation/carloku-paid-launch/billing-operator.md. Tests: against a console that records what it was asked (paths, bearer, bodies, the refusal's text); against the console's own handlers (404 for someone who is not an operator; an operator's empty listings); and a pin of the wire keys in the console package. Three mutants killed (the outcome always sent; the subject under another key; the console's message replaced). The operator-facing strings here have not been reviewed as copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+650
−0
billing_cmd.go+325 −0billing_cmd_test.go+236 −0implementation/carloku-paid-launch/billing-operator.md+43 −0internal/console/billingoperatorwire_test.go+42 −0main.go+4 −0
CI failed — run details
diff --git a/billing_cmd.go b/billing_cmd.go| new file mode 100644 |
| index 000000000..c795b6e6a |
| --- /dev/null |
| +++ b/billing_cmd.go |
| @@ -0,0 +1,325 @@ |
| +// `carlos billing` — the operator's doors onto billing, from a terminal. |
| +// |
| +// Four console API doors exist for a platform operator (users.operator) |
| +// and answer 404 to everyone else (internal/console/billingoperator.go): |
| +// the open review holds, the resolution of one, the webhook events the |
| +// console refused, and the house accounts. This verb is a thin client for |
| +// them: it sends what the operator typed and prints what the console |
| +// answered. It decides nothing. In particular a checkout refusal is |
| +// resolved only as the console's recorded state allows (the retirement |
| +// protocol, internal/console/billingretirement.go); a refusal the console |
| +// holds comes back as its own message, which is printed as it is. |
| +// |
| +// 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" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +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). For a checkout refusal, --outcome is retire |
| +(the default) or keep, and the console answers whether its recorded state |
| +allows it. rejected lists the webhook events the console refused, newest |
| +first. house lists the house accounts, or sets or clears the marker on one. |
| +` |
| + |
| +// 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"` |
| + 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"` |
| +} |
| + |
| +func (s *apiSession) billingGet(ctx context.Context, path string, out any) ([]byte, error) { |
| + status, body, err := s.getJSON(ctx, path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + return body, json.Unmarshal(body, out) |
| +} |
| + |
| +func (s *apiSession) billingReviews(ctx context.Context) ([]apiBillingReview, []byte, error) { |
| + var res struct { |
| + Reviews []apiBillingReview `json:"reviews"` |
| + } |
| + raw, err := s.billingGet(ctx, "/api/billing/reviews", &res) |
| + return res.Reviews, raw, err |
| +} |
| + |
| +func (s *apiSession) billingRejected(ctx context.Context) ([]apiBillingRejected, []byte, error) { |
| + var res struct { |
| + Rejected []apiBillingRejected `json:"rejected"` |
| + } |
| + raw, err := s.billingGet(ctx, "/api/billing/rejected", &res) |
| + return res.Rejected, raw, err |
| +} |
| + |
| +func (s *apiSession) billingHouse(ctx context.Context) ([]string, []byte, error) { |
| + var res struct { |
| + House []string `json:"house"` |
| + } |
| + raw, err := s.billingGet(ctx, "/api/billing/house", &res) |
| + return res.House, 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"` |
| +} |
| + |
| +// billingResolve posts one resolution. retired is the console's answer for |
| +// a checkout refusal (whether the intent was retired); known is false for |
| +// any other reason, which has no such answer. |
| +func (s *apiSession) billingResolve(ctx context.Context, r apiBillingResolution) (retired, known bool, err error) { |
| + status, body, err := s.postJSON(ctx, "/api/billing/reviews/resolve", r) |
| + if err != nil { |
| + return false, false, err |
| + } |
| + if status != http.StatusOK { |
| + return false, false, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Retired *bool `json:"retired"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return false, false, err |
| + } |
| + if res.Retired == nil { |
| + return false, false, nil |
| + } |
| + return *res.Retired, true, nil |
| +} |
| + |
| +func (s *apiSession) billingHouseSet(ctx context.Context, account string, house bool, note string) error { |
| + status, body, err := s.postJSON(ctx, "/api/billing/house", map[string]any{"account": account, "house": house, "note": note}) |
| + if err != nil { |
| + return err |
| + } |
| + if status != http.StatusOK { |
| + return apiErrorFromBody(status, body) |
| + } |
| + return nil |
| +} |
| + |
| +func billingSession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("billing: this verb works through a console; run: carlos auth login --console <url>") |
| + } |
| + return sess |
| +} |
| + |
| +func runBilling(args []string) { |
| + if len(args) == 0 { |
| + log.Fatal(billingUsage) |
| + } |
| + verb, rest := args[0], args[1:] |
| + fs := flag.NewFlagSet("billing "+verb, flag.ExitOnError) |
| + consoleFlag := fs.String("console", "", "console to use (default: the only logged-in one)") |
| + asJSON := fs.Bool("json", false, "print the console's answer as it came") |
| + account := fs.String("account", "", "account sqid") |
| + subject := fs.String("subject", "", "the review's subject: a subscription id, or the intent id for a checkout refusal") |
| + reason := fs.String("reason", "", "the review's reason, as listed") |
| + occurrences := fs.Int64("occurrences", 0, "the occurrences the listing showed") |
| + note := fs.String("note", "", "why, for the record") |
| + outcome := fs.String("outcome", "", "for a checkout refusal: retire | keep") |
| + set := fs.Bool("set", false, "house: set the marker") |
| + clear := fs.Bool("clear", false, "house: clear the marker") |
| + fs.Usage = flagUsage(fs, billingUsage) |
| + fs.Parse(rest) |
| + ctx := context.Background() |
| + |
| + switch verb { |
| + case "reviews": |
| + reviews, raw, err := billingSession(*consoleFlag).billingReviews(ctx) |
| + if err != nil { |
| + log.Fatalf("billing reviews: %v", err) |
| + } |
| + if *asJSON { |
| + fmt.Fprintln(stdout, string(raw)) |
| + return |
| + } |
| + printBillingReviews(stdout, reviews) |
| + case "rejected": |
| + rejected, raw, err := billingSession(*consoleFlag).billingRejected(ctx) |
| + if err != nil { |
| + log.Fatalf("billing rejected: %v", err) |
| + } |
| + if *asJSON { |
| + fmt.Fprintln(stdout, string(raw)) |
| + return |
| + } |
| + printBillingRejected(stdout, rejected) |
| + case "resolve": |
| + if *account == "" || *subject == "" || *reason == "" || *occurrences <= 0 || strings.TrimSpace(*note) == "" { |
| + log.Fatal("billing resolve: --account, --subject, --reason, --occurrences and --note are required\n" + billingUsage) |
| + } |
| + if *outcome != "" && *outcome != "retire" && *outcome != "keep" { |
| + log.Fatal("billing resolve: --outcome is retire or keep") |
| + } |
| + retired, known, err := billingSession(*consoleFlag).billingResolve(ctx, apiBillingResolution{Account: *account, Subject: *subject, Reason: *reason, Occurrences: *occurrences, Note: *note, Outcome: *outcome}) |
| + if err != nil { |
| + log.Fatalf("billing resolve: %v", err) |
| + } |
| + fmt.Fprintln(stdout, billingResolvedLine(*account, *subject, *reason, retired, known)) |
| + case "house": |
| + sess := billingSession(*consoleFlag) |
| + if *set || *clear { |
| + if *set == *clear || *account == "" || strings.TrimSpace(*note) == "" { |
| + log.Fatal("billing house: --account, exactly one of --set and --clear, and --note are required\n" + billingUsage) |
| + } |
| + if err := sess.billingHouseSet(ctx, *account, *set, *note); err != nil { |
| + log.Fatalf("billing house: %v", err) |
| + } |
| + if *set { |
| + fmt.Fprintf(stdout, "%s is a house account\n", *account) |
| + } else { |
| + fmt.Fprintf(stdout, "%s is not a house account\n", *account) |
| + } |
| + return |
| + } |
| + house, raw, err := sess.billingHouse(ctx) |
| + if err != nil { |
| + log.Fatalf("billing house: %v", err) |
| + } |
| + if *asJSON { |
| + fmt.Fprintln(stdout, string(raw)) |
| + return |
| + } |
| + for _, sqid := range house { |
| + fmt.Fprintln(stdout, sqid) |
| + } |
| + if len(house) == 0 { |
| + mutedf(stdout, "no house accounts") |
| + } |
| + default: |
| + log.Fatal(billingUsage) |
| + } |
| +} |
| + |
| +func billingResolvedLine(account, subject, reason string, retired, known bool) string { |
| + line := fmt.Sprintf("resolved %s %s (%s)", account, subject, reason) |
| + if known { |
| + if retired { |
| + line += ": the intent was retired" |
| + } else { |
| + line += ": the intent was kept" |
| + } |
| + } |
| + return line |
| +} |
| + |
| +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.Reference != "" { |
| + fmt.Fprintf(w, " session %s\n", r.Reference) |
| + } |
| + d := r.Disposition |
| + if d == nil { |
| + continue |
| + } |
| + fate := d.Fate |
| + if fate == "" { |
| + fate = "not proven" |
| + } |
| + 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)) |
| + } |
| +} |
| + |
| +func billingAvailability(held string) string { |
| + if held == "" { |
| + return "available" |
| + } |
| + 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..3375aa578 |
| --- /dev/null |
| +++ b/billing_cmd_test.go |
| @@ -0,0 +1,236 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// billingConsole is a console that answers the four operator doors with |
| +// fixed bodies and records what it was asked. |
| +type billingConsole struct { |
| + t *testing.T |
| + asked []string |
| + bodies []map[string]any |
| + bearer string |
| + resolve func(w http.ResponseWriter) |
| +} |
| + |
| +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") |
| + switch r.Method + " " + r.URL.Path { |
| + case "GET /api/billing/reviews": |
| + _, _ = io.WriteString(w, `{"reviews":[ |
| + {"account":"bac","subscription":"ci_1","reason":"billing_checkout_currency_refused","occurrences":2,"created_at":1790000000,"last_seen_at":1790003600,"reference":"cs_test_1", |
| + "disposition":{"fate":"expired","obligations":["sub_hand"],"retire_held":"a subscription is attached to the purchase (sub_hand), and canceling a subscription is not settling it","keep_held":"its session is not proven complete"}}, |
| + {"account":"bad","subscription":"sub_9","reason":"billing_location_changed","occurrences":1,"created_at":1790000000,"last_seen_at":1790000000}]}`) |
| + case "GET /api/billing/rejected": |
| + _, _ = io.WriteString(w, `{"rejected":[{"id":"evt_2","type":"customer.subscription.updated","reason":"billing_identity_mismatch","object":"sub_x","subscription":"sub_x","account":"bac","received_at":1790003600}]}`) |
| + case "GET /api/billing/house": |
| + _, _ = io.WriteString(w, `{"house":["ops"]}`) |
| + case "POST /api/billing/reviews/resolve", "POST /api/billing/house": |
| + var body map[string]any |
| + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| + c.t.Errorf("body: %v", err) |
| + } |
| + c.bodies = append(c.bodies, body) |
| + if c.resolve != nil && r.URL.Path == "/api/billing/reviews/resolve" { |
| + c.resolve(w) |
| + return |
| + } |
| + _, _ = io.WriteString(w, `{"resolved":true,"retired":true,"house":true}`) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| +} |
| + |
| +func billingTestSession(t *testing.T, c *billingConsole) *apiSession { |
| + t.Helper() |
| + ts := httptest.NewServer(c) |
| + t.Cleanup(ts.Close) |
| + return &apiSession{c: newConsoleClient(ts.URL, "tok_operator"), console: ts.URL, address: "ops@keymail.dev"} |
| +} |
| + |
| +// The listing prints, for each hold, what resolve needs, and for a |
| +// checkout refusal what the console recorded and why each outcome is or |
| +// is not available, in the console's own words. |
| +func TestBillingReviewsListing(t *testing.T) { |
| + c := &billingConsole{t: t} |
| + sess := billingTestSession(t, c) |
| + reviews, raw, err := sess.billingReviews(context.Background()) |
| + if err != nil || len(reviews) != 2 || !bytes.Contains(raw, []byte(`"disposition"`)) { |
| + t.Fatalf("reviews: %+v %v", reviews, err) |
| + } |
| + if c.bearer != "Bearer tok_operator" { |
| + t.Fatalf("authorization %q", c.bearer) |
| + } |
| + 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", |
| + "bad sub_9 billing_location_changed occurrences 1", |
| + } { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("listing missing %q:\n%s", want, out) |
| + } |
| + } |
| + // A hold that is not a checkout refusal has no disposition lines. |
| + if strings.Count(out, "session fate:") != 1 { |
| + t.Errorf("disposition printed for a hold that has none:\n%s", out) |
| + } |
| + buf.Reset() |
| + printBillingReviews(&buf, nil) |
| + if !strings.Contains(buf.String(), "no open billing reviews") { |
| + t.Errorf("empty listing: %q", buf.String()) |
| + } |
| +} |
| + |
| +// resolve sends exactly what was typed: the outcome only when given (the |
| +// console's default applies otherwise), and the console's refusal comes |
| +// back as its own words. |
| +func TestBillingResolveSendsWhatWasAskedAndPrintsWhatWasAnswered(t *testing.T) { |
| + c := &billingConsole{t: t} |
| + sess := billingTestSession(t, c) |
| + ctx := context.Background() |
| + retired, known, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "billing_checkout_currency_refused", Occurrences: 2, Note: "expired, never paid"}) |
| + if err != nil || !retired || !known { |
| + t.Fatalf("resolve: retired %v known %v err %v", retired, known, err) |
| + } |
| + body := c.bodies[0] |
| + if body["account"] != "bac" || body["subscription"] != "ci_1" || body["reason"] != "billing_checkout_currency_refused" || body["occurrences"] != float64(2) || body["note"] != "expired, never paid" { |
| + t.Fatalf("body %v", body) |
| + } |
| + if _, sent := body["outcome"]; sent { |
| + t.Fatalf("an outcome was sent though none was asked for: %v", body) |
| + } |
| + if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 2, Note: "n", Outcome: "keep"}); err != nil || c.bodies[1]["outcome"] != "keep" { |
| + t.Fatalf("keep: %v body %v", err, c.bodies[1]) |
| + } |
| + // Held: the console's message, as it is. |
| + c.resolve = func(w http.ResponseWriter) { |
| + w.WriteHeader(http.StatusConflict) |
| + _, _ = io.WriteString(w, `{"error":"held: its session is not yet proven expired. the review stays open"}`) |
| + } |
| + if _, _, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bac", Subject: "ci_1", Reason: "r", Occurrences: 2, Note: "n"}); err == nil || err.Error() != "held: its session is not yet proven expired. the review stays open" { |
| + t.Fatalf("a held resolution: %v; want the console's own message", err) |
| + } |
| + // A resolution of another reason has no retired answer. |
| + c.resolve = func(w http.ResponseWriter) { _, _ = io.WriteString(w, `{"resolved":true}`) } |
| + if retired, known, err := sess.billingResolve(ctx, apiBillingResolution{Account: "bad", Subject: "sub_9", Reason: "billing_location_changed", Occurrences: 1, Note: "n"}); err != nil || retired || known { |
| + t.Fatalf("another reason: retired %v known %v err %v", retired, known, err) |
| + } |
| + if got := billingResolvedLine("bac", "ci_1", "r", true, true); !strings.Contains(got, "the intent was retired") { |
| + t.Errorf("line %q", got) |
| + } |
| + if got := billingResolvedLine("bac", "ci_1", "r", false, true); !strings.Contains(got, "the intent was kept") { |
| + t.Errorf("line %q", got) |
| + } |
| + if got := billingResolvedLine("bad", "sub_9", "r", false, false); strings.Contains(got, "intent") { |
| + t.Errorf("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) |
| + } |
| + 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"} { |
| + 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) |
| + } |
| +} |
| + |
| +// 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 { |
| + t.Fatal("reviews answered someone who is not an operator") |
| + } |
| + if err := sess.billingHouseSet(ctx, "ops", true, "x"); err == nil { |
| + t.Fatal("house set answered someone who is not an operator") |
| + } |
| + 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 { |
| + t.Fatal("a resolution of no review was accepted") |
| + } |
| +} |
diff --git a/implementation/carloku-paid-launch/billing-operator.md b/implementation/carloku-paid-launch/billing-operator.md| new file mode 100644 |
| index 000000000..b2afa5ba3 |
| --- /dev/null |
| +++ b/implementation/carloku-paid-launch/billing-operator.md |
| @@ -0,0 +1,43 @@ |
| +# The operator's billing doors, from a terminal |
| + |
| +Written 2026-10-02 on `feat/carloku-billing-operator-cli-20261002` (off the multi-currency branch). Internal: this is not the public CLI reference, and the verb is not in `carlos`'s top-level usage. Billing is OFF everywhere; nothing here has run against a live console. |
| + |
| +`carlos billing` is a thin client for four console API doors that answer only a platform operator (`users.operator`, set on the box with the console's own tooling) and answer 404 to everyone else. It sends what you type and prints what the console answers. It decides nothing. |
| + |
| +``` |
| +carlos billing reviews [--console <url>] [--json] |
| +carlos billing resolve --account <sqid> --subject <id> --reason <reason> --occurrences <n> --note <text> [--outcome retire|keep] |
| +carlos billing rejected [--console <url>] [--json] |
| +carlos billing house [--console <url>] [--json] |
| +carlos billing house --account <sqid> (--set | --clear) --note <text> |
| +``` |
| + |
| +## reviews |
| + |
| +One block per open hold. The first line carries what `resolve` needs: the account, the subject (a subscription id; for a checkout refusal, the intent's id), the reason and the occurrences. A hold stops the account's invoices until it is resolved. |
| + |
| +For a checkout refusal (`billing_checkout_currency_refused`) the block also shows what the console has recorded: the session it is about, that session's fate (`expired`, `complete`, or not proven), any subscription attached to the purchase, and for each outcome whether it is available or why it is held, in the console's words. |
| + |
| +## resolve |
| + |
| +Clears one hold, and only if it has not recurred since you listed it: `--occurrences` is the number the listing showed, and a hold that has recurred answers that it must be read again. The note is kept with the resolution. |
| + |
| +For a checkout refusal the outcome is `retire` (the default) or `keep`: |
| + |
| +- `retire` is accepted for a session the provider shows expired and unpaid, with nothing attached. The console requests the expiry itself if the session is still open, and answers "try again" while the provider cannot confirm it. |
| +- `keep` is accepted for a completed, paid session that is the intent's own purchase, with no subscription attached but its own; the purchase then records. |
| +- Anything else is **held** and stays under review: a paid session that is not kept, a case a subscription made outside checkout is attached to, a case the provider contradicted itself about (see the `billing_checkout_fate_disputed` hold beside it), and a review opened before the retirement protocol. The answer says which. There is no outcome for these yet; what disposes of them is decided in `DECISIONS-FOR-PAUL.md` (1 to 5). |
| + |
| +Repeating a resolution exactly (same note, same outcome) answers as the first time; anything else for an occurrence already resolved is refused. |
| + |
| +## rejected |
| + |
| +The webhook events the console refused as not this console's or not anybody's, newest first, at most 200: the event, its type, the object and subscription it named, and the account where the refusal knew it. These are within the provider's own event window; they are what to look up there. |
| + |
| +## 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/billingoperatorwire_test.go b/internal/console/billingoperatorwire_test.go| new file mode 100644 |
| index 000000000..de13ca797 |
| --- /dev/null |
| +++ b/internal/console/billingoperatorwire_test.go |
| @@ -0,0 +1,42 @@ |
| +package console |
| + |
| +import ( |
| + "encoding/json" |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The operator doors' wire shapes, pinned by key: `carlos billing` (the |
| +// CLI, package main) decodes these names, and a rename here that it does |
| +// not follow would print empty columns rather than fail. |
| +func TestBillingOperatorWireKeys(t *testing.T) { |
| + keys := func(v any) string { |
| + raw, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m map[string]any |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + for k := range m { |
| + out = append(out, k) |
| + } |
| + sort.Strings(out) |
| + return strings.Join(out, ",") |
| + } |
| + review := billingReviewRow{Account: "a", Subscription: "s", Reason: "r", Occurrences: 1, CreatedAt: 1, LastSeenAt: 1, Reference: "cs", Disposition: &refusalDisposition{}} |
| + if got, want := keys(review), "account,created_at,disposition,last_seen_at,occurrences,reason,reference,subscription"; got != want { |
| + t.Errorf("review row keys %s; want %s", got, want) |
| + } |
| + disposition := refusalDisposition{Fate: "expired", Disputed: true, Obligations: []string{"sub"}, Retire: "x", Keep: "y"} |
| + if got, want := keys(disposition), "disputed,fate,keep_held,obligations,retire_held"; got != want { |
| + t.Errorf("disposition keys %s; want %s", got, want) |
| + } |
| + rejected := billingRejectedEventRow{ID: "e", Type: "t", Reason: "r", Object: "o", Subscription: "s", Account: "a", ReceivedAt: 1} |
| + if got, want := keys(rejected), "account,id,object,reason,received_at,subscription,type"; got != want { |
| + t.Errorf("rejected row keys %s; want %s", got, want) |
| + } |
| +} |
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": |