Skip to content
Closed
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
6 changes: 6 additions & 0 deletions cmd/account-api/infra_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ func (stubUsageStore) AppIDsWithUsage(context.Context, uuid.UUID, time.Time, tim
func (stubUsageStore) MirroredAppIDs(context.Context, uuid.UUID, time.Time, time.Time) ([]uuid.UUID, error) {
return nil, nil
}
func (stubUsageStore) PooledModuleCount(context.Context, uuid.UUID) (int, error) {
return 0, nil
}
func (stubUsageStore) AccountOverageSnapshot(context.Context, uuid.UUID, time.Time) (int64, bool, error) {
return 0, false, nil
}

func newRouterForTest(t *testing.T) http.Handler {
t.Helper()
Expand Down
57 changes: 55 additions & 2 deletions cmd/billing-cycle/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ func main() {
"as_of", res.AsOf,
"activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged,
"skipped_no_pm", res.SkippedNoPM, "zero_arrears", res.ZeroArrears,
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed)
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed,
"overage_candidates", res.OverageCandidates, "overage_charged", res.OverageCharged,
"overage_skipped", res.OverageSkipped, "overage_failed", res.OverageFailed)
if res.Failed > 0 {
os.Exit(1)
}
Expand Down Expand Up @@ -107,7 +109,9 @@ func handler(svc *cycle.Service) func(context.Context, events.CloudWatchEvent) e
"as_of", res.AsOf,
"activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged,
"skipped_no_pm", res.SkippedNoPM, "zero_arrears", res.ZeroArrears,
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed)
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed,
"overage_candidates", res.OverageCandidates, "overage_charged", res.OverageCharged,
"overage_skipped", res.OverageSkipped, "overage_failed", res.OverageFailed)
// A per-account charge failure is recorded (billing_runs status='failed')
// and does NOT fail the batch — the next cycle retries it. The handler
// returns nil so EventBridge doesn't replay the whole batch.
Expand All @@ -127,6 +131,12 @@ type cycleResult struct {
AlreadyRun int
FailedRuns int // per-account charge runs that ended status='failed'
Failed int // errors (rollup error, charge error, or list error)

// Mid-period account-wide overage grace sweep (migration 030).
OverageCandidates int // accounts past the grace window this sweep evaluated
OverageCharged int // accounts whose pooled overage was invoiced mid-period
OverageSkipped int // evaluated but not charged (already billed / under pool / no PM / 0 cents)
OverageFailed int // per-account overage-charge errors (counted, never abort)
}

// runCycle closes every card-bound account's just-ended ANCHORED period as of
Expand Down Expand Up @@ -222,9 +232,52 @@ func runCycle(ctx context.Context, svc *cycle.Service, at time.Time) cycleResult
}
tally(&res, a.ID, chargeSummary)
}

// Mid-period account-wide overage grace sweep (migration 030): independent of
// the boundary close above. Every account whose pooled module overage has
// survived the grace window and whose CURRENT period has no pooled-overage
// snapshot yet is charged the prorated overage now (a deliberate mid-period
// charge). Idempotent per (account, period) via the snapshot ledger + the
// deterministic Stripe idem keys, so firing daily never double-charges.
runOverageSweep(ctx, svc, at, &res)
return res
}

// runOverageSweep charges the mid-period account-wide pooled overage for every
// account past the grace window as of `at`. A single account's error is logged +
// counted but never aborts the sweep (like the boundary loop).
func runOverageSweep(ctx context.Context, svc *cycle.Service, at time.Time, res *cycleResult) {
cands, err := svc.AccountsInOverageGrace(ctx, at)
if err != nil {
slog.ErrorContext(ctx, "list overage-grace accounts failed", "error", err)
res.Failed++
return
}
res.OverageCandidates = len(cands)
for _, c := range cands {
summary, err := svc.ChargeAccountOverage(ctx, c, at)
if err != nil {
slog.ErrorContext(ctx, "account overage charge failed", "account_id", c.ID, "error", err)
res.OverageFailed++
res.Failed++
continue
}
if summary.Status == cycle.OverageCharged || summary.Status == cycle.OverageToppedUp {
// A top-up (finding #3 — an ALREADY-charged period whose pool grew
// further before the period closed) is also money charged, not a skip.
res.OverageCharged++
} else {
res.OverageSkipped++
}
slog.Info("account overage grace sweep",
"account_id", c.ID,
"status", string(summary.Status),
"over_count", summary.OverCount,
"charged_cents", summary.ChargedCents,
"stripe_invoice_id", summary.StripeInvoiceID)
}
}

