From 8c3c83938d5412d24f67dacc52e4a6b2db19dac8 Mon Sep 17 00:00:00 2001 From: midagedev Date: Fri, 28 Aug 2026 09:45:43 +0900 Subject: [PATCH] fix(storage): local evidence objects no longer vanish on restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coupons, promotion codes, subscription schedules, disputes, tax rates, tax IDs and customer cash balances lived only in process memory while every other object was in SQLite. A restart therefore produced a half-restored dataset: customers, subscriptions and invoices came back, but a subscription whose default_tax_rates referenced a tax rate created before the restart failed with resource_missing, and nothing in the surviving data explained why. They are now written through to the run's own store, so isolation and lifetime follow the run: a file-backed run keeps them, an in-memory run stays ephemeral. Persistence lives in the evidence store's own accessors because there are two write paths — the REST handlers and fixture apply, which supplies explicit IDs — and a handler-level save would have missed the one seeded environments actually use. Idempotency keys stay in memory deliberately; losing them on restart is the Stripe-like behaviour. The api test helper now wires the store by default, so the existing suite exercises the persisted path. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 13 + internal/api/api.go | 29 ++- internal/api/api_test.go | 5 + internal/api/local_evidence.go | 233 ++++++++++++++---- .../api/local_evidence_persistence_test.go | 113 +++++++++ internal/server/server.go | 4 + internal/storage/local_evidence.go | 56 +++++ .../storage/migrations/023_local_evidence.sql | 6 + internal/storage/storage_test.go | 64 ++++- 9 files changed, 465 insertions(+), 58 deletions(-) create mode 100644 internal/api/local_evidence_persistence_test.go create mode 100644 internal/storage/local_evidence.go create mode 100644 internal/storage/migrations/023_local_evidence.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ead45c..1c6108f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Local evidence objects — coupons, promotion codes, subscription schedules, + disputes, tax rates, tax IDs and customer cash balances — are now stored in + the run's own database instead of process memory. They were the only objects + that did not survive a restart, so a restarted server kept answering with the + rest of its data while every lookup that needed one of them failed: a + subscription whose `default_tax_rates` referenced a tax rate created before + the restart returned a `resource_missing` error, with nothing in the + surviving data to suggest why. Runs backed by memory stay ephemeral, which + is what they were always for. Idempotency keys remain in memory on purpose — + losing them on restart is the Stripe-like behaviour. +- `TestSQLiteMigrationsRun` now derives the expected versions from the embedded + migration files rather than a hand-written list, so it no longer needs an edit + per migration and it fails on a gap or a duplicated number. - `POST /v1/invoices/{id}/void` moves an `open` invoice to `void`, records `billtap_voided_at`, and emits `invoice.voided`. Other statuses return `invalid_request_error` with `status must be open`. diff --git a/internal/api/api.go b/internal/api/api.go index 931ccb5..ad97632 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -31,6 +31,9 @@ type Options struct { Webhooks *webhooks.Service Diagnostics *diagnostics.Service PublicBaseURL string + // LocalEvidence persists coupons, promotion codes, schedules, disputes, tax + // rates, tax IDs and cash balances. Nil keeps them in memory only. + LocalEvidence LocalEvidenceRepository } type Handler struct { @@ -63,7 +66,7 @@ func New(opts Options) http.Handler { publicBase: strings.TrimRight(opts.PublicBaseURL, "/"), mux: http.NewServeMux(), idem: newIdempotencyStore(), - local: newLocalEvidenceStore(), + local: newLocalEvidenceStore(opts.LocalEvidence), compat: stripecompat.DefaultRegistry(), knownRoutes: stripecompat.DefaultRouteCatalog(), validation: stripecompat.DefaultValidationCatalog(), @@ -4825,9 +4828,9 @@ func (h *Handler) applyFixtureDisputes(r *http.Request, pack fixtures.Pack) ([]m out := make([]map[string]any, 0, len(pack.Disputes)) for _, fixture := range pack.Disputes { dispute := disputeFixturePayload(fixture) - h.local.mu.Lock() - h.local.disputes[fmt.Sprint(dispute["id"])] = dispute - h.local.mu.Unlock() + if err := h.local.save(kindDispute, fmt.Sprint(dispute["id"]), dispute); err != nil { + return nil, err + } out = append(out, cloneEvidence(dispute)) h.emitGenericWebhook(r, "charge.dispute.created", fmt.Sprint(dispute["id"]), dispute, webhooks.SourceFixture) if fmt.Sprint(dispute["status"]) != "needs_response" { @@ -4886,9 +4889,9 @@ func (h *Handler) applyFixtureTaxRates(pack fixtures.Pack) ([]map[string]any, er out := make([]map[string]any, 0, len(pack.TaxRates)) for _, fixture := range pack.TaxRates { taxRate := taxRateFixturePayload(fixture) - h.local.mu.Lock() - h.local.taxRates[fmt.Sprint(taxRate["id"])] = taxRate - h.local.mu.Unlock() + if err := h.local.save(kindTaxRate, fmt.Sprint(taxRate["id"]), taxRate); err != nil { + return nil, err + } out = append(out, cloneEvidence(taxRate)) } return out, nil @@ -4936,9 +4939,9 @@ func (h *Handler) applyFixtureCoupons(pack fixtures.Pack) ([]map[string]any, err out := make([]map[string]any, 0, len(pack.Coupons)) for _, fixture := range pack.Coupons { coupon := couponFixturePayload(fixture) - h.local.mu.Lock() - h.local.coupons[fmt.Sprint(coupon["id"])] = coupon - h.local.mu.Unlock() + if err := h.local.save(kindCoupon, fmt.Sprint(coupon["id"]), coupon); err != nil { + return nil, err + } out = append(out, cloneEvidence(coupon)) } return out, nil @@ -5007,9 +5010,9 @@ func (h *Handler) applyFixturePromotionCodes(pack fixtures.Pack) ([]map[string]a if err != nil { return nil, err } - h.local.mu.Lock() - h.local.promotionCodes[fmt.Sprint(promo["id"])] = promo - h.local.mu.Unlock() + if err := h.local.save(kindPromotionCode, fmt.Sprint(promo["id"]), promo); err != nil { + return nil, err + } out = append(out, cloneEvidence(promo)) } return out, nil diff --git a/internal/api/api_test.go b/internal/api/api_test.go index a9b97ef..ce8f7f6 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -7509,6 +7509,11 @@ func newTestHandlerWithOptions(t *testing.T, opts Options) http.Handler { webhookService := webhooks.NewService(store) opts.Webhooks = webhookService opts.Diagnostics = diagnostics.NewService(store) + if opts.LocalEvidence == nil { + // Wire it by default so the whole suite runs against the persisted path, + // which is what the server does. + opts.LocalEvidence = store + } // After t.TempDir(): LIFO runs wait+close before TempDir removal. webhookstest.RegisterStoreCleanup(t, webhookService, store) return New(opts) diff --git a/internal/api/local_evidence.go b/internal/api/local_evidence.go index e2aa42a..22c48cb 100644 --- a/internal/api/local_evidence.go +++ b/internal/api/local_evidence.go @@ -1,6 +1,8 @@ package api import ( + "context" + "encoding/json" "fmt" "net/http" "strconv" @@ -12,8 +14,29 @@ import ( "github.com/hckim/billtap/internal/webhooks" ) +// Evidence kinds. These are the persistence keys, so renaming one orphans the +// rows already written under the old name. +const ( + kindCoupon = "coupon" + kindPromotionCode = "promotion_code" + kindSchedule = "schedule" + kindDispute = "dispute" + kindTaxRate = "tax_rate" + kindTaxID = "tax_id" + kindCash = "cash" +) + +// LocalEvidenceRepository persists evidence objects in the run's own store, so a +// run backed by a file keeps them across restarts and an in-memory run does not. +type LocalEvidenceRepository interface { + SaveLocalEvidence(ctx context.Context, kind, id, data string) error + DeleteLocalEvidence(ctx context.Context, kind, id string) error + LoadLocalEvidence(ctx context.Context) (map[string]map[string]string, error) +} + type localEvidenceStore struct { mu sync.Mutex + repo LocalEvidenceRepository coupons map[string]map[string]any promotionCodes map[string]map[string]any schedules map[string]map[string]any @@ -24,8 +47,11 @@ type localEvidenceStore struct { taxIDs map[string]map[string]any } -func newLocalEvidenceStore() *localEvidenceStore { - return &localEvidenceStore{ +// newLocalEvidenceStore returns an evidence store. A nil repo keeps everything in +// memory, which is what callers without a store (scorecard runs, unit tests) want. +func newLocalEvidenceStore(repo LocalEvidenceRepository) *localEvidenceStore { + s := &localEvidenceStore{ + repo: repo, coupons: map[string]map[string]any{}, promotionCodes: map[string]map[string]any{}, schedules: map[string]map[string]any{}, @@ -35,6 +61,115 @@ func newLocalEvidenceStore() *localEvidenceStore { taxRates: map[string]map[string]any{}, taxIDs: map[string]map[string]any{}, } + s.restore() + return s +} + +func (s *localEvidenceStore) mapFor(kind string) map[string]map[string]any { + switch kind { + case kindCoupon: + return s.coupons + case kindPromotionCode: + return s.promotionCodes + case kindSchedule: + return s.schedules + case kindDispute: + return s.disputes + case kindTaxRate: + return s.taxRates + case kindTaxID: + return s.taxIDs + } + return nil +} + +// saveLocked records obj and mirrors it to the repo. The caller holds mu. +func (s *localEvidenceStore) saveLocked(kind, id string, obj map[string]any) error { + if m := s.mapFor(kind); m != nil { + m[id] = obj + } + if s.repo == nil { + return nil + } + data, err := json.Marshal(obj) + if err != nil { + return err + } + return s.repo.SaveLocalEvidence(context.Background(), kind, id, string(data)) +} + +func (s *localEvidenceStore) save(kind, id string, obj map[string]any) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.saveLocked(kind, id, obj) +} + +func (s *localEvidenceStore) remove(kind, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + if m := s.mapFor(kind); m != nil { + delete(m, id) + } + if s.repo == nil { + return nil + } + return s.repo.DeleteLocalEvidence(context.Background(), kind, id) +} + +// cashRecord is the persisted shape of one customer's cash balance and its ledger. +type cashRecord struct { + Balance int64 `json:"balance"` + Transactions []map[string]any `json:"transactions"` +} + +// addCash moves the balance and appends the transaction as one unit — the two are +// read together by GET /v1/customers//cash_balance, so a partial write would +// show a balance no ledger explains. +func (s *localEvidenceStore) addCash(customerID string, amount int64, tx map[string]any) error { + s.mu.Lock() + defer s.mu.Unlock() + s.cashBalances[customerID] += amount + s.cashTxs[customerID] = append(s.cashTxs[customerID], tx) + if s.repo == nil { + return nil + } + data, err := json.Marshal(cashRecord{Balance: s.cashBalances[customerID], Transactions: s.cashTxs[customerID]}) + if err != nil { + return err + } + return s.repo.SaveLocalEvidence(context.Background(), kindCash, customerID, string(data)) +} + +// restore reloads persisted evidence. A store that cannot be read is left empty +// rather than failing the process — the run still serves, it just has no history. +func (s *localEvidenceStore) restore() { + if s.repo == nil { + return + } + all, err := s.repo.LoadLocalEvidence(context.Background()) + if err != nil { + return + } + for kind, rows := range all { + for id, raw := range rows { + if kind == kindCash { + var rec cashRecord + if json.Unmarshal([]byte(raw), &rec) == nil { + s.cashBalances[id] = rec.Balance + s.cashTxs[id] = rec.Transactions + } + continue + } + m := s.mapFor(kind) + if m == nil { + continue + } + var obj map[string]any + if json.Unmarshal([]byte(raw), &obj) == nil { + m[id] = obj + } + } + } } func (h *Handler) handleCoupons(w http.ResponseWriter, r *http.Request) { @@ -89,9 +224,10 @@ func (h *Handler) handleCoupons(w http.ResponseWriter, r *http.Request) { if products := p.appliesToProducts(); len(products) > 0 { coupon["applies_to"] = map[string]any{"products": products} } - h.local.mu.Lock() - h.local.coupons[id] = coupon - h.local.mu.Unlock() + if err := h.local.save(kindCoupon, id, coupon); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(coupon)) case http.MethodGet: h.local.mu.Lock() @@ -121,9 +257,10 @@ func (h *Handler) handleCoupon(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, cloneEvidence(coupon)) case http.MethodDelete: deleted := map[string]any{"id": id, "object": "coupon", "deleted": true} - h.local.mu.Lock() - delete(h.local.coupons, id) - h.local.mu.Unlock() + if err := h.local.remove(kindCoupon, id); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, deleted) default: h.methodNotAllowed(w, r, "GET, POST, DELETE") @@ -169,9 +306,10 @@ func (h *Handler) handlePromotionCodes(w http.ResponseWriter, r *http.Request) { "created": now.Unix(), "livemode": false, } - h.local.mu.Lock() - h.local.promotionCodes[id] = promo - h.local.mu.Unlock() + if err := h.local.save(kindPromotionCode, id, promo); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(promo)) case http.MethodGet: h.local.mu.Lock() @@ -244,9 +382,10 @@ func (h *Handler) handleTaxRates(w http.ResponseWriter, r *http.Request) { "created": now.Unix(), "livemode": false, } - h.local.mu.Lock() - h.local.taxRates[id] = taxRate - h.local.mu.Unlock() + if err := h.local.save(kindTaxRate, id, taxRate); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(taxRate)) case http.MethodGet: h.local.mu.Lock() @@ -311,8 +450,12 @@ func (h *Handler) handleTaxRate(w http.ResponseWriter, r *http.Request) { } current["metadata"] = nonNilMap(merged) } - h.local.taxRates[id] = current + err = h.local.saveLocked(kindTaxRate, id, current) h.local.mu.Unlock() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(current)) default: h.methodNotAllowed(w, r, "GET, POST") @@ -360,9 +503,10 @@ func (h *Handler) handleCustomerTaxIDs(w http.ResponseWriter, r *http.Request, c "verified_name": nil, }, } - h.local.mu.Lock() - h.local.taxIDs[id] = taxIDObj - h.local.mu.Unlock() + if err := h.local.save(kindTaxID, id, taxIDObj); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(taxIDObj)) case http.MethodGet: h.local.mu.Lock() @@ -394,9 +538,10 @@ func (h *Handler) handleCustomerTaxIDs(w http.ResponseWriter, r *http.Request, c case http.MethodGet: writeJSON(w, http.StatusOK, cloneEvidence(item)) case http.MethodDelete: - h.local.mu.Lock() - delete(h.local.taxIDs, taxID) - h.local.mu.Unlock() + if err := h.local.remove(kindTaxID, taxID); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, stripeDeleted(taxID, "tax_id")) default: h.methodNotAllowed(w, r, "GET, DELETE") @@ -495,9 +640,10 @@ func (h *Handler) handleSubscriptionSchedules(w http.ResponseWriter, r *http.Req "created": now.Unix(), "livemode": false, } - h.local.mu.Lock() - h.local.schedules[id] = schedule - h.local.mu.Unlock() + if err := h.local.save(kindSchedule, id, schedule); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(schedule)) case http.MethodGet: h.local.mu.Lock() @@ -544,9 +690,10 @@ func (h *Handler) handleSubscriptionSchedule(w http.ResponseWriter, r *http.Requ h.notFound(w, r) return } - h.local.mu.Lock() - h.local.schedules[id] = schedule - h.local.mu.Unlock() + if err := h.local.save(kindSchedule, id, schedule); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } writeJSON(w, http.StatusOK, cloneEvidence(schedule)) } @@ -581,9 +728,8 @@ func (h *Handler) applyDueSubscriptionSchedules(r *http.Request, clockID string, continue } schedule["status"] = "completed" - h.local.mu.Lock() - h.local.schedules[fmt.Sprint(schedule["id"])] = schedule - h.local.mu.Unlock() + // Best effort: this runs inside a clock advance, which has no response to fail. + _ = h.local.save(kindSchedule, fmt.Sprint(schedule["id"]), schedule) h.emitSubscriptionWebhook(r, "customer.subscription.updated", subscription, webhooks.SourceAPI) updated = append(updated, subscription) } @@ -710,10 +856,10 @@ func (h *Handler) handleTestHelperCustomer(w http.ResponseWriter, r *http.Reques "created": now.Unix(), "livemode": false, } - h.local.mu.Lock() - h.local.cashBalances[customerID] += amount - h.local.cashTxs[customerID] = append(h.local.cashTxs[customerID], tx) - h.local.mu.Unlock() + if err := h.local.addCash(customerID, amount, tx); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } settled, _ := h.billing.SettleBankTransferPaymentIntents(r.Context(), customerID) for _, intent := range settled { h.emitPaymentIntentWebhook(r, "payment_intent.succeeded", intent) @@ -834,9 +980,10 @@ func (h *Handler) handleDispute(w http.ResponseWriter, r *http.Request) { "submission_count": 1, "past_due": false, } - h.local.mu.Lock() - h.local.disputes[id] = dispute - h.local.mu.Unlock() + if err := h.local.save(kindDispute, id, dispute); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } h.emitGenericWebhook(r, "charge.dispute.updated", id, dispute, webhooks.SourceAPI) } writeJSON(w, http.StatusOK, cloneEvidence(dispute)) @@ -848,9 +995,10 @@ func (h *Handler) handleDispute(w http.ResponseWriter, r *http.Request) { } dispute["status"] = "won" dispute["closed_at"] = time.Now().UTC().Unix() - h.local.mu.Lock() - h.local.disputes[id] = dispute - h.local.mu.Unlock() + if err := h.local.save(kindDispute, id, dispute); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } h.emitGenericWebhook(r, "charge.dispute.closed", id, dispute, webhooks.SourceAPI) writeJSON(w, http.StatusOK, cloneEvidence(dispute)) } @@ -874,9 +1022,8 @@ func (h *Handler) createDispute(r *http.Request, chargeID string, amount int64, "created": now.Unix(), "livemode": false, } - h.local.mu.Lock() - h.local.disputes[fmt.Sprint(dispute["id"])] = dispute - h.local.mu.Unlock() + // Best effort: the caller returns the dispute, not an error. + _ = h.local.save(kindDispute, fmt.Sprint(dispute["id"]), dispute) h.emitGenericWebhook(r, "charge.dispute.created", chargeID, dispute, webhooks.SourceAPI) return cloneEvidence(dispute) } diff --git a/internal/api/local_evidence_persistence_test.go b/internal/api/local_evidence_persistence_test.go new file mode 100644 index 0000000..08ad690 --- /dev/null +++ b/internal/api/local_evidence_persistence_test.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + + "github.com/hckim/billtap/internal/billing" + "github.com/hckim/billtap/internal/diagnostics" + "github.com/hckim/billtap/internal/storage" + "github.com/hckim/billtap/internal/webhooks" + "github.com/hckim/billtap/internal/webhooks/webhookstest" +) + +// handlerOnStore builds a handler over an already-open store, which is how a +// restart is expressed in a test: same store, second handler. +func handlerOnStore(t *testing.T, store *storage.SQLiteStore) http.Handler { + t.Helper() + webhookService := webhooks.NewService(store) + webhookstest.RegisterStoreCleanup(t, webhookService, store) + return New(Options{ + Billing: billing.NewService(store), + Webhooks: webhookService, + Diagnostics: diagnostics.NewService(store), + LocalEvidence: store, + }) +} + +// A restart used to drop every evidence object while the rest of the store +// survived, so lookups kept returning 200 with the sub-record missing. +func TestLocalEvidenceSurvivesHandlerRestart(t *testing.T) { + store, err := storage.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + first := handlerOnStore(t, store) + + // Path 1: the API. Path 2: fixture apply, which writes explicit IDs and is the + // path a seeded environment actually uses. + created := postForm[map[string]any](t, first, "/v1/tax_rates", url.Values{ + "display_name": {"VAT"}, + "percentage": {"10"}, + "inclusive": {"false"}, + }) + apiID, _ := created["id"].(string) + if apiID == "" { + t.Fatalf("tax rate create returned no id: %#v", created) + } + postJSON[map[string]any](t, first, "/api/fixtures/apply", map[string]any{ + "tax_rates": []map[string]any{ + {"id": "txr_fixture_vat", "display_name": "Fixture VAT", "percentage": 10}, + }, + }) + postForm[map[string]any](t, first, "/v1/coupons", url.Values{ + "id": {"cpn_keepme"}, + "percent_off": {"25"}, + "duration": {"forever"}, + }) + + second := handlerOnStore(t, store) + for _, id := range []string{apiID, "txr_fixture_vat"} { + got := getJSON[map[string]any](t, second, "/v1/tax_rates/"+id) + if got["id"] != id { + t.Fatalf("tax rate %s missing after restart: %#v", id, got) + } + } + if got := getJSON[map[string]any](t, second, "/v1/coupons/cpn_keepme"); got["id"] != "cpn_keepme" { + t.Fatalf("coupon missing after restart: %#v", got) + } + + list := getJSON[map[string]any](t, second, "/v1/tax_rates") + data, _ := list["data"].([]any) + if len(data) != 2 { + t.Fatalf("tax rate list after restart = %d rows, want 2", len(data)) + } +} + +// Deletes must persist too, or a restart resurrects the object. +func TestLocalEvidenceDeleteSurvivesHandlerRestart(t *testing.T) { + store, err := storage.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + first := handlerOnStore(t, store) + postForm[map[string]any](t, first, "/v1/coupons", url.Values{ + "id": {"cpn_gone"}, + "percent_off": {"25"}, + "duration": {"forever"}, + }) + deleteJSON[map[string]any](t, first, "/v1/coupons/cpn_gone") + + second := handlerOnStore(t, store) + req := httptest.NewRequest(http.MethodGet, "/v1/coupons/cpn_gone", nil) + rec := httptest.NewRecorder() + second.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("deleted coupon came back after restart: status %d", rec.Code) + } +} + +// A store that cannot hold evidence keeps the old in-memory behavior. +func TestLocalEvidenceWithoutRepositoryStaysInMemory(t *testing.T) { + s := newLocalEvidenceStore(nil) + if err := s.save(kindTaxRate, "txr_1", map[string]any{"id": "txr_1"}); err != nil { + t.Fatalf("save without repo: %v", err) + } + if _, ok := s.taxRates["txr_1"]; !ok { + t.Fatal("in-memory save did not record the object") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 9d9aff3..278bd7f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -237,11 +237,15 @@ func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) { if diagnosticsRepo, ok := store.(diagnostics.Repository); ok { diagnosticsService = diagnostics.NewService(diagnosticsRepo) } + // Evidence objects (coupons, tax rates, cash balances, ...) live in the run's own + // store when it can hold them, so they survive a restart like every other object. + evidenceRepo, _ := store.(api.LocalEvidenceRepository) return api.New(api.Options{ Billing: billing.NewService(repo), Webhooks: webhookService, Diagnostics: diagnosticsService, PublicBaseURL: config.PublicBaseURLWithPath(s.cfg.PublicBaseURL, s.cfg.PublicBasePath), + LocalEvidence: evidenceRepo, }), nil } diff --git a/internal/storage/local_evidence.go b/internal/storage/local_evidence.go new file mode 100644 index 0000000..2168122 --- /dev/null +++ b/internal/storage/local_evidence.go @@ -0,0 +1,56 @@ +package storage + +import ( + "context" + "errors" + "fmt" +) + +// SaveLocalEvidence upserts one evidence document. +func (s *SQLiteStore) SaveLocalEvidence(ctx context.Context, kind, id, data string) error { + if s == nil || s.db == nil { + return errors.New("sqlite store is not open") + } + if _, err := s.db.ExecContext(ctx, `INSERT INTO local_evidence (kind, id, data) + VALUES (?, ?, ?) + ON CONFLICT(kind, id) DO UPDATE SET data = excluded.data`, kind, id, data); err != nil { + return fmt.Errorf("save local evidence %s/%s: %w", kind, id, err) + } + return nil +} + +// DeleteLocalEvidence removes one evidence document. Deleting an absent row is not an error. +func (s *SQLiteStore) DeleteLocalEvidence(ctx context.Context, kind, id string) error { + if s == nil || s.db == nil { + return errors.New("sqlite store is not open") + } + if _, err := s.db.ExecContext(ctx, `DELETE FROM local_evidence WHERE kind = ? AND id = ?`, kind, id); err != nil { + return fmt.Errorf("delete local evidence %s/%s: %w", kind, id, err) + } + return nil +} + +// LoadLocalEvidence returns every stored evidence document as kind -> id -> JSON. +func (s *SQLiteStore) LoadLocalEvidence(ctx context.Context) (map[string]map[string]string, error) { + if s == nil || s.db == nil { + return nil, errors.New("sqlite store is not open") + } + rows, err := s.db.QueryContext(ctx, `SELECT kind, id, data FROM local_evidence ORDER BY kind, id`) + if err != nil { + return nil, fmt.Errorf("load local evidence: %w", err) + } + defer rows.Close() + + out := map[string]map[string]string{} + for rows.Next() { + var kind, id, data string + if err := rows.Scan(&kind, &id, &data); err != nil { + return nil, fmt.Errorf("scan local evidence: %w", err) + } + if out[kind] == nil { + out[kind] = map[string]string{} + } + out[kind][id] = data + } + return out, rows.Err() +} diff --git a/internal/storage/migrations/023_local_evidence.sql b/internal/storage/migrations/023_local_evidence.sql new file mode 100644 index 0000000..a135177 --- /dev/null +++ b/internal/storage/migrations/023_local_evidence.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS local_evidence ( + kind TEXT NOT NULL, + id TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (kind, id) +); diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 488e34f..b7bbf19 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -3,7 +3,9 @@ package storage import ( "context" "errors" + "io/fs" "path/filepath" + "slices" "testing" "time" @@ -22,8 +24,20 @@ func TestSQLiteMigrationsRun(t *testing.T) { if err != nil { t.Fatalf("MigrationVersions returned error: %v", err) } - if len(versions) != 22 || versions[0] != 1 || versions[1] != 2 || versions[2] != 3 || versions[3] != 4 || versions[4] != 5 || versions[5] != 6 || versions[6] != 7 || versions[7] != 8 || versions[8] != 9 || versions[9] != 10 || versions[10] != 11 || versions[11] != 12 || versions[12] != 13 || versions[13] != 14 || versions[14] != 15 || versions[15] != 16 || versions[16] != 17 || versions[17] != 18 || versions[18] != 19 || versions[19] != 20 || versions[20] != 21 || versions[21] != 22 { - t.Fatalf("versions = %#v, want [1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22]", versions) + // Expect every embedded migration, numbered 1..N with no gap. Spelling the list + // out by hand meant editing this test on every migration, and a gap — the failure + // that actually matters, since a skipped number means a file never ran — read the + // same as "the count moved". + files, err := fs.ReadDir(migrations, "migrations") + if err != nil { + t.Fatalf("read migrations: %v", err) + } + want := make([]int, 0, len(files)) + for i := range files { + want = append(want, i+1) + } + if !slices.Equal(versions, want) { + t.Fatalf("versions = %#v, want %#v", versions, want) } } @@ -340,3 +354,49 @@ func TestRecordTimelineIsIdempotentForRepeatedEventIDs(t *testing.T) { t.Fatalf("timeline holds %d copies of %s, want exactly 1", matching, entry.ID) } } + +func TestLocalEvidenceRoundTrip(t *testing.T) { + ctx := context.Background() + store, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "billtap.db")) + if err != nil { + t.Fatalf("OpenSQLite returned error: %v", err) + } + defer store.Close() + + if err := store.SaveLocalEvidence(ctx, "tax_rate", "txr_1", `{"id":"txr_1"}`); err != nil { + t.Fatalf("SaveLocalEvidence: %v", err) + } + // Same key twice must update, not fail on the primary key. + if err := store.SaveLocalEvidence(ctx, "tax_rate", "txr_1", `{"id":"txr_1","active":false}`); err != nil { + t.Fatalf("SaveLocalEvidence (update): %v", err) + } + if err := store.SaveLocalEvidence(ctx, "coupon", "txr_1", `{"id":"txr_1","object":"coupon"}`); err != nil { + t.Fatalf("SaveLocalEvidence (other kind, same id): %v", err) + } + + all, err := store.LoadLocalEvidence(ctx) + if err != nil { + t.Fatalf("LoadLocalEvidence: %v", err) + } + if got := all["tax_rate"]["txr_1"]; got != `{"id":"txr_1","active":false}` { + t.Fatalf("tax_rate row = %q, want the updated document", got) + } + if _, ok := all["coupon"]["txr_1"]; !ok { + t.Fatal("an id shared across kinds must not collide") + } + + if err := store.DeleteLocalEvidence(ctx, "tax_rate", "txr_1"); err != nil { + t.Fatalf("DeleteLocalEvidence: %v", err) + } + // Deleting what is already gone is not an error. + if err := store.DeleteLocalEvidence(ctx, "tax_rate", "txr_1"); err != nil { + t.Fatalf("DeleteLocalEvidence (absent): %v", err) + } + all, err = store.LoadLocalEvidence(ctx) + if err != nil { + t.Fatalf("LoadLocalEvidence after delete: %v", err) + } + if _, ok := all["tax_rate"]["txr_1"]; ok { + t.Fatal("deleted row came back") + } +}