Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion internal/account/cycle/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,13 +75,18 @@ 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.
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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
34 changes: 34 additions & 0 deletions internal/account/cycle/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions internal/account/cycle/migration027_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/account/cycle/migration028_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 8 additions & 8 deletions internal/account/cycle/migration029_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
})
Expand Down
6 changes: 3 additions & 3 deletions internal/account/cycle/migration030_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/account/cycle/migration031_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/account/cycle/migration033_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
11 changes: 10 additions & 1 deletion internal/account/cycle/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
28 changes: 25 additions & 3 deletions internal/account/cycle/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
Loading
Loading