// tally classifies one account's charge summary for the run totals + a
// per-account info log. RunBillingCycle returns (nil, err) on a charge failure
// — that path is counted in runCycle, not here — but the RunStatusFailed case is
Expand Down
6 changes: 6 additions & 0 deletions cmd/infra-egress-sync/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@ func (f *fakeStore) AppIDsWithUsage(_ context.Context, _ uuid.UUID, _, _ time.Ti
func (f *fakeStore) MirroredAppIDs(_ context.Context, _ uuid.UUID, _, _ time.Time) ([]uuid.UUID, error) {
return nil, nil
}
func (f *fakeStore) PooledModuleCount(_ context.Context, _ uuid.UUID) (int, error) {
return 0, nil
}
func (f *fakeStore) AccountOverageSnapshot(_ context.Context, _ uuid.UUID, _ time.Time) (int64, bool, error) {
return 0, false, nil
}

func newSvc(store usage.Store) *usage.Service { return usage.NewService(store) }

Expand Down
49 changes: 38 additions & 11 deletions internal/account/cycle/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,14 @@ type SyncAppModulesResponse struct {
// PM; otherwise the row is recorded and NO invoice is created (no
// retroactive catch-up on activation in v1). The one-shot guard
// proration_invoice_id short-circuits a retry that already charged.
// Amount = ProratedBaseMicros(AppBaseFeeMicros(base, module_count),
// created_at, the anchored period CONTAINING created_at) — whole UTC
// days, creation day inclusive, round-half-up — converted micros → whole
// cents at the Stripe boundary like every other charge. 0 cents → row
// recorded, no invoice.
// Amount = ProratedBaseMicros(BaseFeeMicros, created_at, the anchored
// period CONTAINING created_at) — the FLAT per-app base only (module
// overage is account-wide pooled, migration 030), whole UTC days, creation
// day inclusive, round-half-up — converted micros → whole cents at the
// Stripe boundary like every other charge. 0 cents → row recorded, no
// invoice. The insert ALSO recomputes the account's pooled-overage grace
// timer (recomputeAccountOverage) so a creation that pushes the account
// over IncludedModules arms accounts.overage_since.
//
// INVARIANT (charge-leg ownership): the proration window is derived from
// the read-back mirror row's created_at (the stable first-registration
Expand Down Expand Up @@ -175,6 +178,16 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg
}
resp := &RegisterAppResponse{AppID: app.AppID, AccountID: app.AccountID}

// Recompute the account-wide pooled overage timer (migration 030) after the
// insert: the new app's module_count may push the account's pool over
// IncludedModules (arming accounts.overage_since) — independent of the
// per-app creation-proration charge below, which only ever bills the FLAT
// base. Idempotent (Start/Clear are first-crossing-wins / no-op), so a retry
// re-runs it harmlessly.
if err := s.recomputeAccountOverage(ctx, app.AccountID); err != nil {
return nil, err
}

// One-shot guard: a prior attempt already charged (or a concurrent one
// won). Idempotent success — NEVER a second invoice.
if app.ProrationInvoiceID != "" {
Expand Down Expand Up @@ -220,10 +233,10 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg
return resp, nil
}

prorated := usage.ProratedBaseMicros(
usage.AppBaseFeeMicros(usage.BaseFeeMicros, app.ModuleCount),
app.CreatedAt, periodStart, periodEnd,
)
// The creation-proration charge is the FLAT per-app base only (migration
// 030 — module overage is account-wide pooled, billed by the grace sweep /
// boundary at the account level, never folded into this per-app proration).
prorated := usage.ProratedBaseMicros(usage.BaseFeeMicros, app.CreatedAt, periodStart, periodEnd)
cents, err := centsFromMicros(prorated)
if err != nil {
return nil, billing.Internal("micros to cents conversion failed", err)
Expand Down Expand Up @@ -308,8 +321,14 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg
// — there is no future base for the tier to move);
// - an unknown app_id is NOT_FOUND (the platform must RegisterApp first).
//
// Count changes take effect at the NEXT boundary charge (D1b — no mid-period
// micro-invoices for module #6, no mid-period refunds for uninstalls).
// After any module_count / delete write it recomputes the account's pooled-
// overage grace timer (recomputeAccountOverage). The per-app FLAT base still
// takes effect at the NEXT boundary (no mid-period base micro-invoice / refund),
// but the ACCOUNT-WIDE pooled overage is now the DELIBERATE exception to the old
// D1b "no mid-period charges" rule: crossing the pooled IncludedModules arms a
// grace timer, and the mid-period sweep charges the pooled overage once the
// grace window elapses (migration 030). Dropping back under the pool clears the
// timer (no refund of anything already charged, D1e).
func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) (*SyncAppModulesResponse, error) {
if req.AppID == uuid.Nil {
return nil, billing.InvalidInput("app_id required")
Expand Down Expand Up @@ -345,6 +364,14 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest)
app.ModuleCount = *req.ModuleCount
}

// Recompute the account-wide pooled-overage grace timer (migration 030) after
// the write: a count bump or delete can push the pool over IncludedModules
// (arm overage_since) or drop it back under (clear it). Idempotent, so a
// pure-delete-of-an-already-deleted-app retry re-runs it harmlessly.
if err := s.recomputeAccountOverage(ctx, app.AccountID); err != nil {
return nil, err
}

return &SyncAppModulesResponse{
AppID: app.AppID,
ModuleCount: app.ModuleCount,
Expand Down
52 changes: 35 additions & 17 deletions internal/account/cycle/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,13 @@ func TestRegisterApp_ChargesCreationProration(t *testing.T) {
require.Equal(t, 0, snap.snap.ModuleCount)
}

func TestRegisterApp_ProrationIncludesModuleOverage(t *testing.T) {
// module_count 7 at creation → base_at_creation = 20e6 + 2×3e6 = 26e6;
// 15 of 30 remaining days → 13e6 micros → 1300 cents.
func TestRegisterApp_ProrationIsFlatBaseRegardlessOfModuleCount(t *testing.T) {
// Migration 030: module overage is account-wide POOLED, so the per-app
// creation proration is the FLAT $20 base regardless of module_count — a
// 7-module app prorates EXACTLY like a 0-module app. 15 of 30 remaining days
// → 20e6 × 15/30 = 10e6 micros → 1000 cents (NOT the pre-030 26e6 → 1300).
store := newFakeStore()
user, _ := registeredAccount(store)
user, acct := registeredAccount(store)
sc := newFakeStripe()

resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{
Expand All @@ -99,7 +101,10 @@ func TestRegisterApp_ProrationIncludesModuleOverage(t *testing.T) {
CreatedAt: time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC),
})
require.NoError(t, err)
require.EqualValues(t, 1300, resp.ProrationCents)
require.EqualValues(t, 1000, resp.ProrationCents, "flat base proration — overage is pooled, not per-app")

// The 7-module create pushed the account pool over 5, arming the grace timer.
require.Contains(t, store.overageSince, acct, "crossing the pooled 5 arms overage_since")
}

func TestRegisterApp_DefaultsCreatedAtToNow(t *testing.T) {
Expand Down Expand Up @@ -445,9 +450,11 @@ func seedAppCreated(store *fakeStore, accountID uuid.UUID, moduleCount int, dele
return id
}

func TestRunBillingCycle_InvoicesUsagePlusAdvanceBase(t *testing.T) {
// arrears 1e6 (usage) + base (20e6 flat + [20e6 + 1×3e6] for a 6-module
// app) = 44e6 total → 4400 cents on ONE invoice.
func TestRunBillingCycle_InvoicesUsagePlusAdvanceBasePlusPooledOverage(t *testing.T) {
// Migration 030: arrears 1e6 (usage) + FLAT base (2 × 20e6 = 40e6) + the
// account-wide POOLED overage (pool = 0 + 6 = 6 → 1 over → $3 = 3e6) = 44e6
// total → 4400 cents on ONE invoice. Same total as the pre-030 per-app tier
// for this single-account case, but split base-vs-overage differently.
store := newFakeStore()
store.chargedTotal = 1_000_000
store.hasPM = true
Expand All @@ -460,23 +467,31 @@ func TestRunBillingCycle_InvoicesUsagePlusAdvanceBase(t *testing.T) {
require.NoError(t, err)
require.Equal(t, cycle.RunStatusInvoiced, resp.Status)
require.EqualValues(t, 1_000_000, resp.ArrearsMicros)
require.EqualValues(t, 43_000_000, resp.AdvanceBaseMicros) // 20e6 + 23e6
require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) // 2 × flat $20 (no per-app overage)
require.EqualValues(t, 3_000_000, resp.AccountOverageMicros) // pool 6 → 1 over → $3
require.EqualValues(t, 4_400, resp.ChargedCents)
require.Len(t, sc.itemCalls, 1, "usage + base pool into ONE line on ONE invoice")
require.Len(t, sc.itemCalls, 1, "usage + base + pooled overage pool into ONE line on ONE invoice")
require.EqualValues(t, 4_400, sc.itemCalls[0].amountCfg)

// The advance leg froze one migration-028 snapshot per billed app for the
// NEW window [Jul 1, Aug 1) — count + base as invoiced, so the display can
// never drift after later syncs.
// The advance leg froze one migration-028 base snapshot per billed app for
// the NEW window [Jul 1, Aug 1) — now the FLAT base (overage is pooled).
fs, ok := store.baseSnapshots[snapKey{flat, periodEnd}]
require.True(t, ok)
require.Equal(t, "advance", fs.source)
require.EqualValues(t, usage.BaseFeeMicros, fs.snap.BaseMicros)
require.Equal(t, periodEnd.AddDate(0, 1, 0), fs.snap.PeriodEnd)
ts, ok := store.baseSnapshots[snapKey{tiered, periodEnd}]
require.True(t, ok)
require.EqualValues(t, 23_000_000, ts.snap.BaseMicros)
require.EqualValues(t, usage.BaseFeeMicros, ts.snap.BaseMicros, "per-app base is flat now")
require.Equal(t, 6, ts.snap.ModuleCount)

// And ONE account_overage_snapshots row (source='advance') for the closing
// period, so the mid-period sweep + display agree it is already billed.
ov, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}]
require.True(t, ok, "the boundary must freeze the pooled overage it billed")
require.Equal(t, "advance", ov.Source)
require.Equal(t, 1, ov.OverCount)
require.EqualValues(t, 3_000_000, ov.ChargedMicros)
}

func TestRunBillingCycle_BaseOnlyInvoiceWhenNoUsage(t *testing.T) {
Expand Down Expand Up @@ -575,11 +590,14 @@ func TestRunBillingCycle_ExcludesAppCreatedInsideNewPeriod(t *testing.T) {
require.False(t, ok)

// NEXT boundary (closing [Jul 1, Aug 1)): the app now pre-exists the newer
// period and joins the advance leg — billed exactly once, never twice.
// period and joins the advance leg — billed exactly once, never twice. Both
// apps contribute the FLAT $20 base (2 × 20e6); the account pool is now 0 + 6
// = 6 → 1 over → the pooled overage ($3) is charged at the account level.
resp, err = svc.RunBillingCycle(context.Background(), chargeAccount, periodEnd, periodEnd.AddDate(0, 1, 0), 0)
require.NoError(t, err)
require.EqualValues(t, usage.BaseFeeMicros+usage.AppBaseFeeMicros(usage.BaseFeeMicros, 6), resp.AdvanceBaseMicros,
"the new app joins the advance leg at the NEXT boundary")
require.EqualValues(t, 2*usage.BaseFeeMicros, resp.AdvanceBaseMicros,
"the new app joins the advance leg at the NEXT boundary (flat base)")
require.EqualValues(t, 3_000_000, resp.AccountOverageMicros, "pool 6 → 1 over → $3 pooled overage")
}

func TestRunBillingCycle_ReclaimedRunNoDoubleBase(t *testing.T) {
Expand Down
Loading
Loading