carlos / platform Public

Add operator doors for billing review holds and the house marker

Clearing a review hold and marking a house account existed only as store
functions, so every hold was permanent and the house marker could only be
set in the database by hand. Four API doors behind an operator's CLI
token, answering 404 to everyone else: list open reviews, resolve one
against the occurrence count that was read (a recurrence in between is
refused as stale), list house accounts, set or clear the marker. The
marker changes only while the account has no subscription that is not
over and no checkout in flight, and every change appends an immutable
audit row.

Verified: the BillingOperator tests pass. Not verified: no CLI verb or
documentation yet; the doors have not had their own independent review.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b448ee542740b0945b10f478593f680e81d19ad6 parent 99f1da2
4 files changed, +517 −2
  • internal/console/billingoperator.go +300 −0
  • internal/console/billingoperator_test.go +208 −0
  • internal/console/billingstore.go +5 −2
  • internal/console/server.go +4 −0
diff --git a/internal/console/billingoperator.go b/internal/console/billingoperator.go
new file mode 100644
index 000000000..9de084bcd
--- /dev/null
+++ b/internal/console/billingoperator.go
@@ -0,0 +1,300 @@
+package console
+
+import (
+ "database/sql"
+ "errors"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Operator doors for billing (pricing spec step 10 and the review holds).
+//
+// Two things could only be done by hand in the database until now, and one
+// of them could not be done at all without breaking its own audit: clearing
+// a billing review hold (resolveBillingReview had no caller, so every hold
+// was permanent) and marking an account as a house account.
+//
+// Both are API doors behind the CLI token of an operator (users.operator),
+// answering 404 to everyone else so they confirm nothing. Neither calls the
+// payment provider. Both leave an append-only record of who did what.
+//
+// GET /api/billing/reviews open review holds
+// POST /api/billing/reviews/resolve clear one hold, conditional on its occurrence count
+// GET /api/billing/house house accounts
+// POST /api/billing/house set or clear the house marker
+//
+// The house marker may change only while the account has no subscription
+// and no checkout in flight. The marker is snapshotted into every
+// assignment revision, the house coupon is attached at checkout, and a
+// month that mixes house and non-house assignments holds; flipping it under
+// a live subscription would change none of those and leave the history
+// disagreeing with itself.
+
+var (
+ errBillingHouseInvalid = errors.New("billing_house_invalid")
+ errBillingHouseSubscribed = errors.New("billing_house_subscription_exists")
+ errBillingHouseCheckout = errors.New("billing_house_checkout_open")
+)
+
+func (s *Store) migrateBillingOperator() error {
+ for _, stmt := range []string{
+ // One row per change of the house marker, appended in the same
+ // transaction that makes it and never rewritten.
+ `CREATE TABLE IF NOT EXISTS billing_house_changes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL,
+ house INTEGER NOT NULL, actor TEXT NOT NULL, note TEXT NOT NULL, changed_at INTEGER NOT NULL
+ )`,
+ `CREATE TRIGGER IF NOT EXISTS billing_house_changes_no_update BEFORE UPDATE ON billing_house_changes BEGIN SELECT RAISE(ABORT,'billing_house_change_immutable'); END`,
+ `CREATE TRIGGER IF NOT EXISTS billing_house_changes_no_delete BEFORE DELETE ON billing_house_changes BEGIN SELECT RAISE(ABORT,'billing_house_change_immutable'); END`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// openBillingReviews lists every unresolved review hold, oldest first.
+func (s *Store) openBillingReviews() ([]billingReview, error) {
+ rows, err := s.db.Query(`SELECT account_id,subscription,reason,occurrences,created_at,last_seen_at FROM billing_reviews WHERE resolved_at=0 ORDER BY created_at, account_id, subscription, reason`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []billingReview
+ for rows.Next() {
+ var x billingReview
+ if err := rows.Scan(&x.AccountID, &x.Subscription, &x.Reason, &x.Occurrences, &x.CreatedAt, &x.LastSeenAt); err != nil {
+ return nil, err
+ }
+ out = append(out, x)
+ }
+ return out, rows.Err()
+}
+
+// setBillingHouse sets or clears the house marker and appends the audit
+// row, in one transaction. Asking for the state already in place writes
+// nothing. It refuses while a subscription that is not over, or any
+// checkout intent, exists for the account.
+func (s *Store) setBillingHouse(accountID int64, house bool, actor, note string, now time.Time) error {
+ if accountID <= 0 || now.Unix() <= 0 || !validReviewText(actor, billingReviewActorMax) || !validReviewText(note, billingReviewNoteMax) {
+ return errBillingHouseInvalid
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ // Reserve the writer before the reads, as the other billing writers do.
+ if _, err := tx.Exec(`UPDATE billing_accounts SET house=house WHERE account_id=?`, accountID); err != nil {
+ return err
+ }
+ var operator bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM users WHERE address=? AND operator<>0)`, actor).Scan(&operator); err != nil {
+ return err
+ }
+ if !operator {
+ return errBillingReviewActor
+ }
+ var exists bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM accounts WHERE id=?)`, accountID).Scan(&exists); err != nil {
+ return err
+ }
+ if !exists {
+ return errBillingHouseInvalid
+ }
+ var current bool
+ var subscription, status string
+ err = tx.QueryRow(`SELECT house,subscription,status FROM billing_accounts WHERE account_id=?`, accountID).Scan(&current, &subscription, &status)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+ if current == house {
+ return nil
+ }
+ if subscription != "" && status != "canceled" && status != "incomplete_expired" {
+ return errBillingHouseSubscribed
+ }
+ var intent bool
+ if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM billing_checkout_intents WHERE account_id=?)`, accountID).Scan(&intent); err != nil {
+ return err
+ }
+ if intent {
+ return errBillingHouseCheckout
+ }
+ if _, err := tx.Exec(`INSERT INTO billing_accounts(account_id,house) VALUES(?,?) ON CONFLICT(account_id) DO UPDATE SET house=excluded.house`, accountID, house); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`INSERT INTO billing_house_changes(account_id,house,actor,note,changed_at) VALUES(?,?,?,?,?)`, accountID, house, actor, note, now.Unix()); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (s *Store) billingHouseAccounts() ([]int64, error) {
+ rows, err := s.db.Query(`SELECT account_id FROM billing_accounts WHERE house<>0 ORDER BY account_id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []int64
+ for rows.Next() {
+ var id int64
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
+// billingOperator gates the doors: a signed-in CLI user who is an operator.
+// Everyone else gets the same 404 an unknown path gets.
+func (s *Server) billingOperator(w http.ResponseWriter, r *http.Request) (string, bool) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return "", false
+ }
+ isOperator, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: api billing operator: %v", err)
+ }
+ if err != nil || !isOperator {
+ http.NotFound(w, r)
+ return "", false
+ }
+ return addr, true
+}
+
+type billingReviewRow 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"`
+}
+
+func (s *Server) handleAPIBillingReviews(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.billingOperator(w, r); !ok {
+ return
+ }
+ reviews, err := s.store.openBillingReviews()
+ if err != nil {
+ log.Printf("console: api billing reviews: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not read billing reviews. try again")
+ return
+ }
+ out := make([]billingReviewRow, 0, len(reviews))
+ for _, x := range reviews {
+ sqid := ""
+ if acct, err := s.store.AccountByID(x.AccountID); err == nil && acct != nil {
+ sqid = acct.Sqid
+ }
+ out = append(out, billingReviewRow{Account: sqid, Subscription: x.Subscription, Reason: x.Reason, Occurrences: x.Occurrences, CreatedAt: x.CreatedAt, LastSeenAt: x.LastSeenAt})
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"reviews": out})
+}
+
+func (s *Server) handleAPIBillingReviewResolve(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.billingOperator(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ Account string `json:"account"`
+ Subscription string `json:"subscription"`
+ Reason string `json:"reason"`
+ Occurrences int64 `json:"occurrences"`
+ Note string `json:"note"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ acct, err := s.store.AccountBySqid(req.Account)
+ if err != nil || acct == nil {
+ jsonError(w, http.StatusNotFound, "no such billing review")
+ return
+ }
+ err = s.store.resolveBillingReview(billingReviewResolution{AccountID: acct.ID, Subscription: req.Subscription, Reason: req.Reason, Occurrence: req.Occurrences, Actor: addr, Note: strings.TrimSpace(req.Note)}, time.Now())
+ switch {
+ case err == nil:
+ s.audit(acct, "", addr, "billing-review-resolve", req.Reason)
+ writeJSON(w, http.StatusOK, map[string]any{"account": acct.Sqid, "subscription": req.Subscription, "reason": req.Reason, "resolved": true})
+ case errors.Is(err, errBillingReviewInvalid):
+ jsonError(w, http.StatusBadRequest, "account, subscription, reason, occurrences and a note are required")
+ case errors.Is(err, errBillingReviewNotFound):
+ jsonError(w, http.StatusNotFound, "no such billing review")
+ case errors.Is(err, errBillingReviewStale):
+ jsonError(w, http.StatusConflict, "the review recurred since it was read. read it again")
+ case errors.Is(err, errBillingReviewResolved):
+ jsonError(w, http.StatusConflict, "that occurrence was already resolved with a different note")
+ case errors.Is(err, errBillingReviewActor):
+ http.NotFound(w, r)
+ default:
+ log.Printf("console: api billing review resolve: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not resolve the review. try again")
+ }
+}
+
+func (s *Server) handleAPIBillingHouseList(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.billingOperator(w, r); !ok {
+ return
+ }
+ ids, err := s.store.billingHouseAccounts()
+ if err != nil {
+ log.Printf("console: api billing house: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not read house accounts. try again")
+ return
+ }
+ out := make([]string, 0, len(ids))
+ for _, id := range ids {
+ if acct, err := s.store.AccountByID(id); err == nil && acct != nil {
+ out = append(out, acct.Sqid)
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"house": out})
+}
+
+func (s *Server) handleAPIBillingHouseSet(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.billingOperator(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ Account string `json:"account"`
+ House *bool `json:"house"`
+ Note string `json:"note"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ acct, err := s.store.AccountBySqid(req.Account)
+ if err != nil || acct == nil || req.House == nil {
+ jsonError(w, http.StatusBadRequest, "account, house and a note are required")
+ return
+ }
+ err = s.store.setBillingHouse(acct.ID, *req.House, addr, strings.TrimSpace(req.Note), time.Now())
+ switch {
+ case err == nil:
+ action := "billing-house-set"
+ if !*req.House {
+ action = "billing-house-clear"
+ }
+ s.audit(acct, "", addr, action, strings.TrimSpace(req.Note))
+ writeJSON(w, http.StatusOK, map[string]any{"account": acct.Sqid, "house": *req.House})
+ case errors.Is(err, errBillingHouseInvalid):
+ jsonError(w, http.StatusBadRequest, "account, house and a note are required")
+ case errors.Is(err, errBillingHouseSubscribed):
+ jsonError(w, http.StatusConflict, "the account has a subscription. the house marker can change only when it has none")
+ case errors.Is(err, errBillingHouseCheckout):
+ jsonError(w, http.StatusConflict, "the account has a checkout in progress. try again when it has settled")
+ case errors.Is(err, errBillingReviewActor):
+ http.NotFound(w, r)
+ default:
+ log.Printf("console: api billing house set: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not change the house marker. try again")
+ }
+}
diff --git a/internal/console/billingoperator_test.go b/internal/console/billingoperator_test.go
new file mode 100644
index 000000000..9286f53ea
--- /dev/null
+++ b/internal/console/billingoperator_test.go
@@ -0,0 +1,208 @@
+package console
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+)
+
+// rawStatus issues a request and returns only the status: a refused door
+// answers with the mux's plain 404, which is not JSON.
+func rawStatus(t *testing.T, method, u string, body any, bearer string) int {
+ t.Helper()
+ var payload []byte
+ if body != nil {
+ var err error
+ if payload, err = json.Marshal(body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ req, err := http.NewRequest(method, u, bytes.NewReader(payload))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ return res.StatusCode
+}
+
+func billingOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string {
+ t.Helper()
+ if err := fx.st.SetOperator(address, true); err != nil {
+ t.Fatal(err)
+ }
+ return cliToken(t, fx, address, code)
+}
+
+// Everyone who is not an operator gets the 404 an unknown path gets, on
+// every door, and nothing is written.
+func TestBillingOperatorDoorsAnswerOnlyOperators(t *testing.T) {
+ fx := newConsoleFixture(t)
+ member := cliToken(t, fx, "paul@keymail.dev", "code-billing-op-member")
+ for _, u := range []string{"/api/billing/reviews", "/api/billing/house"} {
+ if status := rawStatus(t, http.MethodGet, fx.ts.URL+u, nil, member); status != http.StatusNotFound {
+ t.Errorf("GET %s as a non-operator = %d, want 404", u, status)
+ }
+ if status := rawStatus(t, http.MethodGet, fx.ts.URL+u, nil, ""); status == http.StatusOK {
+ t.Errorf("GET %s signed out = %d", u, status)
+ }
+ }
+ if status := rawStatus(t, http.MethodPost, fx.ts.URL+"/api/billing/house", map[string]any{"account": fx.sqid, "house": true, "note": "founders"}, member); status != http.StatusNotFound {
+ t.Errorf("house set as a non-operator = %d, want 404", status)
+ }
+ if status := rawStatus(t, http.MethodPost, fx.ts.URL+"/api/billing/reviews/resolve", map[string]any{"account": fx.sqid, "subscription": "sub_x", "reason": "r", "occurrences": 1, "note": "n"}, member); status != http.StatusNotFound {
+ t.Errorf("resolve as a non-operator = %d, want 404", status)
+ }
+ if st, err := fx.st.billingAccount(OpsAccountID); err != nil || st.House {
+ t.Fatalf("a refused door changed the marker: %+v %v", st, err)
+ }
+}
+
+// An operator lists open holds, resolves one against the occurrence count
+// they read, and a hold that recurred in between is refused as stale.
+func TestBillingOperatorResolvesAReviewAgainstWhatTheyRead(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-resolve")
+ file := func() {
+ t.Helper()
+ tx, err := fx.st.db.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := fileBillingReviewTx(tx, OpsAccountID, "sub_demo", "billing_clock_unproven", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ file()
+ status, out := getJSON(t, fx.ts.URL+"/api/billing/reviews", token)
+ if status != http.StatusOK {
+ t.Fatalf("list = %d", status)
+ }
+ rows, _ := out["reviews"].([]any)
+ if len(rows) != 1 {
+ t.Fatalf("reviews = %v", out)
+ }
+ row := rows[0].(map[string]any)
+ if row["account"] != fx.sqid || row["reason"] != "billing_clock_unproven" || row["occurrences"].(float64) != 1 {
+ t.Fatalf("row = %v", row)
+ }
+ body := map[string]any{"account": fx.sqid, "subscription": "sub_demo", "reason": "billing_clock_unproven", "occurrences": 1, "note": "clock verified against the provider"}
+
+ // It recurs before the operator acts: the read is stale.
+ file()
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", body, token); status != http.StatusConflict {
+ t.Fatalf("stale resolve = %d, want 409", status)
+ }
+ if open, err := fx.srv.billingReviewOpen(OpsAccountID); err != nil || !open {
+ t.Fatalf("a stale resolution cleared the hold: %v %v", open, err)
+ }
+ // A note is required.
+ noNote := map[string]any{"account": fx.sqid, "subscription": "sub_demo", "reason": "billing_clock_unproven", "occurrences": 2}
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", noNote, token); status != http.StatusBadRequest {
+ t.Fatalf("resolve without a note = %d, want 400", status)
+ }
+ body["occurrences"] = 2
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", body, token); status != http.StatusOK {
+ t.Fatalf("resolve = %d, want 200", status)
+ }
+ if open, err := fx.srv.billingReviewOpen(OpsAccountID); err != nil || open {
+ t.Fatalf("hold still open: %v %v", open, err)
+ }
+ // Repeating the same resolution is a no-op; a different note is not.
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", body, token); status != http.StatusOK {
+ t.Fatalf("repeat = %d, want 200", status)
+ }
+ body["note"] = "a different story"
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", body, token); status != http.StatusConflict {
+ t.Fatalf("rewrite of a resolution = %d, want 409", status)
+ }
+ var audits int
+ if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM billing_review_resolutions WHERE account_id=?`, OpsAccountID).Scan(&audits); err != nil || audits != 1 {
+ t.Fatalf("resolution audit rows = %d %v, want 1", audits, err)
+ }
+ if _, out := getJSON(t, fx.ts.URL+"/api/billing/reviews", token); len(out["reviews"].([]any)) != 0 {
+ t.Fatalf("resolved hold still listed: %v", out)
+ }
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/reviews/resolve", map[string]any{"account": fx.sqid, "subscription": "sub_none", "reason": "nope", "occurrences": 1, "note": "n"}, token); status != http.StatusNotFound {
+ t.Fatalf("unknown review = %d, want 404", status)
+ }
+}
+
+// The house marker is set and cleared with an audit row each time, and
+// cannot change under a subscription or a checkout in flight.
+func TestBillingOperatorHouseMarker(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := billingOperatorToken(t, fx, "ops@keymail.dev", "code-billing-op-house")
+ set := func(house bool, note string) int {
+ t.Helper()
+ status, _ := postJSON(t, fx.ts.URL+"/api/billing/house", map[string]any{"account": fx.sqid, "house": house, "note": note}, token)
+ return status
+ }
+ if status := set(true, ""); status != http.StatusBadRequest {
+ t.Fatalf("house without a note = %d, want 400", status)
+ }
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/house", map[string]any{"account": fx.sqid, "note": "missing flag"}, token); status != http.StatusBadRequest {
+ t.Fatalf("house without the flag = %d, want 400", status)
+ }
+ if status := set(true, "founders' own account"); status != http.StatusOK {
+ t.Fatalf("set = %d", status)
+ }
+ if st, err := fx.st.billingAccount(OpsAccountID); err != nil || !st.House {
+ t.Fatalf("marker not set: %+v %v", st, err)
+ }
+ if status := set(true, "again"); status != http.StatusOK {
+ t.Fatalf("idempotent set = %d", status)
+ }
+ _, out := getJSON(t, fx.ts.URL+"/api/billing/house", token)
+ if list := out["house"].([]any); len(list) != 1 || list[0] != fx.sqid {
+ t.Fatalf("house list = %v", out)
+ }
+ // A checkout in flight pins the marker: it was opened with or without
+ // the house coupon.
+ if _, err := fx.st.db.Exec(`INSERT INTO billing_checkout_intents(account_id,id,plan,terms,band,price,quantity,created_at) VALUES(?,?,?,?,?,?,?,?)`, OpsAccountID, "intent-x", "personal", "t", "A", "price_x", 1, time.Now().Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if status := set(false, "changed our minds"); status != http.StatusConflict {
+ t.Fatalf("clear during a checkout = %d, want 409", status)
+ }
+ if _, err := fx.st.db.Exec(`DELETE FROM billing_checkout_intents WHERE account_id=?`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ // A live subscription pins it too.
+ if _, err := fx.st.db.Exec(`UPDATE billing_accounts SET subscription='sub_live', status='active' WHERE account_id=?`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ if status := set(false, "changed our minds"); status != http.StatusConflict {
+ t.Fatalf("clear under a subscription = %d, want 409", status)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE billing_accounts SET status='canceled' WHERE account_id=?`, OpsAccountID); err != nil {
+ t.Fatal(err)
+ }
+ if status := set(false, "subscription ended"); status != http.StatusOK {
+ t.Fatalf("clear after cancellation = %d", status)
+ }
+ var audits int
+ if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM billing_house_changes WHERE account_id=?`, OpsAccountID).Scan(&audits); err != nil || audits != 2 {
+ t.Fatalf("house audit rows = %d %v, want 2 (set, clear; the no-op wrote nothing)", audits, err)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE billing_house_changes SET note='rewritten'`); err == nil {
+ t.Fatal("the house audit must be immutable")
+ }
+ if status, _ := postJSON(t, fx.ts.URL+"/api/billing/house", map[string]any{"account": "zzz", "house": true, "note": "n"}, token); status != http.StatusBadRequest {
+ t.Fatalf("unknown account = %d, want 400", status)
+ }
+}
diff --git a/internal/console/billingstore.go b/internal/console/billingstore.go
index c0d9b4e51..be20682a3 100644
--- a/internal/console/billingstore.go
+++ b/internal/console/billingstore.go
@@ -151,6 +151,9 @@ func (s *Store) migrateBilling() error {
if err := s.migrateBillingInvoices(); err != nil {
return err
}
+ if err := s.migrateBillingOperator(); err != nil {
+ return err
+ }
return s.migrateEntitlements()
}
@@ -537,8 +540,8 @@ func validReviewText(v string, max int) bool {
// and only if it has not recurred since the operator read it. The row update
// and the audit append share one transaction. Repeating the exact resolution
// of an already resolved occurrence succeeds without writing; a different
-// actor or note for it is errBillingReviewResolved. There is deliberately no
-// HTTP, CLI or UI caller yet.
+// actor or note for it is errBillingReviewResolved. Its one caller is the
+// operator door in billingoperator.go.
func (s *Store) resolveBillingReview(r billingReviewResolution, now time.Time) error {
if r.AccountID <= 0 || r.Subscription == "" || r.Reason == "" || r.Occurrence <= 0 || now.Unix() <= 0 ||
!validReviewText(r.Actor, billingReviewActorMax) || !validReviewText(r.Note, billingReviewNoteMax) {
diff --git a/internal/console/server.go b/internal/console/server.go
index 4f9f99f0d..096bc9dc8 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1140,6 +1140,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("/a/{sqid}/billing/checkout", s.handleBillingCheckout)
s.mux.HandleFunc("/a/{sqid}/billing/portal", s.handleBillingPortal)
s.mux.HandleFunc("/billing/webhook", s.handleBillingWebhook)
+ s.mux.HandleFunc("GET /api/billing/reviews", s.handleAPIBillingReviews)
+ s.mux.HandleFunc("POST /api/billing/reviews/resolve", s.handleAPIBillingReviewResolve)
+ s.mux.HandleFunc("GET /api/billing/house", s.handleAPIBillingHouseList)
+ s.mux.HandleFunc("POST /api/billing/house", s.handleAPIBillingHouseSet)
s.mux.HandleFunc("POST /a/{sqid}/errors/level", s.handleAccountErrorLevel)
s.mux.HandleFunc("POST /a/{sqid}/analytics/setting", s.handleAccountAnalyticsSetting)
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why