Persist inert overage preferences with ordered immutable receipts
3 files changed,
+550
−1
internal/console/overagepreferencestore.go+265 −0internal/console/overagepreferencestore_test.go+281 −0internal/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 |