carlos / platform Public

Persist inert overage preferences with ordered immutable receipts

claude agent, operated by paul@keymail.dev 44cdddb8510cb29b8c1c714d25766dfa55951ada parent b634c44
3 files changed, +550 −1
  • internal/console/overagepreferencestore.go +265 −0
  • internal/console/overagepreferencestore_test.go +281 −0
  • internal/console/store.go +4 −1

CI passed — run details

diff --git a/internal/console/overagepreferencestore.go b/internal/console/overagepreferencestore.go
new file mode 100644
index 000000000..f42b4bb7c
--- /dev/null
+++ b/internal/console/overagepreferencestore.go
@@ -0,0 +1,265 @@
+package console
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "math"
+ "regexp"
+ "time"
+
+ "github.com/carlosframework/platform/internal/maintenanceguard"
+)
+
+var (
+ errOveragePreferenceInvalid = errors.New("overage_preference_invalid")
+ errOveragePreferenceDenied = errors.New("overage_preference_denied")
+ errOveragePreferenceConflict = errors.New("overage_preference_conflict")
+ errOveragePreferenceStale = errors.New("overage_preference_stale")
+ errOveragePreferenceOverflow = errors.New("overage_preference_overflow")
+ errOveragePreferenceCorrupt = errors.New("overage_preference_corrupt")
+ errOveragePreferenceClock = errors.New("overage_preference_clock_rollback")
+)
+
+var (
+ overageEvidenceName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`)
+ overageCurrency = regexp.MustCompile(`^[a-z]{3}$`)
+ overageDigest = regexp.MustCompile(`^[0-9a-f]{64}$`)
+)
+
+type overageEvidence struct {
+ TermsVersion, PriceBook, Currency, DisclosureDigest, OfferDigest string
+}
+
+type overagePreference struct {
+ AccountID int64
+ Revision int64
+ RequestedOn bool
+ Evidence overageEvidence
+ Actor string
+ OperationID string
+ UpdatedAt time.Time
+}
+
+type overagePreferenceChange struct {
+ AccountID int64
+ Actor string
+ ExpectedRevision int64
+ RequestedOn bool
+ OperationID string
+ Evidence overageEvidence
+}
+
+type overagePreferenceReceipt struct {
+ OperationID string
+ AccountID int64
+ Actor string
+ Revision int64
+ RequestedOn bool
+ Evidence overageEvidence
+ ChangedAt time.Time
+}
+
+func (s *Store) migrateOveragePreference() error {
+ for _, stmt := range []string{
+ `CREATE TABLE IF NOT EXISTS overage_preferences (
+ account_id INTEGER PRIMARY KEY, revision INTEGER NOT NULL,
+ requested_on INTEGER NOT NULL CHECK(requested_on IN (0,1)), terms_version TEXT NOT NULL,
+ price_book TEXT NOT NULL, currency TEXT NOT NULL,
+ disclosure_digest TEXT NOT NULL, offer_digest TEXT NOT NULL,
+ actor TEXT NOT NULL, operation_id TEXT NOT NULL, updated_at INTEGER NOT NULL
+)`,
+ `CREATE TABLE IF NOT EXISTS overage_preference_receipts (
+ operation_id TEXT PRIMARY KEY, account_id INTEGER NOT NULL,
+ actor TEXT NOT NULL, request_digest TEXT NOT NULL,
+ revision INTEGER NOT NULL, requested_on INTEGER NOT NULL,
+ terms_version TEXT NOT NULL, price_book TEXT NOT NULL,
+ currency TEXT NOT NULL, disclosure_digest TEXT NOT NULL,
+ offer_digest TEXT NOT NULL, changed_at INTEGER NOT NULL,
+ UNIQUE(account_id, revision)
+)`,
+ `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_update BEFORE UPDATE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`,
+ `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_delete BEFORE DELETE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`,
+ } {
+ if _, err := s.db.Exec(stmt); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validOverageEvidence(e overageEvidence) bool {
+ return overageEvidenceName.MatchString(e.TermsVersion) &&
+ overageEvidenceName.MatchString(e.PriceBook) &&
+ overageCurrency.MatchString(e.Currency) &&
+ overageDigest.MatchString(e.DisclosureDigest) &&
+ overageDigest.MatchString(e.OfferDigest)
+}
+
+func validateOverageChange(c overagePreferenceChange) error {
+ if c.AccountID <= 0 || c.Actor == "" || len(c.Actor) > 320 || c.ExpectedRevision < 0 ||
+ !maintenanceguard.ValidID(c.OperationID) {
+ return errOveragePreferenceInvalid
+ }
+ if c.RequestedOn && !validOverageEvidence(c.Evidence) {
+ return errOveragePreferenceInvalid
+ }
+ if !c.RequestedOn && c.Evidence != (overageEvidence{}) {
+ return errOveragePreferenceInvalid
+ }
+ return nil
+}
+
+func overageChangeDigest(c overagePreferenceChange) string {
+ b, _ := json.Marshal(c)
+ h := sha256.Sum256(b)
+ return hex.EncodeToString(h[:])
+}
+
+func scanOveragePreference(row interface{ Scan(...any) error }, accountID int64) (overagePreference, error) {
+ var p overagePreference
+ var on int
+ var at int64
+ p.AccountID = accountID
+ err := row.Scan(&p.Revision, &on, &p.Evidence.TermsVersion, &p.Evidence.PriceBook,
+ &p.Evidence.Currency, &p.Evidence.DisclosureDigest, &p.Evidence.OfferDigest,
+ &p.Actor, &p.OperationID, &at)
+ if errors.Is(err, sql.ErrNoRows) {
+ return p, nil
+ }
+ if err != nil {
+ return p, err
+ }
+ p.RequestedOn = on != 0
+ p.UpdatedAt = time.Unix(at, 0).UTC()
+ if p.Revision <= 0 || (on != 0 && on != 1) || p.Actor == "" ||
+ !maintenanceguard.ValidID(p.OperationID) || at < 0 ||
+ (p.RequestedOn && !validOverageEvidence(p.Evidence)) ||
+ (!p.RequestedOn && p.Evidence != (overageEvidence{})) {
+ return overagePreference{}, errOveragePreferenceCorrupt
+ }
+ return p, nil
+}
+
+func scanOverageReceipt(row interface{ Scan(...any) error }) (overagePreferenceReceipt, string, error) {
+ var r overagePreferenceReceipt
+ var digest string
+ var on int
+ var at int64
+ err := row.Scan(&r.OperationID, &r.AccountID, &r.Actor, &digest, &r.Revision, &on,
+ &r.Evidence.TermsVersion, &r.Evidence.PriceBook, &r.Evidence.Currency,
+ &r.Evidence.DisclosureDigest, &r.Evidence.OfferDigest, &at)
+ r.RequestedOn = on != 0
+ r.ChangedAt = time.Unix(at, 0).UTC()
+ if err != nil {
+ return r, digest, err
+ }
+ if !maintenanceguard.ValidID(r.OperationID) || r.AccountID <= 0 || r.Actor == "" ||
+ !overageDigest.MatchString(digest) || r.Revision <= 0 || (on != 0 && on != 1) || at < 0 ||
+ (r.RequestedOn && !validOverageEvidence(r.Evidence)) ||
+ (!r.RequestedOn && r.Evidence != (overageEvidence{})) {
+ return overagePreferenceReceipt{}, "", errOveragePreferenceCorrupt
+ }
+ return r, digest, nil
+}
+
+func authorizeOverageOwnerTx(tx *sql.Tx, accountID int64, actor string) error {
+ if _, err := lockMembershipWritesTx(tx); err != nil {
+ return err
+ }
+ var count int
+ err := tx.QueryRow(`SELECT count(*) FROM accounts a JOIN memberships m ON m.account_id=a.id
+ WHERE a.id=? AND a.deleted_at=0 AND m.address=? AND m.role=?
+ AND NOT EXISTS (SELECT 1 FROM billing_retirements b WHERE b.account_id=a.id)`,
+ accountID, actor, RoleOwner).Scan(&count)
+ if err != nil {
+ return err
+ }
+ if count != 1 {
+ return errOveragePreferenceDenied
+ }
+ return nil
+}
+
+func (s *Store) getOveragePreference(accountID int64, actor string) (overagePreference, error) {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return overagePreference{}, err
+ }
+ defer tx.Rollback()
+ if accountID <= 0 || actor == "" {
+ return overagePreference{}, errOveragePreferenceInvalid
+ }
+ if err := authorizeOverageOwnerTx(tx, accountID, actor); err != nil {
+ return overagePreference{}, err
+ }
+ return scanOveragePreference(tx.QueryRow(`SELECT revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at
+ FROM overage_preferences WHERE account_id=?`, accountID), accountID)
+}
+
+func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time) (overagePreferenceReceipt, error) {
+ if err := validateOverageChange(c); err != nil || now.IsZero() || now.Unix() < 0 {
+ return overagePreferenceReceipt{}, errOveragePreferenceInvalid
+ }
+ tx, err := s.db.Begin()
+ if err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ defer tx.Rollback()
+ // Authorization precedes operation lookup so an id from another account
+ // cannot be used to discover that account's receipt.
+ if err := authorizeOverageOwnerTx(tx, c.AccountID, c.Actor); err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ digest := overageChangeDigest(c)
+ r, storedDigest, err := scanOverageReceipt(tx.QueryRow(`SELECT operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at
+ FROM overage_preference_receipts WHERE operation_id=?`, c.OperationID))
+ if err == nil {
+ if storedDigest != digest {
+ return overagePreferenceReceipt{}, errOveragePreferenceConflict
+ }
+ return r, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return overagePreferenceReceipt{}, err
+ }
+ current, err := scanOveragePreference(tx.QueryRow(`SELECT revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at
+ FROM overage_preferences WHERE account_id=?`, c.AccountID), c.AccountID)
+ if err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ if current.Revision != c.ExpectedRevision {
+ return overagePreferenceReceipt{}, errOveragePreferenceStale
+ }
+ if current.Revision == math.MaxInt64 {
+ return overagePreferenceReceipt{}, errOveragePreferenceOverflow
+ }
+ changed := now.UTC().Truncate(time.Second)
+ if !current.UpdatedAt.IsZero() && changed.Before(current.UpdatedAt) {
+ return overagePreferenceReceipt{}, errOveragePreferenceClock
+ }
+ nextRevision := current.Revision + 1
+ if _, err := tx.Exec(`INSERT INTO overage_preferences(account_id,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(account_id) DO UPDATE SET
+ revision=excluded.revision,requested_on=excluded.requested_on,terms_version=excluded.terms_version,
+ price_book=excluded.price_book,currency=excluded.currency,disclosure_digest=excluded.disclosure_digest,
+ offer_digest=excluded.offer_digest,actor=excluded.actor,operation_id=excluded.operation_id,updated_at=excluded.updated_at`,
+ c.AccountID, nextRevision, c.RequestedOn, c.Evidence.TermsVersion, c.Evidence.PriceBook,
+ c.Evidence.Currency, c.Evidence.DisclosureDigest, c.Evidence.OfferDigest,
+ c.Actor, c.OperationID, changed.Unix()); err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ if _, err := tx.Exec(`INSERT INTO overage_preference_receipts(operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, c.OperationID, c.AccountID, c.Actor, digest, nextRevision,
+ c.RequestedOn, c.Evidence.TermsVersion, c.Evidence.PriceBook, c.Evidence.Currency,
+ c.Evidence.DisclosureDigest, c.Evidence.OfferDigest, changed.Unix()); err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return overagePreferenceReceipt{}, err
+ }
+ return overagePreferenceReceipt{OperationID: c.OperationID, AccountID: c.AccountID, Actor: c.Actor,
+ Revision: nextRevision, RequestedOn: c.RequestedOn, Evidence: c.Evidence, ChangedAt: changed}, nil
+}
diff --git a/internal/console/overagepreferencestore_test.go b/internal/console/overagepreferencestore_test.go
new file mode 100644
index 000000000..5c1215d34
--- /dev/null
+++ b/internal/console/overagepreferencestore_test.go
@@ -0,0 +1,281 @@
+package console
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "math"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+)
+
+const overageOwner = "owner@example.test"
+
+func overageOperation(n byte) string {
+ b := make([]byte, 32)
+ for i := range b {
+ b[i] = n
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func validOverageTestEvidence() overageEvidence {
+ return overageEvidence{
+ TermsVersion: "terms-v2", PriceBook: "book-v2", Currency: "eur",
+ DisclosureDigest: string(make([]byte, 0)),
+ OfferDigest: string(make([]byte, 0)),
+ }
+}
+
+func overageEvidenceForTest() overageEvidence {
+ e := validOverageTestEvidence()
+ e.DisclosureDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ e.OfferDigest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+ return e
+}
+
+func newOverageStore(t *testing.T, path string) *Store {
+ t.Helper()
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.Close() })
+ return s
+}
+
+func seedOverageAccount(t *testing.T, s *Store, id int64, owner string, now time.Time) {
+ t.Helper()
+ if err := s.CreateAccount(id, fmt.Sprintf("acct%d", id), "Account", owner, now); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func overageChange(id int64, actor string, revision int64, on bool, op byte) overagePreferenceChange {
+ c := overagePreferenceChange{AccountID: id, Actor: actor, ExpectedRevision: revision, RequestedOn: on, OperationID: overageOperation(op)}
+ if on {
+ c.Evidence = overageEvidenceForTest()
+ }
+ return c
+}
+
+func TestOveragePreferenceMissingDefaultsOffAndPersists(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, path)
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ p, err := s.getOveragePreference(42, overageOwner)
+ if err != nil || p.Revision != 0 || p.RequestedOn {
+ t.Fatalf("missing preference = %+v, %v", p, err)
+ }
+ r, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, true, 1), now.Add(time.Second))
+ if err != nil || r.Revision != 1 || !r.RequestedOn {
+ t.Fatalf("enable receipt = %+v, %v", r, err)
+ }
+ if err := s.Close(); err != nil {
+ t.Fatal(err)
+ }
+ s = newOverageStore(t, path)
+ p, err = s.getOveragePreference(42, overageOwner)
+ if err != nil || p.Revision != 1 || !p.RequestedOn || p.Evidence != overageEvidenceForTest() {
+ t.Fatalf("reopened preference = %+v, %v", p, err)
+ }
+}
+
+func TestOveragePreferenceAuthorizationAndRetirement(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ if err := s.AddMember(42, "member@example.test", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ for name, c := range map[string]overagePreferenceChange{
+ "unknown account": overageChange(99, overageOwner, 0, false, 2),
+ "member": overageChange(42, "member@example.test", 0, false, 3),
+ "foreign": overageChange(42, "foreign@example.test", 0, false, 4),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := s.changeOveragePreference(c, now); !errors.Is(err, errOveragePreferenceDenied) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+ if _, err := s.db.Exec(`DELETE FROM memberships WHERE account_id=42 AND address=?`, overageOwner); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 5), now); !errors.Is(err, errOveragePreferenceDenied) {
+ t.Fatalf("deleted owner error = %v", err)
+ }
+ if _, err := s.db.Exec(`INSERT INTO memberships(account_id,address,role,added_at) VALUES(42,?,?,?)`, overageOwner, RoleOwner, now.Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`INSERT INTO billing_retirements(account_id,started_at) VALUES(42,?)`, now.Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 6), now); !errors.Is(err, errOveragePreferenceDenied) {
+ t.Fatalf("retiring account error = %v", err)
+ }
+}
+
+func TestOveragePreferenceEvidenceAndCorruptRow(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ c := overageChange(42, overageOwner, 0, true, 7)
+ c.Evidence.OfferDigest = "bad"
+ if _, err := s.changeOveragePreference(c, now); !errors.Is(err, errOveragePreferenceInvalid) {
+ t.Fatalf("invalid evidence error = %v", err)
+ }
+ c = overageChange(42, overageOwner, 0, false, 8)
+ c.Evidence = overageEvidenceForTest()
+ if _, err := s.changeOveragePreference(c, now); !errors.Is(err, errOveragePreferenceInvalid) {
+ t.Fatalf("disable evidence error = %v", err)
+ }
+ if _, err := s.db.Exec(`INSERT INTO overage_preferences(account_id,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at)
+ VALUES(42,1,0,'','','','','','owner@example.test','invalid',?)`, now.Unix()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.getOveragePreference(42, overageOwner); !errors.Is(err, errOveragePreferenceCorrupt) {
+ t.Fatalf("corrupt row error = %v", err)
+ }
+}
+
+func TestOveragePreferenceReplayNeverReappliesHistoricalState(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ enable := overageChange(42, overageOwner, 0, true, 9)
+ r1, err := s.changeOveragePreference(enable, now.Add(time.Second))
+ if err != nil {
+ t.Fatal(err)
+ }
+ disable := overageChange(42, overageOwner, 1, false, 10)
+ if _, err := s.changeOveragePreference(disable, now.Add(2*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ replay, err := s.changeOveragePreference(enable, now.Add(3*time.Second))
+ if err != nil || replay != r1 {
+ t.Fatalf("historical replay = %+v, %v; want %+v", replay, err, r1)
+ }
+ p, err := s.getOveragePreference(42, overageOwner)
+ if err != nil || p.Revision != 2 || p.RequestedOn {
+ t.Fatalf("current after replay = %+v, %v", p, err)
+ }
+}
+
+func TestOveragePreferenceConflictsAndClock(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ seedOverageAccount(t, s, 43, "other@example.test", now)
+ c := overageChange(42, overageOwner, 0, true, 11)
+ if _, err := s.changeOveragePreference(c, now.Add(2*time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ changed := c
+ changed.RequestedOn, changed.Evidence = false, overageEvidence{}
+ if _, err := s.changeOveragePreference(changed, now.Add(3*time.Second)); !errors.Is(err, errOveragePreferenceConflict) {
+ t.Fatalf("changed payload error = %v", err)
+ }
+ cross := overageChange(43, "other@example.test", 0, false, 11)
+ if _, err := s.changeOveragePreference(cross, now.Add(3*time.Second)); !errors.Is(err, errOveragePreferenceConflict) {
+ t.Fatalf("cross-account reuse error = %v", err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, false, 12), now.Add(3*time.Second)); !errors.Is(err, errOveragePreferenceStale) {
+ t.Fatalf("stale error = %v", err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 13), now); !errors.Is(err, errOveragePreferenceClock) {
+ t.Fatalf("clock rollback error = %v", err)
+ }
+ if _, err := s.db.Exec(`UPDATE overage_preferences SET revision=? WHERE account_id=42`, int64(math.MaxInt64)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, math.MaxInt64, false, 14), now.Add(3*time.Second)); !errors.Is(err, errOveragePreferenceOverflow) {
+ t.Fatalf("revision overflow error = %v", err)
+ }
+}
+
+func TestOveragePreferenceInitialOffAndNoopConsumeRevisions(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ for revision := int64(0); revision < 2; revision++ {
+ r, err := s.changeOveragePreference(overageChange(42, overageOwner, revision, false, byte(20+revision)), now.Add(time.Duration(revision)*time.Second))
+ if err != nil || r.Revision != revision+1 || r.RequestedOn {
+ t.Fatalf("off change %d = %+v, %v", revision, r, err)
+ }
+ }
+}
+
+func TestOveragePreferenceConcurrentRevisionZeroHasOneWinner(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s1 := newOverageStore(t, path)
+ seedOverageAccount(t, s1, 42, overageOwner, now)
+ s2 := newOverageStore(t, path)
+ start := make(chan struct{})
+ errs := make([]error, 2)
+ var wg sync.WaitGroup
+ for i, s := range []*Store{s1, s2} {
+ wg.Add(1)
+ go func(i int, s *Store) {
+ defer wg.Done()
+ <-start
+ _, errs[i] = s.changeOveragePreference(overageChange(42, overageOwner, 0, i == 0, byte(30+i)), now.Add(time.Second))
+ }(i, s)
+ }
+ close(start)
+ wg.Wait()
+ wins, stale := 0, 0
+ for _, err := range errs {
+ if err == nil {
+ wins++
+ } else if errors.Is(err, errOveragePreferenceStale) {
+ stale++
+ } else {
+ t.Fatalf("race error = %v", err)
+ }
+ }
+ if wins != 1 || stale != 1 {
+ t.Fatalf("race wins=%d stale=%d errors=%v", wins, stale, errs)
+ }
+}
+
+func TestOveragePreferenceReceiptFailureRollsBackCurrentAndBillingUntouched(t *testing.T) {
+ now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
+ s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db"))
+ seedOverageAccount(t, s, 42, overageOwner, now)
+ if _, err := s.db.Exec(`INSERT INTO billing_accounts(account_id,revision,status,customer,subscription) VALUES(42,7,'unpaid','cus_keep','sub_keep')`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`INSERT INTO billing_months(account_id,month,calculation,digest,state,invoice_id) VALUES(42,'2026-09','kept','digest','open','in_keep')`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.db.Exec(`CREATE TRIGGER fail_overage_receipt BEFORE INSERT ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'injected'); END`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 0, true, 40), now); err == nil {
+ t.Fatal("change succeeded through receipt trigger")
+ }
+ var preferences, receipts int
+ if err := s.db.QueryRow(`SELECT count(*) FROM overage_preferences`).Scan(&preferences); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.db.QueryRow(`SELECT count(*) FROM overage_preference_receipts`).Scan(&receipts); err != nil {
+ t.Fatal(err)
+ }
+ var revision int64
+ var status, customer, subscription string
+ if err := s.db.QueryRow(`SELECT revision,status,customer,subscription FROM billing_accounts WHERE account_id=42`).Scan(&revision, &status, &customer, &subscription); err != nil {
+ t.Fatal(err)
+ }
+ var calculation, invoiceID string
+ if err := s.db.QueryRow(`SELECT calculation,invoice_id FROM billing_months WHERE account_id=42 AND month='2026-09'`).Scan(&calculation, &invoiceID); err != nil {
+ t.Fatal(err)
+ }
+ if preferences != 0 || receipts != 0 || revision != 7 || status != "unpaid" || customer != "cus_keep" || subscription != "sub_keep" || calculation != "kept" || invoiceID != "in_keep" {
+ t.Fatalf("rollback preferences=%d receipts=%d billing=%d/%s/%s/%s month=%s/%s", preferences, receipts, revision, status, customer, subscription, calculation, invoiceID)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index 3e66345b0..325e87cad 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -385,7 +385,10 @@ func (s *Store) migrate() error {
if err := s.migratePendingChanges(); err != nil {
return err
}
- return s.migrateBilling()
+ if err := s.migrateBilling(); err != nil {
+ return err
+ }
+ return s.migrateOveragePreference()
}
// migrateAppsKey moves the apps table from PRIMARY KEY(app) — a GLOBAL name