From 06d15cd5ddd0c411e1ff84fe1298f2b19378bc6b Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 14:33:55 +0800 Subject: [PATCH] feat: freeze the app display name in the billing mirror (migration 037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deleted app's bill rows rendered as "unknown app": billing-engine deliberately held no app names, so the frontend resolved them from the live app registry by app_id — which loses deleted apps. Freeze the name in the mirror so the bill is self-contained (the same posture as created_module_count / app_base_snapshots). - Migration 037: ms_billing.apps.name (nullable), NEVER cleared on delete. - RegisterApp stamps the name on first registration (frozen across retries via ON CONFLICT DO NOTHING); SyncAppModules updates it while the app is live and freezes it once deleted (SetAppName, WHERE deleted_at IS NULL — same gate as SetAppModuleCount). - The account bill (AccountAppBill) and single-app bill (GetAppBillResponse) now carry `name` + a server-authoritative `is_deleted` flag. computeAppBill reads the mirror UNCONDITIONALLY (hoisted out of the un-snapshotted branch) so name/deleted show on charged periods too — the frontend uses is_deleted to show a deleted app's charges in a dialog instead of linking to the gone app page. - Also wire migrations 029-037 into scripts/init-db.sql (it was stuck at 028 — a fresh `make db-init` / CI / reset DB was missing the entire base-fee v2 schema, which 503'd every billing route). Additive, back-compat: name stays NULL until api-platform sends it. Unit + integration suites green (migrations 001->037, real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/account/cycle/apps.go | 24 ++++++++- internal/account/cycle/apps_test.go | 34 +++++++++++++ .../cycle/migration027_integration_test.go | 16 +++--- .../cycle/migration028_integration_test.go | 2 +- .../cycle/migration029_integration_test.go | 16 +++--- .../cycle/migration030_integration_test.go | 6 +-- .../cycle/migration031_integration_test.go | 4 +- .../cycle/migration033_integration_test.go | 8 +-- internal/account/cycle/service_test.go | 11 ++++- internal/account/cycle/store.go | 28 +++++++++-- .../store_prorationlock_integration_test.go | 6 +-- internal/account/db/apps.sql.go | 49 ++++++++++++++++--- internal/account/db/models.go | 2 + internal/account/db/queries/apps.sql | 21 ++++++-- internal/account/usage/accountbill.go | 2 + internal/account/usage/accountbill_test.go | 30 ++++++++++++ internal/account/usage/bill.go | 28 ++++++++--- internal/account/usage/store.go | 2 + internal/account/usage/types.go | 14 ++++++ migrations/billing/037_apps_name.down.sql | 3 ++ migrations/billing/037_apps_name.up.sql | 21 ++++++++ scripts/init-db.sql | 12 +++++ 22 files changed, 287 insertions(+), 52 deletions(-) create mode 100644 migrations/billing/037_apps_name.down.sql create mode 100644 migrations/billing/037_apps_name.up.sql diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 7ad0bea..d7d240e 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -47,6 +47,12 @@ type RegisterAppRequest struct { // zero → the server's now. It anchors the creation-proration window, and // the FIRST registration's value is immutable across retries. CreatedAt time.Time `json:"created_at,omitempty"` + + // Name is the platform app display name, FROZEN into the billing mirror on + // first registration (migration 037) so a later-deleted app's bill still + // shows its name. Empty → NULL (the frontend falls back to its registry + // lookup). SyncAppModules updates it while the app is live. + Name string `json:"name,omitempty"` } // RegisterAppResponse reports the mirror write. RegisterApp charges nothing @@ -69,6 +75,10 @@ type SyncAppModulesRequest struct { AppID uuid.UUID `json:"app_id"` ModuleCount *int `json:"module_count,omitempty"` Deleted bool `json:"deleted,omitempty"` + // Name, when non-nil, is an app rename: updates the frozen mirror name while + // the app is LIVE (a no-op once deleted, freezing the last-known name). nil + // = no name change this sync (same nil-vs-value pattern as ModuleCount). + Name *string `json:"name,omitempty"` } // SyncAppModulesResponse echoes the roster row's post-sync state. @@ -76,6 +86,7 @@ type SyncAppModulesResponse struct { AppID uuid.UUID `json:"app_id"` ModuleCount int `json:"module_count"` Deleted bool `json:"deleted"` + Name string `json:"name"` } // RegisterApp mirrors a freshly created platform app into ms_billing.apps. It @@ -140,7 +151,7 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg return nil, billing.Internal("ensure billing account failed", err) } - if err := s.store.InsertAppMirror(ctx, req.AppID, accountID, req.ModuleCount, createdAt); err != nil { + if err := s.store.InsertAppMirror(ctx, req.AppID, accountID, req.ModuleCount, createdAt, req.Name); err != nil { return nil, billing.Internal("insert app mirror failed", err) } @@ -249,9 +260,20 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) } } + // Rename — no-op once deleted (frozen name, D1e-style), the same gate as the + // count update above: a live app's bill tracks its current name; a deleted + // app keeps its last-known name for the historical bill (migration 037). + if req.Name != nil && !app.Deleted { + if err := s.store.SetAppName(ctx, req.AppID, *req.Name); err != nil { + return nil, billing.Internal("set app name failed", err) + } + app.Name = *req.Name + } + return &SyncAppModulesResponse{ AppID: app.AppID, ModuleCount: app.ModuleCount, Deleted: app.Deleted, + Name: app.Name, }, nil } diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index 2451396..ec8c4ad 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -40,6 +40,40 @@ func appsSvc(store *fakeStore, sc *fakeStripe) *cycle.Service { // --- RegisterApp: mirror-only (creation grace — RegisterApp never charges) --- +// Migration 037: RegisterApp freezes the app name; SyncAppModules renames it +// while live; a rename AFTER deletion is a no-op (the last-known name survives +// so a deleted app's bill still shows it). +func TestRegisterApp_FreezesNameThatSurvivesDeletion(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + _, err := svc.RegisterApp(ctx, cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), + Name: "影音教育平台", + }) + require.NoError(t, err) + require.Equal(t, "影音教育平台", store.apps[appID].Name, "name frozen on registration") + + // Rename while live → mirror tracks it. + newName := "影音學習平台" + syncResp, err := svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Name: &newName}) + require.NoError(t, err) + require.Equal(t, newName, syncResp.Name) + require.Equal(t, newName, store.apps[appID].Name, "live rename updates the frozen name") + + // Delete, then attempt a rename → frozen (no-op), last-known name kept. + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + frozen := "should-not-apply" + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Name: &frozen}) + require.NoError(t, err) + require.Equal(t, newName, store.apps[appID].Name, "a rename after deletion is a no-op — the last-known name is frozen for the bill") +} + func TestRegisterApp_MirrorsRowWithoutCharging(t *testing.T) { // Creation grace: even a FULLY chargeable account (activated + PM + Stripe // customer) is NOT charged at registration — RegisterApp only mirrors the diff --git a/internal/account/cycle/migration027_integration_test.go b/internal/account/cycle/migration027_integration_test.go index 6adb500..acb1b1b 100644 --- a/internal/account/cycle/migration027_integration_test.go +++ b/internal/account/cycle/migration027_integration_test.go @@ -31,8 +31,8 @@ func TestAppsMirror_Integration_GuardSemantics(t *testing.T) { // Register + a conflicting retry: the FIRST registration's created_at / // module_count survive (ON CONFLICT DO NOTHING). - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, created)) - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 9, created.AddDate(0, 0, 5))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 9, created.AddDate(0, 0, 5), "")) app, found, err := store.AppMirror(ctx, appID) require.NoError(t, err) require.True(t, found) @@ -85,15 +85,15 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { newPeriodStart := mustTime(t, "2026-07-01T00:00:00Z") live1, live2, dead, late := uuid.New(), uuid.New(), uuid.New(), uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, live1, acct, 0, created)) - require.NoError(t, store.InsertAppMirror(ctx, live2, acct, 6, created)) - require.NoError(t, store.InsertAppMirror(ctx, dead, acct, 9, created)) + require.NoError(t, store.InsertAppMirror(ctx, live1, acct, 0, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, live2, acct, 6, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, dead, acct, 9, created, "")) require.NoError(t, store.MarkAppDeleted(ctx, dead)) - require.NoError(t, store.InsertAppMirror(ctx, uuid.New(), other, 3, created)) // another account's app + require.NoError(t, store.InsertAppMirror(ctx, uuid.New(), other, 3, created, "")) // another account's app // Created INSIDE the new period (on the cutoff instant is also out — the // comparison is strict): its new-period base belongs to the RegisterApp // proration leg, never this boundary's advance sum. - require.NoError(t, store.InsertAppMirror(ctx, late, acct, 4, mustTime(t, "2026-07-01T10:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, late, acct, 4, mustTime(t, "2026-07-01T10:00:00Z"), "")) apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) @@ -109,7 +109,7 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { // 2026-07-06): excluded too — it hasn't survived grace, and its creation // charge covers the straddled period. inGrace := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, inGrace, acct, 1, mustTime(t, "2026-06-29T00:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, inGrace, acct, 1, mustTime(t, "2026-06-29T00:00:00Z"), "")) apps, err = store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) require.Len(t, apps, 2, "an app whose creation grace straddles the boundary joins only at the NEXT boundary") diff --git a/internal/account/cycle/migration028_integration_test.go b/internal/account/cycle/migration028_integration_test.go index c44acdc..2668c52 100644 --- a/internal/account/cycle/migration028_integration_test.go +++ b/internal/account/cycle/migration028_integration_test.go @@ -28,7 +28,7 @@ func TestAppBaseSnapshots_Integration_ConflictSemanticsAndRead(t *testing.T) { acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 5, mustTime(t, "2026-06-10T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 5, mustTime(t, "2026-06-10T08:00:00Z"), "")) periodStart := mustTime(t, "2026-06-01T00:00:00Z") periodEnd := mustTime(t, "2026-07-01T00:00:00Z") diff --git a/internal/account/cycle/migration029_integration_test.go b/internal/account/cycle/migration029_integration_test.go index 5a77452..87e9e34 100644 --- a/internal/account/cycle/migration029_integration_test.go +++ b/internal/account/cycle/migration029_integration_test.go @@ -37,11 +37,11 @@ func TestAppsPendingProration_Integration_Filter(t *testing.T) { deletedIn := uuid.New() // deleted WITHIN its grace → excluded (never charged) deletedAfter := uuid.New() // deleted AFTER its grace elapsed → returned (D11: survived, still owes) charged := uuid.New() // guard armed → excluded - require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, now.Add(-10*24*time.Hour))) - require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, now.Add(2*time.Hour))) - require.NoError(t, store.InsertAppMirror(ctx, deletedIn, acct, 0, now.Add(-time.Hour))) - require.NoError(t, store.InsertAppMirror(ctx, deletedAfter, acct, 0, now.Add(-9*24*time.Hour))) - require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, now.Add(-8*24*time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, now.Add(-10*24*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, now.Add(2*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, deletedIn, acct, 0, now.Add(-time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, deletedAfter, acct, 0, now.Add(-9*24*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, now.Add(-8*24*time.Hour), "")) require.NoError(t, store.MarkAppDeleted(ctx, deletedIn)) require.NoError(t, store.MarkAppDeleted(ctx, deletedAfter)) require.NoError(t, store.SetAppProrationInvoice(ctx, charged, "in_already")) @@ -81,7 +81,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // Live, unarmed app → the callback fires, and the invoice + snapshot + guard // commit atomically. live := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, live, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, live, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) called := false outcome, invID, err := store.ChargeProrationLocked(ctx, live, func(l cycle.AppMirror) (*cycle.ProrationCharge, error) { called = true @@ -113,7 +113,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // relative to the real clock because MarkAppDeleted stamps now() — a fixed // past date would make this a post-grace delete, which D11 charges. deleted := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, time.Now().UTC().Add(-time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, time.Now().UTC().Add(-time.Hour), "")) require.NoError(t, store.MarkAppDeleted(ctx, deleted)) called = false outcome, _, err = store.ChargeProrationLocked(ctx, deleted, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { @@ -126,7 +126,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // Callback declines (0 cents) → NoCharge, guard stays unarmed, nothing persisted. zero := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, zero, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, zero, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) outcome, _, err = store.ChargeProrationLocked(ctx, zero, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { return nil, nil }) diff --git a/internal/account/cycle/migration030_integration_test.go b/internal/account/cycle/migration030_integration_test.go index 803ac27..3c8039c 100644 --- a/internal/account/cycle/migration030_integration_test.go +++ b/internal/account/cycle/migration030_integration_test.go @@ -26,7 +26,7 @@ func TestCreatedModuleCount_Integration_FrozenAcrossSetAppModuleCount(t *testing acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, mustTime(t, "2026-07-01T08:00:00Z"), "")) app, found, err := store.AppMirror(ctx, appID) require.NoError(t, err) @@ -64,8 +64,8 @@ func TestCreatedModuleCount_Integration_RetryKeepsFirstRegistrationsFrozenCount( acct := seedAccount(t, pool) appID := uuid.New() created := mustTime(t, "2026-07-01T08:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 3, created)) - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 12, created)) // retry, different count + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 3, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 12, created, "")) // retry, different count app, _, err := store.AppMirror(ctx, appID) require.NoError(t, err) diff --git a/internal/account/cycle/migration031_integration_test.go b/internal/account/cycle/migration031_integration_test.go index d957940..9f40eac 100644 --- a/internal/account/cycle/migration031_integration_test.go +++ b/internal/account/cycle/migration031_integration_test.go @@ -28,7 +28,7 @@ func TestProrationSkipped_Integration_OneShotAndExcludedFromPending(t *testing.T cutoff := mustTime(t, "2026-04-05T00:00:00Z") skipped := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, skipped, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, skipped, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) // Past grace, unarmed, not yet skipped → pending. ids, err := store.AppsPendingProration(ctx, cutoff) @@ -66,7 +66,7 @@ func TestProrationSkipped_Integration_RefusesToSkipAnAlreadyChargedApp(t *testin acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) require.NoError(t, store.SetAppProrationInvoice(ctx, appID, "in_already_charged")) require.NoError(t, store.SetAppProrationSkipped(ctx, appID)) // no-op, not an error diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 96943e2..d956e32 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -31,7 +31,7 @@ func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { require.NoError(t, err) app := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, app, acct, 0, mustTime(t, "2026-06-01T00:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, app, acct, 0, mustTime(t, "2026-06-01T00:00:00Z"), "")) // 5 "included" installs anchored early + 1 "over" install anchored June 10. early := mustTime(t, "2026-05-04T00:00:00Z") @@ -109,7 +109,7 @@ func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t acct := seedAccount(t, pool) app := uuid.New() created := mustTime(t, "2026-06-19T12:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, app, acct, 7, created)) + require.NoError(t, store.InsertAppMirror(ctx, app, acct, 7, created, "")) const workers = 8 errs := make(chan error, workers) @@ -164,8 +164,8 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { appA, appB := uuid.New(), uuid.New() created := mustTime(t, "2026-06-19T12:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, appA, acct, 7, created)) - require.NoError(t, store.InsertAppMirror(ctx, appB, acct, 0, created)) + require.NoError(t, store.InsertAppMirror(ctx, appA, acct, 7, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appB, acct, 0, created, "")) // appA: 7 co-created install timers at created_at → FIFO ranks 0-6, so 2 are // "over" (rank ≥ IncludedModules=5). diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 1397f80..5080f09 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -479,7 +479,7 @@ func (f *fakeStore) AccountActivation(_ context.Context, accountID uuid.UUID) (t return at, ok, nil } -func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time) error { +func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { if f.errAppInsert != nil { return f.errAppInsert } @@ -490,6 +490,15 @@ func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUI AppID: appID, AccountID: accountID, ModuleCount: moduleCount, CreatedModuleCount: moduleCount, // frozen at insert, mirroring InsertAppMirror's $3/$3 write CreatedAt: createdAt, + Name: name, // frozen on first registration (migration 037) + } + return nil +} + +func (f *fakeStore) SetAppName(_ context.Context, appID uuid.UUID, name string) error { + if app, ok := f.apps[appID]; ok && !app.Deleted { // no-op once deleted (WHERE deleted_at IS NULL) + app.Name = name + f.apps[appID] = app } return nil } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index e74664d..80ca04f 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -200,8 +200,14 @@ type Store interface { // InsertAppMirror registers a ms_billing.apps roster row idempotently // (ON CONFLICT (app_id) DO NOTHING — a retry never rewrites the original - // created_at / module_count, which anchor the proration). - InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time) error + // created_at / module_count / name, which anchor the proration + freeze the + // display name). name "" writes NULL. + InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error + + // SetAppName updates the frozen display name (SyncAppModules rename); + // no-op once the app is deleted (WHERE deleted_at IS NULL), freezing the + // last-known name for the bill. + SetAppName(ctx context.Context, appID uuid.UUID, name string) error // AppMirror reads one roster row (deleted rows included — the caller owns // deletion semantics). found=false → the app was never registered. @@ -451,6 +457,10 @@ type AppMirror struct { ModuleCount int CreatedModuleCount int CreatedAt time.Time + // Name: the frozen app display name (migration 037) — "" when NULL. Written + // by RegisterApp / SyncAppModules (freeze-on-delete) so a deleted app's bill + // still shows its last-known name. + Name string ProrationInvoiceID string ProrationSkipped bool // ProrationAttempted: a prior creation-proration charge attempt reached its @@ -1086,13 +1096,24 @@ func (s *pgxStore) AccountActivation(ctx context.Context, accountID uuid.UUID) ( return at.Time, true, nil } -func (s *pgxStore) InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time) error { +func (s *pgxStore) InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { // RowsAffected 0 = a retry hit ON CONFLICT DO NOTHING — success either way. _, err := s.q.InsertAppMirror(ctx, db.InsertAppMirrorParams{ AppID: appID.String(), AccountID: accountID.String(), ModuleCount: int32(moduleCount), //nolint:gosec // RegisterApp validates 0 ≤ count ≤ maxModuleCount (100000), far below int32 max CreatedAt: createdAt, + Name: pgtype.Text{String: name, Valid: name != ""}, // NULL when the caller omits a name (frontend falls back) + }) + return err +} + +func (s *pgxStore) SetAppName(ctx context.Context, appID uuid.UUID, name string) error { + // 0 rows = the app is deleted (frozen name, WHERE deleted_at IS NULL) — a + // documented no-op, the same posture as SetAppModuleCount on a deleted app. + _, err := s.q.SetAppName(ctx, db.SetAppNameParams{ + AppID: appID.String(), + Name: pgtype.Text{String: name, Valid: name != ""}, }) return err } @@ -1119,6 +1140,7 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b ModuleCount: int(row.ModuleCount), CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, + Name: row.Name.String, // "" when NULL (pre-037 / unnamed) ProrationInvoiceID: row.ProrationInvoiceID.String, // "" when NULL (guard unarmed) ProrationSkipped: row.ProrationSkippedAt.Valid, ProrationAttempted: row.ProrationAttemptedAt.Valid, diff --git a/internal/account/cycle/store_prorationlock_integration_test.go b/internal/account/cycle/store_prorationlock_integration_test.go index 11a75cd..108c7fd 100644 --- a/internal/account/cycle/store_prorationlock_integration_test.go +++ b/internal/account/cycle/store_prorationlock_integration_test.go @@ -43,7 +43,7 @@ func TestChargeProrationLocked_Integration_LockNotHeldAcrossStripeCall(t *testin acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) insideCallback := make(chan struct{}) release := make(chan struct{}) @@ -96,7 +96,7 @@ func TestChargeProrationLocked_Integration_ConcurrentDeleteDoesNotBlockOnLock(t acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) insideCallback := make(chan struct{}) release := make(chan struct{}) @@ -155,7 +155,7 @@ func TestChargeProrationLocked_Integration_PersistsLargeAutoCollectFlag(t *testi acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) pc := mkProrationCharge(acct, appID, "in_large_flag", mustTime(t, "2026-07-04T00:00:00Z")) pc.Invoice.IsLargeAutoCollect = true diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 8131a40..d8a3d96 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -97,16 +97,17 @@ func (q *Queries) InsertAdvanceBaseSnapshot(ctx context.Context, arg InsertAdvan const insertAppMirror = `-- name: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) -VALUES ($1, $2, $3, $3, $4) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name) +VALUES ($1, $2, $3, $3, $4, $5) ON CONFLICT (app_id) DO NOTHING ` type InsertAppMirrorParams struct { - AppID string `json:"app_id"` - AccountID string `json:"account_id"` - ModuleCount int32 `json:"module_count"` - CreatedAt time.Time `json:"created_at"` + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedAt time.Time `json:"created_at"` + Name pgtype.Text `json:"name"` } // Queries backing the ms_billing.apps mirror (migration 027) — the base-fee @@ -123,12 +124,16 @@ type InsertAppMirrorParams struct { // from, immune to a later SyncAppModules install/uninstall during grace. // :execrows so the caller can tell a fresh insert (1) from a retry no-op (0), // though both are success. +// name ($5) is frozen from the FIRST registration like created_at / +// module_count (ON CONFLICT DO NOTHING keeps the first value across retries); +// SyncAppModules updates it while the app is live (SetAppName). func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams) (int64, error) { result, err := q.db.Exec(ctx, insertAppMirror, arg.AppID, arg.AccountID, arg.ModuleCount, arg.CreatedAt, + arg.Name, ) if err != nil { return 0, err @@ -325,7 +330,7 @@ func (q *Queries) SelectAppBaseSnapshot(ctx context.Context, arg SelectAppBaseSn } const selectAppMirror = `-- name: SelectAppMirror :one -SELECT app_id, account_id, module_count, created_module_count, created_at, +SELECT app_id, account_id, module_count, created_module_count, created_at, name, proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 @@ -337,6 +342,7 @@ type SelectAppMirrorRow struct { ModuleCount int32 `json:"module_count"` CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` + Name pgtype.Text `json:"name"` ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` @@ -355,6 +361,7 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM &i.ModuleCount, &i.CreatedModuleCount, &i.CreatedAt, + &i.Name, &i.ProrationInvoiceID, &i.ProrationSkippedAt, &i.ProrationAttemptedAt, @@ -364,7 +371,7 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM } const selectAppMirrorForUpdate = `-- name: SelectAppMirrorForUpdate :one -SELECT app_id, account_id, module_count, created_module_count, created_at, +SELECT app_id, account_id, module_count, created_module_count, created_at, name, proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 @@ -377,6 +384,7 @@ type SelectAppMirrorForUpdateRow struct { ModuleCount int32 `json:"module_count"` CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` + Name pgtype.Text `json:"name"` ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` @@ -400,6 +408,7 @@ func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (S &i.ModuleCount, &i.CreatedModuleCount, &i.CreatedAt, + &i.Name, &i.ProrationInvoiceID, &i.ProrationSkippedAt, &i.ProrationAttemptedAt, @@ -433,6 +442,30 @@ func (q *Queries) SetAppModuleCount(ctx context.Context, arg SetAppModuleCountPa return result.RowsAffected(), nil } +const setAppName = `-- name: SetAppName :execrows +UPDATE ms_billing.apps +SET name = $2 +WHERE app_id = $1 + AND deleted_at IS NULL +` + +type SetAppNameParams struct { + AppID string `json:"app_id"` + Name pgtype.Text `json:"name"` +} + +// SetAppName updates the frozen display name (SyncAppModules rename path). +// WHERE deleted_at IS NULL freezes the name once deleted — the same +// freeze-on-delete posture as SetAppModuleCount, so a rename after deletion is +// a documented no-op (0 rows), keeping the last-known name for the bill. +func (q *Queries) SetAppName(ctx context.Context, arg SetAppNameParams) (int64, error) { + result, err := q.db.Exec(ctx, setAppName, arg.AppID, arg.Name) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const setAppProrationInvoice = `-- name: SetAppProrationInvoice :execrows UPDATE ms_billing.apps SET proration_invoice_id = $2 diff --git a/internal/account/db/models.go b/internal/account/db/models.go index 5b92ac7..883f5f1 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -360,6 +360,8 @@ type MsBillingApp struct { ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` // First instant a creation-proration charge attempt for this app reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set and an unarmed guard reconciles against Stripe (ms_charge_ref app-proration:) before minting new Stripe objects. ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` + // App display name, frozen from RegisterApp's payload and updated by SyncAppModules while the app is live (gated on deleted_at IS NULL); NEVER cleared on delete — this is what lets a deleted app's historical bill still show its name. NULL for pre-037 rows / callers that omit it. + Name pgtype.Text `json:"name"` } type MsBillingAppBaseSnapshot struct { diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index f0f5a3b..6d249a1 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -14,15 +14,18 @@ -- :execrows so the caller can tell a fresh insert (1) from a retry no-op (0), -- though both are success. -- name: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) -VALUES ($1, $2, $3, $3, $4) +-- name ($5) is frozen from the FIRST registration like created_at / +-- module_count (ON CONFLICT DO NOTHING keeps the first value across retries); +-- SyncAppModules updates it while the app is live (SetAppName). +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name) +VALUES ($1, $2, $3, $3, $4, $5) ON CONFLICT (app_id) DO NOTHING; -- SelectAppMirror reads one roster row (deleted or not — the caller decides -- what deletion means for its path: SyncAppModules no-ops a count update, -- GetAppBill still displays the spent creation-period base). -- name: SelectAppMirror :one -SELECT app_id, account_id, module_count, created_module_count, created_at, +SELECT app_id, account_id, module_count, created_module_count, created_at, name, proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1; @@ -36,7 +39,7 @@ WHERE app_id = $1; -- SyncAppModules soft-delete (MarkAppDeleted) only ever contends for the brief -- read, never for the duration of a Stripe HTTP call. -- name: SelectAppMirrorForUpdate :one -SELECT app_id, account_id, module_count, created_module_count, created_at, +SELECT app_id, account_id, module_count, created_module_count, created_at, name, proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 @@ -109,6 +112,16 @@ SET module_count = $2 WHERE app_id = $1 AND deleted_at IS NULL; +-- SetAppName updates the frozen display name (SyncAppModules rename path). +-- WHERE deleted_at IS NULL freezes the name once deleted — the same +-- freeze-on-delete posture as SetAppModuleCount, so a rename after deletion is +-- a documented no-op (0 rows), keeping the last-known name for the bill. +-- name: SetAppName :execrows +UPDATE ms_billing.apps +SET name = $2 +WHERE app_id = $1 + AND deleted_at IS NULL; + -- MarkAppDeleted soft-deletes the roster row out of future advance base fees. -- WHERE deleted_at IS NULL keeps the FIRST deletion instant (idempotent — a -- re-fire affects 0 rows and never moves the timestamp). diff --git a/internal/account/usage/accountbill.go b/internal/account/usage/accountbill.go index dd2544d..3fb0b95 100644 --- a/internal/account/usage/accountbill.go +++ b/internal/account/usage/accountbill.go @@ -159,6 +159,8 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) } apps = append(apps, AccountAppBill{ AppID: appID, + Name: parts.Name, + IsDeleted: parts.IsDeleted, BaseFeeMicros: parts.BaseFeeMicros, ModuleUsageMicros: parts.ModuleUsageTotalMicros, InfraMicros: parts.InfraTotalMicros, diff --git a/internal/account/usage/accountbill_test.go b/internal/account/usage/accountbill_test.go index f32a94f..eb65ac2 100644 --- a/internal/account/usage/accountbill_test.go +++ b/internal/account/usage/accountbill_test.go @@ -210,6 +210,36 @@ func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { "un-snapshotted current period estimates the pooled overage from the live pool 8 → 3 over → $9") } +// Migration 037: the account bill carries each app's FROZEN name + a deleted +// flag, and — the hoist guard — they show even on a CHARGED (snapshotted) +// period, where pre-037 the mirror was never read. A deleted app keeps its +// last-known name so its bill row renders the name instead of "unknown app". +func TestGetAccountBill_FrozenNameAndDeletedFlagOnChargedPeriod(t *testing.T) { + store := newFakeStore() + owner := uuid.New() + store.accounts[owner] = uuid.New() + pid := mirrorPeriod(store) + app := seqUUID(1) + // A DELETED app with a frozen name, and a CHARGED (snapshotted) base for the + // period — the snapshot path, which pre-hoist skipped the mirror read. + store.appMirrors[app] = usage.AppMirrorInfo{ + ModuleCount: 0, CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), + Name: "影音教育平台", Deleted: true, DeletedAt: time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC), + } + store.baseSnapshots[baseSnapKey(app, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC))] = usage.AppBaseSnapshotInfo{ + BaseMicros: usage.BaseFeeMicros, + } + + resp, err := newService(store).GetAccountBill(context.Background(), usage.GetAccountBillRequest{ + OwnerUserID: owner, PeriodID: pid.String(), + }) + require.NoError(t, err) + require.Len(t, resp.Apps, 1) + require.Equal(t, "影音教育平台", resp.Apps[0].Name, "the frozen name shows even on a charged/snapshotted period (hoist guard)") + require.True(t, resp.Apps[0].IsDeleted, "the server-authoritative deleted flag") + require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "the charged base is unaffected") +} + // --- period resolution -------------------------------------------------------- func TestGetAccountBill_EmptyPeriodIDResolvesCurrentWindow(t *testing.T) { diff --git a/internal/account/usage/bill.go b/internal/account/usage/bill.go index 296aeb7..bb703a2 100644 --- a/internal/account/usage/bill.go +++ b/internal/account/usage/bill.go @@ -213,6 +213,8 @@ func (s *Service) GetAppBill(ctx context.Context, req GetAppBillRequest) (*GetAp return &GetAppBillResponse{ AppID: req.AppID, + Name: parts.Name, + IsDeleted: parts.IsDeleted, PeriodID: periodID, PeriodStart: periodStart, PeriodEnd: periodEnd, @@ -283,6 +285,12 @@ type appBillParts struct { InfraTotalMicros int64 InfraLines []AppInfraUsage ModuleInfraLines []AppModuleInfraUsage + // Name is the frozen app display name (migration 037) — "" when the app was + // never mirrored or was registered pre-037. IsDeleted is the server- + // authoritative removal flag; the bill page reads it to show a deleted app's + // charges in a dialog instead of linking to the (gone) app page. + Name string + IsDeleted bool } // computeAppBill computes one app's pre-credit bill parts for the resolved @@ -384,6 +392,17 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found // from the mirror's CURRENT module_count (or, with no mirror row at all, // the pre-027 flat fee + usage-proxy overage — see the // INSTALLED-MODULE-COUNT note above). + // + // The mirror is read UNCONDITIONALLY (migration 037): it carries the frozen + // name + the deleted flag, which the bill must surface on EVERY period — + // including already-charged (snapshotted) periods — so a deleted app's + // historical rows still show its name. (Pre-037 this read was nested in the + // un-snapshotted estimate branch only.) The base-fee resolution below still + // prefers the snapshot; the mirror only drives the estimate fallbacks. + mirror, mirrored, err := s.store.AppMirror(ctx, appID) + if err != nil { + return nil, billing.Internal("app mirror lookup failed", err) + } var baseFee int64 snap, snapped, err := s.store.AppBaseSnapshot(ctx, appID, periodStart) if err != nil { @@ -391,14 +410,9 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found } if snapped { // This period's base was charged: display EXACTLY what was invoiced. - // The snapshot alone decides — the mirror is only read on the - // un-snapshotted estimate paths below. + // The snapshot alone decides — the mirror only drives the estimate paths. baseFee = snap.BaseMicros } else { - mirror, mirrored, err := s.store.AppMirror(ctx, appID) - if err != nil { - return nil, billing.Internal("app mirror lookup failed", err) - } switch { case mirrored && mirror.Deleted && !mirror.DeletedAt.After(periodStart): // Deleted BEFORE this period opened → no base was (or will be) charged @@ -426,6 +440,8 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found InfraTotalMicros: infraTotal, InfraLines: infraLines, ModuleInfraLines: moduleInfraLines, + Name: mirror.Name, // "" when not mirrored / pre-037 + IsDeleted: mirrored && mirror.Deleted, }, nil } diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index 452d3c8..b0b213d 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -234,6 +234,7 @@ type AppBaseSnapshotInfo struct { type AppMirrorInfo struct { ModuleCount int CreatedAt time.Time + Name string // frozen display name (migration 037); "" when NULL Deleted bool DeletedAt time.Time } @@ -569,6 +570,7 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirrorInf return AppMirrorInfo{ ModuleCount: int(row.ModuleCount), CreatedAt: row.CreatedAt, + Name: row.Name.String, // "" when NULL (pre-037 / unnamed) Deleted: row.DeletedAt.Valid, DeletedAt: row.DeletedAt.Time, }, true, nil diff --git a/internal/account/usage/types.go b/internal/account/usage/types.go index f471e66..01af075 100644 --- a/internal/account/usage/types.go +++ b/internal/account/usage/types.go @@ -385,6 +385,11 @@ type GetAppBillRequest struct { type GetAppBillResponse struct { // AppID echoes the requested app (self-describing per-app bill). AppID uuid.UUID `json:"app_id"` + // Name is the app's frozen display name (migration 037), "" for pre-037 / + // unnamed rows. IsDeleted is the server-authoritative removal flag — a + // deleted app's bill still resolves (base spent, D1e) and carries its name. + Name string `json:"name,omitempty"` + IsDeleted bool `json:"is_deleted"` // PeriodID echoes the resolved period id — empty ("") for the current live // period (which has no billing_periods row yet), the real id for a past one. PeriodID string `json:"period_id"` @@ -500,6 +505,15 @@ type AccountPlan struct { // usage lines). type AccountAppBill struct { AppID uuid.UUID `json:"app_id"` + // Name is the app's frozen display name (migration 037), "" for pre-037 / + // unnamed rows (the frontend then falls back to its own registry lookup). + // Freezing it in billing is what lets a DELETED app's row still show its + // name instead of "unknown app". + Name string `json:"name,omitempty"` + // IsDeleted is the server-authoritative removal flag — the bill page reads + // it to show this app's charges in a dialog rather than linking to the + // (now-gone) app page. + IsDeleted bool `json:"is_deleted"` // BaseFeeMicros is this app's 基本費用 for the period, resolved SNAPSHOT- // FIRST exactly like GetAppBill (charged periods show what was invoiced; // un-charged ones the live mirror estimate, prorated for a creation period). diff --git a/migrations/billing/037_apps_name.down.sql b/migrations/billing/037_apps_name.down.sql new file mode 100644 index 0000000..6674c33 --- /dev/null +++ b/migrations/billing/037_apps_name.down.sql @@ -0,0 +1,3 @@ +-- 037 down: drop the frozen app-name column. +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS name; diff --git a/migrations/billing/037_apps_name.up.sql b/migrations/billing/037_apps_name.up.sql new file mode 100644 index 0000000..10725e8 --- /dev/null +++ b/migrations/billing/037_apps_name.up.sql @@ -0,0 +1,21 @@ +-- Migration 037 — freeze the app display name in the billing mirror. +-- +-- ms_billing.apps was an existence mirror keyed on app_id with NO human name +-- (billing-engine deliberately held no app names — the display name was +-- resolved downstream from the live app registry by app_id). That breaks the +-- moment an app is DELETED: it vanishes from the registry, so its historical +-- bill rows can no longer resolve a name and render as "unknown app". +-- +-- Freezing the name here makes the bill self-contained (the same posture as +-- created_module_count / app_base_snapshots): RegisterApp stamps the initial +-- name, SyncAppModules updates it while the app is live, and it is NEVER +-- cleared on delete — so a deleted app's bill still shows its last-known name. +-- +-- Nullable: pre-migration rows and any RegisterApp caller that omits the name +-- stay NULL (surfaced as empty → the frontend's existing registry fallback). + +ALTER TABLE ms_billing.apps + ADD COLUMN name TEXT NULL; + +COMMENT ON COLUMN ms_billing.apps.name IS + 'App display name, frozen from RegisterApp''s payload and updated by SyncAppModules while the app is live (gated on deleted_at IS NULL); NEVER cleared on delete — this is what lets a deleted app''s historical bill still show its name. NULL for pre-037 rows / callers that omit it.'; diff --git a/scripts/init-db.sql b/scripts/init-db.sql index ed2373a..f4ebd08 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -42,3 +42,15 @@ \i migrations/billing/027_apps_mirror.up.sql -- 028: per-app-period base snapshots (display == invoice). \i migrations/billing/028_app_base_snapshots.up.sql + +-- 029–037: base-fee v2 — creation grace, per-module overage timers, auto-collect +-- disclosure, frozen boundary charge, crash-recovery markers, frozen app name. +\i migrations/billing/029_apps_proration_sweep_idx.up.sql +\i migrations/billing/030_apps_created_module_count.up.sql +\i migrations/billing/031_apps_proration_skipped.up.sql +\i migrations/billing/032_account_wide_overage.up.sql +\i migrations/billing/033_app_module_overage_timers.up.sql +\i migrations/billing/034_auto_collect_disclosure.up.sql +\i migrations/billing/035_billing_run_frozen_charge.up.sql +\i migrations/billing/036_charge_attempt_markers.up.sql +\i migrations/billing/037_apps_name.up.sql