From b0421ede6471baf5ca4838072d655c4eebbd0318 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 05:57:11 +0800 Subject: [PATCH 01/30] feat: disclose large auto-collected charges (migration 031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a post-hoc TRANSPARENCY flag for off-session charges that ALREADY succeeded, once they cross a per-account size threshold — so customers aren't surprised by a large automatic debit. This changes NO charging behaviour; it is complementary to (and distinct from) the spend-ceiling gate, which SKIPS a charge before it fires. - migration 031: accounts.auto_collect_threshold_micros (BIGINT NULL, NULL = $100 default) + invoices.is_large_auto_collect (BOOLEAN NOT NULL DEFAULT false). - collection.DefaultAutoCollectThresholdMicros ($100) + ResolveAutoCollectThreshold + IsLargeAutoCollect (strict-> : exactly-at threshold is NOT flagged, matching ExceedsSpendCeiling's dollar-cap reading). - Server-computed at charge time (threshold resolved when the charge fires, not later) in BOTH off-session charge legs: RunBillingCycle (arrears + advance base) and RegisterApp proration. - Wired through InvoiceMirror/UpsertInvoice and exposed on the wire as InvoiceRow.is_large_auto_collect (additive) for the web billing page. - Tests: collection flag logic (below/above/at/override/NULL) + cycle wiring (large flags, small doesn't, per-account override respected). Co-Authored-By: Claude Opus 4.8 --- internal/account/collection/collection.go | 41 ++++++++++++++ .../account/collection/collection_test.go | 53 ++++++++++++++++++ internal/account/cycle/apps.go | 28 +++++++--- internal/account/cycle/charge.go | 23 +++++--- internal/account/cycle/charge_test.go | 53 ++++++++++++++++++ internal/account/cycle/store.go | 43 +++++++++----- internal/account/db/cycle.sql.go | 56 ++++++++++++------- internal/account/db/invoices.sql.go | 28 +++++----- internal/account/db/models.go | 4 ++ internal/account/db/queries/cycle.sql | 28 +++++++--- internal/account/db/queries/invoices.sql | 2 +- internal/account/usage/invoices.go | 33 +++++++---- internal/account/usage/store.go | 26 +++++---- .../031_auto_collect_disclosure.down.sql | 9 +++ .../031_auto_collect_disclosure.up.sql | 49 ++++++++++++++++ 15 files changed, 381 insertions(+), 95 deletions(-) create mode 100644 migrations/billing/031_auto_collect_disclosure.down.sql create mode 100644 migrations/billing/031_auto_collect_disclosure.up.sql diff --git a/internal/account/collection/collection.go b/internal/account/collection/collection.go index ddaaad9..abc1c2e 100644 --- a/internal/account/collection/collection.go +++ b/internal/account/collection/collection.go @@ -204,6 +204,47 @@ func tighten(acct Account, reason Reason) Decision { } } +// --- large auto-collect disclosure -------------------------------------------- + +// DefaultAutoCollectThresholdMicros is the platform-default size threshold above +// which a SUCCESSFUL off-session charge is disclosed as "large" on the billing +// page: $100.00. An account with no per-account override (NULL +// auto_collect_threshold_micros, migration 031) uses this. FINANCE-OWNED like +// the other collection thresholds; kept here (a risk/disclosure concept), not in +// the pricing consts. +// +// This is DISCLOSURE ONLY: unlike the spend ceiling (which SKIPS a charge that +// would breach it), the threshold changes NO charging behaviour — it only marks, +// after the fact, that a charge which ALREADY succeeded was large, so the +// customer isn't surprised by the auto-debit. +const DefaultAutoCollectThresholdMicros int64 = 100_000_000 // $100.00 + +// ResolveAutoCollectThreshold resolves the effective per-account disclosure +// threshold: the account override when set (non-nil), else the platform default. +// A nil override models the migration-031 NULL column ("use the default"). This +// MUST be resolved AT CHARGE TIME so the flag reflects the threshold that applied +// when the charge fired, not one edited afterward. +func ResolveAutoCollectThreshold(overrideMicros *int64) int64 { + if overrideMicros != nil { + return *overrideMicros + } + return DefaultAutoCollectThresholdMicros +} + +// IsLargeAutoCollect reports whether a SUCCESSFUL off-session charge of +// chargedMicros (netted arrears + advance base, pre-cents-conversion) crosses the +// account's disclosure threshold (override when non-nil, else the default) and so +// must be disclosed as "large". +// +// The comparison is strict-greater-than (>): a charge EXACTLY EQUAL to the +// threshold is NOT flagged. "Large" means ABOVE the threshold — a $100 threshold +// discloses charges over $100, not a charge of exactly $100. This matches +// ExceedsSpendCeiling's precise-dollar-cap reading (equal-to is not "above") and +// is intentionally distinct from overCreditLimit's inclusive (>=) trust trigger. +func IsLargeAutoCollect(chargedMicros int64, overrideMicros *int64) bool { + return chargedMicros > ResolveAutoCollectThreshold(overrideMicros) +} + // ExceedsSpendCeiling reports whether the netted arrears would breach the // account's hard per-cycle bill-shock cap. NoCeiling (HasSpendCeiling=false) → // never breaches. This is a HARD cap independent of the mode/credit-limit judge: diff --git a/internal/account/collection/collection_test.go b/internal/account/collection/collection_test.go index e841121..1bea39a 100644 --- a/internal/account/collection/collection_test.go +++ b/internal/account/collection/collection_test.go @@ -200,3 +200,56 @@ func TestTrustRampedCreditLimit_PartialTenureMonthDoesNotCount(t *testing.T) { collection.TrustRampedCreditLimit(0, 29, false), "30 days adds one tenure month") } + +// --- large auto-collect disclosure ---------------------------------------- + +func TestResolveAutoCollectThreshold_NilUsesDefault(t *testing.T) { + // A nil override models the migration-031 NULL column → the platform default. + require.Equal(t, collection.DefaultAutoCollectThresholdMicros, + collection.ResolveAutoCollectThreshold(nil)) + require.EqualValues(t, 100_000_000, collection.DefaultAutoCollectThresholdMicros, + "default is $100.00 in micros") +} + +func TestResolveAutoCollectThreshold_OverrideRespected(t *testing.T) { + override := int64(250_000_000) // $250.00 per-account override + require.Equal(t, override, collection.ResolveAutoCollectThreshold(&override)) +} + +func TestIsLargeAutoCollect_BelowDefaultThresholdFalse(t *testing.T) { + // $99.99 with the default $100 threshold (nil override) → not large. + require.False(t, collection.IsLargeAutoCollect(99_990_000, nil)) +} + +func TestIsLargeAutoCollect_AboveDefaultThresholdTrue(t *testing.T) { + // $100.01 with the default $100 threshold → large. + require.True(t, collection.IsLargeAutoCollect(100_010_000, nil)) +} + +func TestIsLargeAutoCollect_ExactlyAtThresholdFalse(t *testing.T) { + // JUDGMENT (documented): the comparison is strict-greater-than, so a charge + // EXACTLY EQUAL to the threshold is NOT flagged — "large" means ABOVE the + // threshold (a $100 threshold discloses charges over $100, not one of exactly + // $100). Matches ExceedsSpendCeiling's precise-dollar-cap reading. + require.False(t, collection.IsLargeAutoCollect(collection.DefaultAutoCollectThresholdMicros, nil)) + override := int64(250_000_000) + require.False(t, collection.IsLargeAutoCollect(override, &override), + "exactly at a custom override is likewise not large") +} + +func TestIsLargeAutoCollect_CustomOverrideRespected(t *testing.T) { + // A $250 override: $150 is under the CUSTOM threshold (though over the $100 + // default), so the per-account override governs and it is NOT large… + override := int64(250_000_000) + require.False(t, collection.IsLargeAutoCollect(150_000_000, &override)) + // …while $250.01 crosses the override and IS large. + require.True(t, collection.IsLargeAutoCollect(250_010_000, &override)) +} + +func TestIsLargeAutoCollect_ZeroOverrideFlagsAnyPositiveCharge(t *testing.T) { + // A $0 override (a deliberately-disclose-everything account) flags any + // positive charge but not a zero charge (0 is not > 0). + zero := int64(0) + require.True(t, collection.IsLargeAutoCollect(1, &zero)) + require.False(t, collection.IsLargeAutoCollect(0, &zero)) +} diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 6952799..8a9adf6 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/collection" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) @@ -255,19 +256,30 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg return nil, billing.StripeError("proration invoice failed", err) } + // Resolve the account's large-charge disclosure threshold AT CHARGE TIME so + // the proration invoice's flag reflects what applied when it fired (migration + // 031), the same contract as the RunBillingCycle leg. A prorated app base fee + // rarely crosses the $100 default, but the flag is computed uniformly at every + // off-session charge call site so no successful large debit escapes disclosure. + acct, err := s.store.AccountCollection(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("account collection lookup failed", err) + } + // Mirror with the PARTIAL window: [creation day (UTC midnight), period // end) — the SAME coverage-start instant ProratedBaseMicros priced, so // the mirrored window and the charged amount agree by construction. partialStart := usage.ProrationCoverageStart(app.CreatedAt, periodStart) if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: app.AccountID, - StripeInvoiceID: inv.ID, - Status: inv.Status, - AmountDueCents: inv.AmountDue, - AmountPaidCents: inv.AmountPaid, - Currency: chargeCurrency, - PeriodStart: partialStart, - PeriodEnd: periodEnd, + AccountID: app.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: partialStart, + PeriodEnd: periodEnd, + IsLargeAutoCollect: collection.IsLargeAutoCollect(prorated, acct.AutoCollectThresholdMicros), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index cde5378..d7bb72c 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -289,15 +289,22 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.StripeError("charge failed", err) } + // Post-hoc large-charge disclosure (migration 031): the charge SUCCEEDED + // above; flag it as "large" iff the amount just charged (netted usage + // arrears + advance base, in micros, the SAME value converted to cents + // above) exceeds the account's threshold resolved AT CHARGE TIME (its + // per-account override, or the platform default when NULL). Pure disclosure — + // it changes NO charging behaviour, only surfaces the already-successful debit. if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: accountID, - StripeInvoiceID: inv.ID, - Status: inv.Status, - AmountDueCents: inv.AmountDue, - AmountPaidCents: inv.AmountPaid, - Currency: chargeCurrency, - PeriodStart: periodStart, - PeriodEnd: periodEnd, + AccountID: accountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + IsLargeAutoCollect: collection.IsLargeAutoCollect(arrears+advanceBase, acct.AutoCollectThresholdMicros), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 5bdf808..171d308 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -470,3 +470,56 @@ func TestRunBillingCycle_PropagatesStoreErrors(t *testing.T) { }) } } + +// --- large auto-collect disclosure flag (migration 031) ------------------- + +func TestRunBillingCycle_LargeChargeFlagsMirror(t *testing.T) { + // A charge above the default $100 threshold (nil override) freezes + // is_large_auto_collect=true on the mirror. $150 arrears → flagged. + store := newFakeStore() + store.chargedTotal = 150_000_000 // $150 in micros, over the $100 default + store.hasPM = true + store.stripeCustomer = "cus_large" + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.True(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, + "$150 > $100 default threshold → disclosed as large") +} + +func TestRunBillingCycle_SmallChargeDoesNotFlagMirror(t *testing.T) { + // A charge below the default threshold leaves the flag false (the historic / + // non-disclosed default). + store := newFakeStore() + store.chargedTotal = 50_000_000 // $50 in micros, under the $100 default + store.hasPM = true + store.stripeCustomer = "cus_small" + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.False(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, + "$50 < $100 default threshold → not disclosed") +} + +func TestRunBillingCycle_PerAccountThresholdOverrideRespected(t *testing.T) { + // A $200 per-account override governs over the $100 default: a $150 charge is + // under the CUSTOM threshold and so is NOT flagged, proving the flag is + // resolved against the account's own threshold at charge time. + store := newFakeStore() + override := int64(200_000_000) // $200 override + store.collection.AutoCollectThresholdMicros = &override + store.chargedTotal = 150_000_000 // $150, over default but under the override + store.hasPM = true + store.stripeCustomer = "cus_override" + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.False(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, + "$150 < $200 per-account override → not disclosed despite exceeding the default") +} diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3e248cd..d16e003 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -282,6 +282,11 @@ type AccountCollection struct { HasSpendCeiling bool SpendCeilingMicros int64 CreatedAt time.Time + // AutoCollectThresholdMicros is the per-account large-charge disclosure + // threshold (migration 031), nil when the account uses the platform default + // (collection.DefaultAutoCollectThresholdMicros). Resolved AT CHARGE TIME by + // collection.IsLargeAutoCollect to freeze the post-hoc disclosure flag. + AutoCollectThresholdMicros *int64 } // BillingMode mirrors ms_billing.usage_billing_mode (and collection.Mode) @@ -309,6 +314,11 @@ type InvoiceMirror struct { Currency string PeriodStart time.Time PeriodEnd time.Time + // IsLargeAutoCollect is the server-computed post-hoc disclosure flag + // (migration 031): true iff the charged amount exceeded the account's + // resolved auto-collect threshold WHEN THE CHARGE FIRED. Set by every + // off-session charge call site; false for anything below the threshold. + IsLargeAutoCollect bool } // RawAggregate is one per-kind aggregated row from the rollup SELECTs, before @@ -604,12 +614,18 @@ func (s *pgxStore) AccountCollection(ctx context.Context, accountID uuid.UUID) ( if err != nil { return AccountCollection{}, err } + var autoCollectThreshold *int64 + if row.AutoCollectThresholdMicros.Valid { + v := row.AutoCollectThresholdMicros.Int64 + autoCollectThreshold = &v + } return AccountCollection{ - Mode: BillingMode(row.UsageBillingMode), - CreditLimitMicros: row.CreditLimitMicros, - HasSpendCeiling: row.SpendCeilingMicros.Valid, - SpendCeilingMicros: row.SpendCeilingMicros.Int64, - CreatedAt: row.CreatedAt, + Mode: BillingMode(row.UsageBillingMode), + CreditLimitMicros: row.CreditLimitMicros, + HasSpendCeiling: row.SpendCeilingMicros.Valid, + SpendCeilingMicros: row.SpendCeilingMicros.Int64, + CreatedAt: row.CreatedAt, + AutoCollectThresholdMicros: autoCollectThreshold, }, nil } @@ -685,14 +701,15 @@ func (s *pgxStore) UpsertInvoice(ctx context.Context, inv InvoiceMirror) error { return err } return s.q.UpsertInvoice(ctx, db.UpsertInvoiceParams{ - AccountID: inv.AccountID.String(), - StripeInvoiceID: inv.StripeInvoiceID, - Status: inv.Status, - AmountDue: due, - AmountPaid: paid, - Currency: inv.Currency, - PeriodStart: pgtype.Timestamptz{Time: inv.PeriodStart, Valid: !inv.PeriodStart.IsZero()}, - PeriodEnd: pgtype.Timestamptz{Time: inv.PeriodEnd, Valid: !inv.PeriodEnd.IsZero()}, + AccountID: inv.AccountID.String(), + StripeInvoiceID: inv.StripeInvoiceID, + Status: inv.Status, + AmountDue: due, + AmountPaid: paid, + Currency: inv.Currency, + PeriodStart: pgtype.Timestamptz{Time: inv.PeriodStart, Valid: !inv.PeriodStart.IsZero()}, + PeriodEnd: pgtype.Timestamptz{Time: inv.PeriodEnd, Valid: !inv.PeriodEnd.IsZero()}, + IsLargeAutoCollect: inv.IsLargeAutoCollect, }) } diff --git a/internal/account/db/cycle.sql.go b/internal/account/db/cycle.sql.go index 865a76a..e5a6aef 100644 --- a/internal/account/db/cycle.sql.go +++ b/internal/account/db/cycle.sql.go @@ -17,16 +17,18 @@ SELECT usage_billing_mode, credit_limit_micros, spend_ceiling_micros, + auto_collect_threshold_micros, created_at FROM ms_billing.accounts WHERE id = $1 ` type AccountCollectionFieldsRow struct { - UsageBillingMode MsBillingUsageBillingMode `json:"usage_billing_mode"` - CreditLimitMicros int64 `json:"credit_limit_micros"` - SpendCeilingMicros pgtype.Int8 `json:"spend_ceiling_micros"` - CreatedAt time.Time `json:"created_at"` + UsageBillingMode MsBillingUsageBillingMode `json:"usage_billing_mode"` + CreditLimitMicros int64 `json:"credit_limit_micros"` + SpendCeilingMicros pgtype.Int8 `json:"spend_ceiling_micros"` + AutoCollectThresholdMicros pgtype.Int8 `json:"auto_collect_threshold_micros"` + CreatedAt time.Time `json:"created_at"` } // AccountCollectionFields loads the risk-graded collection controls for the @@ -35,7 +37,10 @@ type AccountCollectionFieldsRow struct { // limit. created_at is returned so the risk-judge can compute account tenure // WITHOUT a cross-schema read into ms_account (billing-engine never reads // ms_account tables). spend_ceiling_micros is NULL when no ceiling is set; the -// Go layer carries it as a nullable. +// Go layer carries it as a nullable. auto_collect_threshold_micros (migration +// 031) is NULL when the account uses the platform default; the charge leg +// resolves it to collection.DefaultAutoCollectThresholdMicros AT CHARGE TIME to +// decide the post-hoc large-charge disclosure flag. func (q *Queries) AccountCollectionFields(ctx context.Context, id string) (AccountCollectionFieldsRow, error) { row := q.db.QueryRow(ctx, accountCollectionFields, id) var i AccountCollectionFieldsRow @@ -43,6 +48,7 @@ func (q *Queries) AccountCollectionFields(ctx context.Context, id string) (Accou &i.UsageBillingMode, &i.CreditLimitMicros, &i.SpendCeilingMicros, + &i.AutoCollectThresholdMicros, &i.CreatedAt, ) return i, err @@ -427,35 +433,42 @@ const upsertInvoice = `-- name: UpsertInvoice :exec INSERT INTO ms_billing.invoices ( account_id, stripe_invoice_id, status, amount_due, amount_paid, currency, - period_start, period_end + period_start, period_end, is_large_auto_collect ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) ON CONFLICT (stripe_invoice_id) DO UPDATE SET - status = EXCLUDED.status, - amount_due = EXCLUDED.amount_due, - amount_paid = EXCLUDED.amount_paid, - currency = EXCLUDED.currency, - period_start = EXCLUDED.period_start, - period_end = EXCLUDED.period_end + status = EXCLUDED.status, + amount_due = EXCLUDED.amount_due, + amount_paid = EXCLUDED.amount_paid, + currency = EXCLUDED.currency, + period_start = EXCLUDED.period_start, + period_end = EXCLUDED.period_end, + is_large_auto_collect = EXCLUDED.is_large_auto_collect ` type UpsertInvoiceParams struct { - AccountID string `json:"account_id"` - StripeInvoiceID string `json:"stripe_invoice_id"` - Status string `json:"status"` - AmountDue pgtype.Numeric `json:"amount_due"` - AmountPaid pgtype.Numeric `json:"amount_paid"` - Currency string `json:"currency"` - PeriodStart pgtype.Timestamptz `json:"period_start"` - PeriodEnd pgtype.Timestamptz `json:"period_end"` + AccountID string `json:"account_id"` + StripeInvoiceID string `json:"stripe_invoice_id"` + Status string `json:"status"` + AmountDue pgtype.Numeric `json:"amount_due"` + AmountPaid pgtype.Numeric `json:"amount_paid"` + Currency string `json:"currency"` + PeriodStart pgtype.Timestamptz `json:"period_start"` + PeriodEnd pgtype.Timestamptz `json:"period_end"` + IsLargeAutoCollect bool `json:"is_large_auto_collect"` } // UpsertInvoice mirrors a Stripe invoice into ms_billing.invoices, keyed on the // UNIQUE stripe_invoice_id so a re-run (deterministic Stripe Idempotency-Key // returns the same invoice) upserts the same row rather than duplicating it. // Webhook reconciliation (PR #7) later updates status + amount_paid in place. +// is_large_auto_collect (migration 031) is the server-computed post-hoc +// disclosure flag, frozen at invoice-create time from the amount charged vs the +// account threshold that applied WHEN THE CHARGE FIRED. A deterministic re-run +// (same Stripe idem key → same invoice, same charged amount) re-computes the same +// value, so carrying it through EXCLUDED on conflict is a no-op refresh. func (q *Queries) UpsertInvoice(ctx context.Context, arg UpsertInvoiceParams) error { _, err := q.db.Exec(ctx, upsertInvoice, arg.AccountID, @@ -466,6 +479,7 @@ func (q *Queries) UpsertInvoice(ctx context.Context, arg UpsertInvoiceParams) er arg.Currency, arg.PeriodStart, arg.PeriodEnd, + arg.IsLargeAutoCollect, ) return err } diff --git a/internal/account/db/invoices.sql.go b/internal/account/db/invoices.sql.go index 463ea7f..328f3dc 100644 --- a/internal/account/db/invoices.sql.go +++ b/internal/account/db/invoices.sql.go @@ -17,7 +17,7 @@ const listInvoicesForAccount = `-- name: ListInvoicesForAccount :many SELECT id, stripe_invoice_id, number, status, amount_due, amount_paid, currency, period_start, period_end, created_at, - hosted_invoice_url, invoice_pdf + hosted_invoice_url, invoice_pdf, is_large_auto_collect FROM ms_billing.invoices WHERE account_id = $1::uuid AND status <> 'draft' @@ -36,18 +36,19 @@ type ListInvoicesForAccountParams struct { } type ListInvoicesForAccountRow struct { - ID string `json:"id"` - StripeInvoiceID string `json:"stripe_invoice_id"` - Number pgtype.Text `json:"number"` - Status string `json:"status"` - AmountDue pgtype.Numeric `json:"amount_due"` - AmountPaid pgtype.Numeric `json:"amount_paid"` - Currency string `json:"currency"` - PeriodStart pgtype.Timestamptz `json:"period_start"` - PeriodEnd pgtype.Timestamptz `json:"period_end"` - CreatedAt time.Time `json:"created_at"` - HostedInvoiceUrl pgtype.Text `json:"hosted_invoice_url"` - InvoicePdf pgtype.Text `json:"invoice_pdf"` + ID string `json:"id"` + StripeInvoiceID string `json:"stripe_invoice_id"` + Number pgtype.Text `json:"number"` + Status string `json:"status"` + AmountDue pgtype.Numeric `json:"amount_due"` + AmountPaid pgtype.Numeric `json:"amount_paid"` + Currency string `json:"currency"` + PeriodStart pgtype.Timestamptz `json:"period_start"` + PeriodEnd pgtype.Timestamptz `json:"period_end"` + CreatedAt time.Time `json:"created_at"` + HostedInvoiceUrl pgtype.Text `json:"hosted_invoice_url"` + InvoicePdf pgtype.Text `json:"invoice_pdf"` + IsLargeAutoCollect bool `json:"is_large_auto_collect"` } // Account-scoped READS over the ms_billing.invoices Stripe mirror (011 + 026) @@ -106,6 +107,7 @@ func (q *Queries) ListInvoicesForAccount(ctx context.Context, arg ListInvoicesFo &i.CreatedAt, &i.HostedInvoiceUrl, &i.InvoicePdf, + &i.IsLargeAutoCollect, ); err != nil { return nil, err } diff --git a/internal/account/db/models.go b/internal/account/db/models.go index d9e8e21..4820438 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -331,6 +331,8 @@ type MsBillingAccount struct { SpendCeilingMicros pgtype.Int8 `json:"spend_ceiling_micros"` // UTC instant the account bound its FIRST credit card (billing-account activation). Immutable, first-bind-wins; billing-period anchor day = activated_at day-of-month (ADR 0005). NULL = never activated -> skipped by cmd/billing-cycle. ActivatedAt pgtype.Timestamptz `json:"activated_at"` + // Per-account size threshold (micro-USD) above which a SUCCESSFUL off-session charge is disclosed as "large" on the billing page. NULL = platform default ($100 = 100000000 micros). Resolved at charge time; pure disclosure, changes no charging behaviour. + AutoCollectThresholdMicros pgtype.Int8 `json:"auto_collect_threshold_micros"` } type MsBillingAddCardRequest struct { @@ -439,6 +441,8 @@ type MsBillingInvoice struct { HostedInvoiceUrl pgtype.Text `json:"hosted_invoice_url"` // Stripe invoice PDF download URL. Assigned at finalization; NULL = not yet delivered. InvoicePdf pgtype.Text `json:"invoice_pdf"` + // Server-computed at invoice-create time: true iff the charged amount (netted arrears + advance base, micros) exceeded the account auto_collect_threshold_micros (or the default when NULL) that applied WHEN THE CHARGE FIRED. Post-hoc disclosure only. + IsLargeAutoCollect bool `json:"is_large_auto_collect"` } type MsBillingMetricDefinition struct { diff --git a/internal/account/db/queries/cycle.sql b/internal/account/db/queries/cycle.sql index 6bb4cf7..cfffc61 100644 --- a/internal/account/db/queries/cycle.sql +++ b/internal/account/db/queries/cycle.sql @@ -166,22 +166,28 @@ WHERE id = $1; -- UNIQUE stripe_invoice_id so a re-run (deterministic Stripe Idempotency-Key -- returns the same invoice) upserts the same row rather than duplicating it. -- Webhook reconciliation (PR #7) later updates status + amount_paid in place. +-- is_large_auto_collect (migration 031) is the server-computed post-hoc +-- disclosure flag, frozen at invoice-create time from the amount charged vs the +-- account threshold that applied WHEN THE CHARGE FIRED. A deterministic re-run +-- (same Stripe idem key → same invoice, same charged amount) re-computes the same +-- value, so carrying it through EXCLUDED on conflict is a no-op refresh. -- name: UpsertInvoice :exec INSERT INTO ms_billing.invoices ( account_id, stripe_invoice_id, status, amount_due, amount_paid, currency, - period_start, period_end + period_start, period_end, is_large_auto_collect ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) ON CONFLICT (stripe_invoice_id) DO UPDATE SET - status = EXCLUDED.status, - amount_due = EXCLUDED.amount_due, - amount_paid = EXCLUDED.amount_paid, - currency = EXCLUDED.currency, - period_start = EXCLUDED.period_start, - period_end = EXCLUDED.period_end; + status = EXCLUDED.status, + amount_due = EXCLUDED.amount_due, + amount_paid = EXCLUDED.amount_paid, + currency = EXCLUDED.currency, + period_start = EXCLUDED.period_start, + period_end = EXCLUDED.period_end, + is_large_auto_collect = EXCLUDED.is_large_auto_collect; -- HasUsableDefaultPM is the no-PM charge gate: true iff the account has at -- least one active (not soft-deleted), not-expired payment_methods_mirror row. @@ -217,12 +223,16 @@ WHERE id = $1; -- limit. created_at is returned so the risk-judge can compute account tenure -- WITHOUT a cross-schema read into ms_account (billing-engine never reads -- ms_account tables). spend_ceiling_micros is NULL when no ceiling is set; the --- Go layer carries it as a nullable. +-- Go layer carries it as a nullable. auto_collect_threshold_micros (migration +-- 031) is NULL when the account uses the platform default; the charge leg +-- resolves it to collection.DefaultAutoCollectThresholdMicros AT CHARGE TIME to +-- decide the post-hoc large-charge disclosure flag. -- name: AccountCollectionFields :one SELECT usage_billing_mode, credit_limit_micros, spend_ceiling_micros, + auto_collect_threshold_micros, created_at FROM ms_billing.accounts WHERE id = $1; diff --git a/internal/account/db/queries/invoices.sql b/internal/account/db/queries/invoices.sql index cd1d6cd..692a076 100644 --- a/internal/account/db/queries/invoices.sql +++ b/internal/account/db/queries/invoices.sql @@ -31,7 +31,7 @@ SELECT id, stripe_invoice_id, number, status, amount_due, amount_paid, currency, period_start, period_end, created_at, - hosted_invoice_url, invoice_pdf + hosted_invoice_url, invoice_pdf, is_large_auto_collect FROM ms_billing.invoices WHERE account_id = @account_id::uuid AND status <> 'draft' diff --git a/internal/account/usage/invoices.go b/internal/account/usage/invoices.go index f1979c1..6b1aeb0 100644 --- a/internal/account/usage/invoices.go +++ b/internal/account/usage/invoices.go @@ -86,6 +86,14 @@ type InvoiceRow struct { // for an unenriched row rather than rendering dead links. HostedInvoiceURL string `json:"hosted_invoice_url,omitempty"` InvoicePDF string `json:"invoice_pdf,omitempty"` + + // IsLargeAutoCollect is the server-computed post-hoc disclosure flag + // (migration 031): true when this invoice's off-session charge exceeded the + // account's auto-collect threshold that applied when it fired. The billing + // page surfaces flagged charges in its large-auto-collected disclosure + // section. Always present (defaults false); api-client-shared's Invoice type + // should add a matching `is_large_auto_collect` field to consume it. + IsLargeAutoCollect bool `json:"is_large_auto_collect"` } // ListInvoicesResponse is one page of the invoice history, newest-first. @@ -164,18 +172,19 @@ func (s *Service) ListInvoices(ctx context.Context, req ListInvoicesRequest) (*L invoices := make([]InvoiceRow, 0, len(rows)) for _, r := range rows { invoices = append(invoices, InvoiceRow{ - ID: r.ID.String(), - StripeInvoiceID: r.StripeInvoiceID, - Number: r.Number, - Status: r.Status, - AmountDueMicros: r.AmountDueMicros, - AmountPaidMicros: r.AmountPaidMicros, - Currency: r.Currency, - PeriodStart: r.PeriodStart, - PeriodEnd: r.PeriodEnd, - CreatedAt: r.CreatedAt, - HostedInvoiceURL: r.HostedInvoiceURL, - InvoicePDF: r.InvoicePDF, + ID: r.ID.String(), + StripeInvoiceID: r.StripeInvoiceID, + Number: r.Number, + Status: r.Status, + AmountDueMicros: r.AmountDueMicros, + AmountPaidMicros: r.AmountPaidMicros, + Currency: r.Currency, + PeriodStart: r.PeriodStart, + PeriodEnd: r.PeriodEnd, + CreatedAt: r.CreatedAt, + HostedInvoiceURL: r.HostedInvoiceURL, + InvoicePDF: r.InvoicePDF, + IsLargeAutoCollect: r.IsLargeAutoCollect, }) } diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index 3a737d3..5607cb7 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -401,6 +401,11 @@ type InvoiceMirrorRaw struct { CreatedAt time.Time HostedInvoiceURL string InvoicePDF string + // IsLargeAutoCollect is the server-computed post-hoc disclosure flag + // (migration 031): true when this invoice's off-session charge exceeded the + // account's auto-collect threshold that applied when it fired. Read-through + // from the mirror for the billing page's large-charge disclosure surface. + IsLargeAutoCollect bool } // NewStore returns a Store backed by the given pgxpool. The pool is @@ -976,16 +981,17 @@ func (s *pgxStore) ListInvoices(ctx context.Context, accountID uuid.UUID, limit StripeInvoiceID: r.StripeInvoiceID, // pgtype.Text zero-values String to "" when NULL, which is exactly // the "not enriched yet" contract InvoiceMirrorRaw documents. - Number: r.Number.String, - Status: r.Status, - AmountDueMicros: due, - AmountPaidMicros: paid, - Currency: r.Currency, - PeriodStart: timePtrFromTimestamptz(r.PeriodStart), - PeriodEnd: timePtrFromTimestamptz(r.PeriodEnd), - CreatedAt: r.CreatedAt, - HostedInvoiceURL: r.HostedInvoiceUrl.String, - InvoicePDF: r.InvoicePdf.String, + Number: r.Number.String, + Status: r.Status, + AmountDueMicros: due, + AmountPaidMicros: paid, + Currency: r.Currency, + PeriodStart: timePtrFromTimestamptz(r.PeriodStart), + PeriodEnd: timePtrFromTimestamptz(r.PeriodEnd), + CreatedAt: r.CreatedAt, + HostedInvoiceURL: r.HostedInvoiceUrl.String, + InvoicePDF: r.InvoicePdf.String, + IsLargeAutoCollect: r.IsLargeAutoCollect, }) } return out, nil diff --git a/migrations/billing/031_auto_collect_disclosure.down.sql b/migrations/billing/031_auto_collect_disclosure.down.sql new file mode 100644 index 0000000..40902f3 --- /dev/null +++ b/migrations/billing/031_auto_collect_disclosure.down.sql @@ -0,0 +1,9 @@ +-- Down migration 031 — drop the large auto-collect disclosure columns. Both are +-- pure additive columns (the flag frozen per-invoice, the threshold per-account), +-- so dropping them fully reverts the migration; up/down/up round-trips cleanly. + +ALTER TABLE ms_billing.invoices + DROP COLUMN IF EXISTS is_large_auto_collect; + +ALTER TABLE ms_billing.accounts + DROP COLUMN IF EXISTS auto_collect_threshold_micros; diff --git a/migrations/billing/031_auto_collect_disclosure.up.sql b/migrations/billing/031_auto_collect_disclosure.up.sql new file mode 100644 index 0000000..66a5b83 --- /dev/null +++ b/migrations/billing/031_auto_collect_disclosure.up.sql @@ -0,0 +1,49 @@ +-- Migration 031 — large auto-collected charge DISCLOSURE (transparency flag). +-- +-- A post-hoc TRANSPARENCY surface for off-session charges that ALREADY +-- SUCCEEDED, once they cross a per-account size threshold — so a customer is +-- never surprised by a large automatic debit. This is COMPLEMENTARY to (and +-- deliberately DISTINCT from) the spend-ceiling gate (migration 016): the +-- ceiling SKIPS a charge that would breach a bill-shock cap BEFORE it fires; +-- this flag changes NO charging behaviour at all — it only records, after the +-- fact, that a successful charge was "large" so the web billing page can +-- disclose it. Money is micro-dollar BIGINT end-to-end (no float). +-- +-- Two additive columns, one migration: +-- +-- accounts.auto_collect_threshold_micros (BIGINT, NULL) +-- The per-account size threshold above which a successful off-session +-- charge is disclosed as "large". NULL = use the platform default +-- ($100.00 = 100_000_000 micros, collection.DefaultAutoCollectThresholdMicros). +-- Resolved AT CHARGE TIME (never later), so the flag reflects the threshold +-- that applied when the charge actually fired, even if it is changed after. +-- +-- invoices.is_large_auto_collect (BOOLEAN, NOT NULL, DEFAULT false) +-- The server-computed verdict, frozen on the invoice mirror row at +-- invoice-create time: true iff the charged amount (netted arrears + advance +-- base, in micros, pre-cents-conversion) exceeded the account's resolved +-- threshold. Every historic row defaults false (no disclosure) — the flag is +-- only ever set true by a NEW charge going forward. +-- +-- Born clean at slot 031 (next free after 028; 029/030 reserved by sibling +-- worktrees in flight). sqlc picks up both columns from migrations/billing/ +-- automatically. updated_at on both tables is trigger-maintained (001). +-- +-- Spec: mirrorstack-docs/db/ms_billing/tables.md#accounts + #invoices. + +ALTER TABLE ms_billing.accounts + ADD COLUMN auto_collect_threshold_micros BIGINT NULL + CHECK (auto_collect_threshold_micros IS NULL OR auto_collect_threshold_micros >= 0); + +COMMENT ON COLUMN ms_billing.accounts.auto_collect_threshold_micros IS + 'Per-account size threshold (micro-USD) above which a SUCCESSFUL off-session ' + 'charge is disclosed as "large" on the billing page. NULL = platform default ' + '($100 = 100000000 micros). Resolved at charge time; pure disclosure, changes no charging behaviour.'; + +ALTER TABLE ms_billing.invoices + ADD COLUMN is_large_auto_collect BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN ms_billing.invoices.is_large_auto_collect IS + 'Server-computed at invoice-create time: true iff the charged amount (netted ' + 'arrears + advance base, micros) exceeded the account auto_collect_threshold_micros ' + '(or the default when NULL) that applied WHEN THE CHARGE FIRED. Post-hoc disclosure only.'; From 041e0340d62167e959b054ddd472c6b4c1ec1ed0 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:09:26 +0800 Subject: [PATCH 02/30] fix: 3-day creation grace before charging an app's base fee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newly created app is no longer charged its creation-period base synchronously in RegisterApp. It enters a GraceDays (=3) grace window and is charged only once it has SURVIVED it, so an app soft-deleted within grace is NEVER billed. A survivor pays the SAME creation-period proration (identical ProratedBaseMicros math, anchored to the TRUE created_at) — grace delays WHEN the charge fires, never WHAT it covers. - RegisterApp now ONLY mirrors the ms_billing.apps row (account, created_at, module_count). It never calls Stripe. Response fields kept for wire back-compat (ProrationCents always 0; ProrationInvoiceID echoes the armed guard on a post-sweep retry). - New Service.ChargeCreationProration(appID): the extracted charge leg (same Stripe plumbing, micros→cents boundary, invoice mirror, and migration-028 base snapshot the old inline RegisterApp charge used), keyed by app id with the same one-shot proration_invoice_id guard. - New Service.SweepCreationProrations(at): lists apps past grace (created_at <= at − GraceDays, guard unarmed, deleted_at IS NULL) and charges each. Wired into cmd/billing-cycle alongside the per-account boundary loop (both transports). - Race safety: ChargeProrationLocked runs the not-deleted re-check, the Stripe charge, and the guard-arm inside ONE transaction holding a SELECT ... FOR UPDATE row lock, so a concurrent soft-delete and the charge are mutually exclusive (no refund path, D1e). Whichever commits first wins; the loser no-ops. - Delayed billing correctly still charges an app whose creation period has since closed: that period is billed by NO other leg (the boundary advance leg only ever bills an app's SUBSEQUENT periods), so it never double-bills. - Migration 029: partial index apps_pending_proration_idx on (created_at) WHERE deleted_at IS NULL AND proration_invoice_id IS NULL — the sweep's work-list stays a fraction of the roster. - GraceDays const in internal/account/usage/bill.go alongside the pricing tier consts. Tests: RegisterApp mirror-only (never charges); sweep grace-holds / deleted-within-grace-never-charged / survivor-charged-once-across-reruns; proration $ math unchanged (200¢, 1300¢ overage, full-base-on-boundary); gates + idempotency; integration (build-tagged) for the AppsPendingProration filter and the FOR UPDATE ChargeProrationLocked semantics. make test / go vet / go build / gofmt clean. Co-Authored-By: Claude Opus 4.8 --- cmd/billing-cycle/main.go | 32 +- internal/account/cycle/apps.go | 217 ++---------- internal/account/cycle/apps_test.go | 240 +++---------- .../cycle/migration029_integration_test.go | 127 +++++++ internal/account/cycle/proration.go | 314 +++++++++++++++++ internal/account/cycle/proration_test.go | 316 ++++++++++++++++++ internal/account/cycle/service_test.go | 114 +++++-- internal/account/cycle/store.go | 124 +++++++ internal/account/db/apps.sql.go | 74 ++++ internal/account/db/queries/apps.sql | 29 ++ internal/account/usage/bill.go | 10 + .../029_apps_proration_sweep_idx.down.sql | 1 + .../029_apps_proration_sweep_idx.up.sql | 24 ++ 13 files changed, 1215 insertions(+), 407 deletions(-) create mode 100644 internal/account/cycle/migration029_integration_test.go create mode 100644 internal/account/cycle/proration.go create mode 100644 internal/account/cycle/proration_test.go create mode 100644 migrations/billing/029_apps_proration_sweep_idx.down.sql create mode 100644 migrations/billing/029_apps_proration_sweep_idx.up.sql diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index fc70e75..c79c5fe 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -70,19 +70,39 @@ func main() { } // Local one-shot run: close every card-bound account's just-ended anchored - // period as of now, then exit. No HTTP listener — the dev cycle is a single - // batch invocation. - res := runCycle(context.Background(), svc, time.Now().UTC()) + // period as of now AND sweep the creation-proration grace queue, then exit. + // No HTTP listener — the dev cycle is a single batch invocation. + at := time.Now().UTC() + res := runCycle(context.Background(), svc, at) slog.Info("billing-cycle local run complete", "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) - if res.Failed > 0 { + sweepFailed := runProrationSweep(context.Background(), svc, at) + if res.Failed > 0 || sweepFailed { os.Exit(1) } } +// runProrationSweep charges the creation-period base for every app that has +// survived the grace window as of `at` (the second leg of the cycle job, +// alongside the per-account boundary loop). Reports whether any per-app charge +// failed so the caller can set a non-zero exit code; a failure is retried on the +// next sweep and never aborts the batch. Logged here so both transports share +// the shape. +func runProrationSweep(ctx context.Context, svc *cycle.Service, at time.Time) bool { + sweep, err := svc.SweepCreationProrations(ctx, at) + if err != nil { + slog.ErrorContext(ctx, "creation-proration sweep failed", "as_of", at, "error", err) + return true + } + slog.InfoContext(ctx, "creation-proration sweep complete", + "as_of", at, "pending", sweep.Pending, "charged", sweep.Charged, + "skipped", sweep.Skipped, "failed", sweep.Failed) + return sweep.Failed > 0 +} + // buildService wires the pgxpool + Stripe client into the cycle Service. The // Stripe secret is required (the charge leg cannot run without it). func buildService() *cycle.Service { @@ -108,7 +128,9 @@ func handler(svc *cycle.Service) func(context.Context, events.CloudWatchEvent) e "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) - // A per-account charge failure is recorded (billing_runs status='failed') + // Sweep the creation-proration grace queue as of the same instant. + runProrationSweep(ctx, svc, at.UTC()) + // A per-account charge failure (or a per-app proration failure) is recorded // and does NOT fail the batch — the next cycle retries it. The handler // returns nil so EventBridge doesn't replay the whole batch. return nil diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 6952799..391b149 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -4,25 +4,21 @@ package cycle // v1, DESIGN.md "Base fee — v1 spec", owner spec 2026-07-05, D1c). Called by // api-platform's applications-service fire-and-forget (with retry) on app // create / module install / uninstall / app delete, so BOTH RPCs are -// idempotent end to end: a retry can re-register or re-sync without a second -// charge or a moved timestamp. +// idempotent end to end: a retry can re-register or re-sync without a moved +// timestamp. // -// They live in the cycle package because RegisterApp's creation-proration -// charge IS a charge-spine leg: it reuses the SAME Stripe invoice plumbing -// (CreateInvoiceItem + CreateInvoice with deterministic Idempotency-Keys), -// the SAME micros→cents boundary (centsFromMicros), and the SAME invoice -// mirror (UpsertInvoice) as RunBillingCycle — never a second Stripe path. +// Neither RPC charges Stripe. The creation-period base is charged by the grace +// sweep (proration.go) once an app survives GraceDays — see ChargeCreationProration +// for the charge leg that reuses the SAME Stripe plumbing, micros→cents boundary, +// and invoice mirror as RunBillingCycle. import ( "context" - "fmt" "time" "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" - "github.com/mirrorstack-ai/billing-engine/internal/account/usage" - "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) // maxModuleCount bounds the installed-module count BOTH mirror RPCs accept. @@ -53,11 +49,12 @@ type RegisterAppRequest struct { CreatedAt time.Time `json:"created_at,omitempty"` } -// RegisterAppResponse reports the mirror write + what the proration leg did. -// ProrationInvoiceID is "" when no proration invoice exists (unactivated -// account, no usable PM, a creation period that already closed — the boundary -// advance leg owns every later period — or the amount rounded to 0 cents; all -// legitimate no-charge outcomes, D1d). +// RegisterAppResponse reports the mirror write. RegisterApp charges nothing +// (creation grace); the creation-proration invoice is minted later by the sweep +// (proration.go). ProrationInvoiceID is therefore "" for a fresh registration +// and carries the armed guard's invoice id only for a retry that lands AFTER the +// sweep already charged (idempotent visibility). ProrationCents stays 0 here (no +// charge happens in this RPC) — the fields are retained for wire back-compat. type RegisterAppResponse struct { AppID uuid.UUID `json:"app_id"` AccountID uuid.UUID `json:"account_id"` @@ -81,8 +78,12 @@ type SyncAppModulesResponse struct { Deleted bool `json:"deleted"` } -// RegisterApp mirrors a freshly created platform app into ms_billing.apps and -// charges the creation-period proration (owner spec 2026-07-05): +// RegisterApp mirrors a freshly created platform app into ms_billing.apps. It +// charges NOTHING (creation grace, owner spec 2026-07-05, D1e follow-up): a +// newly created app enters a grace window and is charged its creation-period +// base only later, by the sweep (ChargeCreationProration / SweepCreationProrations +// in proration.go), and only if it SURVIVES grace — so an app deleted within +// grace is never billed. RegisterApp: // // 1. resolve-or-create the owner's billing account via the SAME // advisory-locked get-or-create billing.Ensure uses (no Stripe Customer is @@ -91,41 +92,12 @@ type SyncAppModulesResponse struct { // // 2. insert the roster row idempotently (ON CONFLICT (app_id) DO NOTHING — // a retry keeps the FIRST registration's created_at / module_count, the -// stable proration anchor), then read the row back; +// stable proration anchor the sweep later prices from), then read it back. // -// 3. proration leg, gated exactly like the spine (D1d): the account must be -// ACTIVATED (activated_at set — migration 025) AND have a usable default -// 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. -// -// INVARIANT (charge-leg ownership): the proration window is derived from -// the read-back mirror row's created_at (the stable first-registration -// anchor), NEVER from "now" — and if that creation period has already -// ENDED, the proration charge is SKIPPED entirely (charge 0, guard left -// unarmed), deterministically on this and every future retry. Every -// period after the creation period belongs to the boundary advance leg -// (which bills the apps that existed before the period opened), so a -// late retry recomputing against a newer window would double-charge a -// period the boundary already billed — and v1 does no retroactive -// catch-up either way (D1d). -// -// 4. charge via the spine's plumbing with app-scoped deterministic -// Idempotency-Keys (app-ii-/app-inv- — the app id is the stable -// charge identity here, as the run id is for the boundary leg), mirror the -// invoice with the PARTIAL window [creation day, period end), write the -// per-app-period base snapshot (migration 028, source='proration' — the -// frozen display value for the creation period), then arm the one-shot -// guard. -// -// A Stripe/mirror/snapshot failure after the row insert returns an error with -// the guard UNARMED — the platform's retry re-attempts the charge through the -// same idem keys, so the failure mode is "retry-safe", never "double-charge". +// The response echoes the resolved account id and — for a retry that lands after +// the sweep already charged — the armed guard's invoice id (idempotent +// visibility). ProrationCents is never set here (no charge happens in this RPC). +// api-platform fires this fire-and-forget with retry; every step is idempotent. func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*RegisterAppResponse, error) { if req.OwnerUserID == uuid.Nil && req.OwnerOrgID == uuid.Nil { return nil, billing.InvalidInput("owner_user_id or owner_org_id required") @@ -163,9 +135,9 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg return nil, billing.Internal("insert app mirror failed", err) } - // Read the row BACK: a retry must prorate from the FIRST registration's - // created_at / module_count (the insert's ON CONFLICT DO NOTHING preserved - // them), and the one-shot guard state decides whether to charge at all. + // Read the row BACK so the response reflects the FIRST registration's stable + // account + guard state (the insert's ON CONFLICT DO NOTHING preserved them): + // a retry that lands after the sweep charged echoes the armed invoice id. app, found, err := s.store.AppMirror(ctx, req.AppID) if err != nil { return nil, billing.Internal("app mirror lookup failed", err) @@ -173,129 +145,11 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg if !found { return nil, billing.Internal("app mirror row missing immediately after insert", nil) } - resp := &RegisterAppResponse{AppID: app.AppID, AccountID: app.AccountID} - - // One-shot guard: a prior attempt already charged (or a concurrent one - // won). Idempotent success — NEVER a second invoice. - if app.ProrationInvoiceID != "" { - resp.ProrationInvoiceID = app.ProrationInvoiceID - return resp, nil - } - - // Activation gate (D1d): unactivated accounts are never charged — the row - // is recorded and the guard stays unarmed (no retroactive catch-up in v1). - activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("account activation lookup failed", err) - } - if !activated { - return resp, nil - } - hasPM, err := s.store.HasUsableDefaultPM(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("usable PM check failed", err) - } - if !hasPM { - return resp, nil // same skipped_no_pm posture as the spine — row kept, no invoice - } - - // The creation period is the anchored window CONTAINING the app's - // created_at (ADR 0005 anchor from the activation day) — windowed from the - // read-back mirror row's created_at, the stable first-registration anchor, - // NEVER from now: a retry landing after the boundary must not recompute - // against the NEW period (ProratedBaseMicros would return the FULL base for - // a window the boundary advance leg already billed → double charge, and the - // legitimate creation-period proration would be lost). The proration bills - // the window's remaining whole UTC days, creation day inclusive. - // TODO(plan): plan-resolved base once a plan resolver exists (v1 = default). - periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(app.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) - - // INVARIANT: the proration leg bills ONLY the creation period. If that - // window has already ended, skip the charge entirely — charge 0, guard - // left UNARMED — deterministically on this and every future retry. Every - // later period is owned by the boundary advance leg (it bills the apps - // that existed before the period opened), and v1 does no retroactive - // catch-up (D1d), so a late retry must never invoice anything here. - if !s.nowFn().UTC().Before(periodEnd) { - return resp, nil - } - - prorated := usage.ProratedBaseMicros( - usage.AppBaseFeeMicros(usage.BaseFeeMicros, app.ModuleCount), - app.CreatedAt, periodStart, periodEnd, - ) - cents, err := centsFromMicros(prorated) - if err != nil { - return nil, billing.Internal("micros to cents conversion failed", err) - } - if cents == 0 { - // Rounded to 0 cents — nothing to invoice; the row stands and the - // boundary leg picks the app up from the next period on. - return resp, nil - } - - if s.stripe == nil { - return nil, billing.Internal("RegisterApp proration requires a Stripe client", nil) - } - custID, err := s.store.AccountStripeCustomer(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) - } - if custID == "" { - // A usable PM implies a Customer (same anomaly posture as the spine). - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) - } - - desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", app.AppID) - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, appProrationItemIdemKey(app.AppID)); err != nil { - return nil, billing.StripeError("proration invoice item failed", err) - } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, appProrationInvoiceIdemKey(app.AppID)) - if err != nil { - return nil, billing.StripeError("proration invoice failed", err) - } - - // Mirror with the PARTIAL window: [creation day (UTC midnight), period - // end) — the SAME coverage-start instant ProratedBaseMicros priced, so - // the mirrored window and the charged amount agree by construction. - partialStart := usage.ProrationCoverageStart(app.CreatedAt, periodStart) - if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: app.AccountID, - StripeInvoiceID: inv.ID, - Status: inv.Status, - AmountDueCents: inv.AmountDue, - AmountPaidCents: inv.AmountPaid, - Currency: chargeCurrency, - PeriodStart: partialStart, - PeriodEnd: periodEnd, - }); err != nil { - return nil, billing.Internal("invoice mirror upsert failed", err) - } - - // Freeze what this charge actually billed (migration 028) BEFORE arming the - // guard: the snapshot is the display's authoritative base for the creation - // period, so a later SyncAppModules can never drift the shown base away - // from this invoice. Keyed by the FULL anchored period_start (the display - // identity); BaseMicros is the prorated partial-window amount. Idempotent — - // a retry that failed between here and the guard re-upserts identical - // values through the same Stripe idem keys. - if err := s.store.UpsertProrationBaseSnapshot(ctx, AppBaseSnapshot{ - AppID: app.AppID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - ModuleCount: app.ModuleCount, - BaseMicros: prorated, - }); err != nil { - return nil, billing.Internal("proration base snapshot upsert failed", err) - } - - if err := s.store.SetAppProrationInvoice(ctx, app.AppID, inv.ID); err != nil { - return nil, billing.Internal("arm proration guard failed", err) - } - - resp.ProrationInvoiceID = inv.ID - resp.ProrationCents = cents - return resp, nil + return &RegisterAppResponse{ + AppID: app.AppID, + AccountID: app.AccountID, + ProrationInvoiceID: app.ProrationInvoiceID, // "" until the sweep charges + }, nil } // SyncAppModules updates an app's roster row: a new installed-module-count @@ -351,12 +205,3 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) Deleted: app.Deleted, }, nil } - -// appProrationItemIdemKey / appProrationInvoiceIdemKey build the deterministic -// Stripe Idempotency-Keys for the creation-proration charge. The APP id is the -// stable charge identity (each app prorates at most once — the one-shot -// proration_invoice_id guard), exactly as the RUN id is for the boundary leg's -// ii-/inv- keys: a RegisterApp retry reuses the SAME Stripe objects and can -// never double-charge even before the guard is armed. -func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } -func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index fb0195c..140940d 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -38,9 +38,13 @@ func appsSvc(store *fakeStore, sc *fakeStripe) *cycle.Service { return cycle.NewService(store, sc).WithNow(func() time.Time { return appsNow }) } -// --- RegisterApp: proration happy path -------------------------------------- +// --- RegisterApp: mirror-only (creation grace — RegisterApp never charges) --- -func TestRegisterApp_ChargesCreationProration(t *testing.T) { +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 + // row. The creation-period base is the sweep's job (see proration_test.go), + // so an app charged before it survives grace is impossible. store := newFakeStore() user, acct := registeredAccount(store) sc := newFakeStripe() @@ -49,97 +53,68 @@ func TestRegisterApp_ChargesCreationProration(t *testing.T) { resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ OwnerUserID: user, AppID: appID, - CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), // the 1st → days 1–3 of [Jun 4, Jul 4) + ModuleCount: 3, + CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), }) require.NoError(t, err) require.Equal(t, acct, resp.AccountID) - // 20e6 × 3/30 = 2_000_000 micros → 200 cents, one item + one invoice with - // the app-scoped deterministic idem keys. - require.EqualValues(t, 200, resp.ProrationCents) - require.Len(t, sc.itemCalls, 1) - require.Len(t, sc.invoiceCalls, 1) - require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) - require.Equal(t, "cus_apps_1", sc.itemCalls[0].custID) - require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) - require.Equal(t, "app-inv-"+appID.String(), sc.invoiceCalls[0].idemKey) - require.True(t, sc.invoiceCalls[0].autoAdvance) - - // Mirrored with the PARTIAL window [creation day, period end) and the - // one-shot guard armed with the invoice id. - require.Equal(t, resp.ProrationInvoiceID, sc.invoiceID) - mirror := store.invoices[sc.invoiceID] - require.Equal(t, acct, mirror.AccountID) - require.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) - require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) - require.Equal(t, sc.invoiceID, store.apps[appID].ProrationInvoiceID) - - // And the migration-028 snapshot froze EXACTLY what was billed, keyed by - // the FULL anchored period start (the display identity), with the - // prorated partial-window amount. - snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] - require.True(t, ok, "the proration leg must freeze its charged base") - require.Equal(t, "proration", snap.source) - require.EqualValues(t, 2_000_000, snap.snap.BaseMicros) - require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), snap.snap.PeriodEnd) - 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. - store := newFakeStore() - user, _ := registeredAccount(store) - sc := newFakeStripe() + // NO charge of any kind, guard unarmed, no snapshot, no invoice. + require.Empty(t, resp.ProrationInvoiceID) + require.Zero(t, resp.ProrationCents) + require.Empty(t, sc.itemCalls) + require.Empty(t, sc.invoiceCalls) + require.Empty(t, store.invoices) + require.Empty(t, store.baseSnapshots) - resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, - AppID: uuid.New(), - ModuleCount: 7, - CreatedAt: time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), - }) - require.NoError(t, err) - require.EqualValues(t, 1300, resp.ProrationCents) + // But the roster row IS recorded verbatim (created_at / module_count / account) + // — the stable anchor the sweep later prices from. + app := store.apps[appID] + require.Equal(t, acct, app.AccountID) + require.Equal(t, 3, app.ModuleCount) + require.Equal(t, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), app.CreatedAt) + require.Empty(t, app.ProrationInvoiceID) } func TestRegisterApp_DefaultsCreatedAtToNow(t *testing.T) { - // Zero CreatedAt → the server's now (appsNow, Jul 1) → the same 3-day - // proration as the explicit-timestamp case. + // Zero CreatedAt → the server's now (appsNow, Jul 1) is stamped on the mirror + // row (the anchor the later sweep prorates from). Still no charge here. store := newFakeStore() user, _ := registeredAccount(store) sc := newFakeStripe() + appID := uuid.New() resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: uuid.New(), + OwnerUserID: user, AppID: appID, }) require.NoError(t, err) - require.EqualValues(t, 200, resp.ProrationCents) + require.Empty(t, resp.ProrationInvoiceID) + require.Empty(t, sc.itemCalls) + require.Equal(t, appsNow, store.apps[appID].CreatedAt) } -// --- RegisterApp: idempotency ------------------------------------------------ - -func TestRegisterApp_RetryNeverChargesTwice(t *testing.T) { +func TestRegisterApp_EchoesArmedGuardOnRetry(t *testing.T) { + // A RegisterApp retry that lands AFTER the sweep already charged echoes the + // armed guard's invoice id (idempotent visibility) and still never charges. store := newFakeStore() user, _ := registeredAccount(store) sc := newFakeStripe() svc := appsSvc(store, sc) - req := cycle.RegisterAppRequest{ - OwnerUserID: user, - AppID: uuid.New(), - CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), - } + appID := uuid.New() + req := cycle.RegisterAppRequest{OwnerUserID: user, AppID: appID, CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)} - first, err := svc.RegisterApp(context.Background(), req) + _, err := svc.RegisterApp(context.Background(), req) require.NoError(t, err) - second, err := svc.RegisterApp(context.Background(), req) + // The sweep charges it (grace elapsed). + _, err = svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) + armed := store.apps[appID].ProrationInvoiceID + require.NotEmpty(t, armed) - // The one-shot guard short-circuits the retry: SAME invoice id back, ONE - // Stripe item + invoice total, ONE roster row. - require.Equal(t, first.ProrationInvoiceID, second.ProrationInvoiceID) - require.Len(t, sc.itemCalls, 1, "a retry must never create a second charge") - require.Len(t, sc.invoiceCalls, 1) - require.Len(t, store.apps, 1) + resp, err := svc.RegisterApp(context.Background(), req) + require.NoError(t, err) + require.Equal(t, armed, resp.ProrationInvoiceID) + require.Len(t, sc.itemCalls, 1, "the retry must not add a second charge") } func TestRegisterApp_RetryKeepsFirstRegistrationsAnchor(t *testing.T) { @@ -165,32 +140,14 @@ func TestRegisterApp_RetryKeepsFirstRegistrationsAnchor(t *testing.T) { require.Equal(t, created, store.apps[appID].CreatedAt) } -// --- RegisterApp: activation gate (D1d) --------------------------------------- - -func TestRegisterApp_UnactivatedAccountRowButNoInvoice(t *testing.T) { +func TestRegisterApp_RecordsRowRegardlessOfAccountState(t *testing.T) { + // RegisterApp no longer gates on activation / PM (that moved to the charge + // sweep). It records the roster row unconditionally — even for an + // unactivated account with no PM — so the sweep can price it once the + // account becomes chargeable. No charge, ever, in this RPC. store := newFakeStore() user, acct := registeredAccount(store) delete(store.activation, acct) // never bound a card - sc := newFakeStripe() - appID := uuid.New() - - resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: appID, - }) - require.NoError(t, err) - - // The mirror row IS recorded (the boundary leg needs it once the account - // activates) but NO invoice exists and the guard stays unarmed. - require.Contains(t, store.apps, appID) - require.Empty(t, resp.ProrationInvoiceID) - require.Empty(t, sc.itemCalls) - require.Empty(t, sc.invoiceCalls) - require.Empty(t, store.apps[appID].ProrationInvoiceID) -} - -func TestRegisterApp_NoUsablePMRowButNoInvoice(t *testing.T) { - store := newFakeStore() - user, _ := registeredAccount(store) store.hasPM = false sc := newFakeStripe() appID := uuid.New() @@ -202,99 +159,7 @@ func TestRegisterApp_NoUsablePMRowButNoInvoice(t *testing.T) { require.Contains(t, store.apps, appID) require.Empty(t, resp.ProrationInvoiceID) require.Empty(t, sc.itemCalls) -} - -func TestRegisterApp_CreatedOnBoundaryChargesNewPeriodFullBase(t *testing.T) { - // Created exactly ON the anchor boundary instant (Jul 4 00:00): half-open - // windows put the creation in the NEW period [Jul 4, Aug 4), so the - // proration leg charges the FULL new-period base ($20 → 2000 cents). This - // is the fix direction of the charge-leg overlap review: the Jul-4 - // boundary's advance leg EXCLUDES apps with created_at ≥ Jul 4, so if - // RegisterApp skipped here the app's first period would never be - // base-charged (a revenue leak); charging it here bills it exactly once. - // (Pre-fix this recomputed 0 remaining days against the OLD window and - // charged nothing, leaving the period to the advance leg's roster scan.) - store := newFakeStore() - user, _ := registeredAccount(store) - sc := newFakeStripe() - appID := uuid.New() - - resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, - AppID: appID, - CreatedAt: time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), - }) - require.NoError(t, err) - require.EqualValues(t, 2_000, resp.ProrationCents, "creation-day == period start → full base") - require.Len(t, sc.itemCalls, 1) - - // Mirrored with the FULL new window (partial start == period start) and - // the snapshot frozen for [Jul 4, Aug 4). - mirror := store.invoices[sc.invoiceID] - require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) - require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) - snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}] - require.True(t, ok, "the proration leg must freeze the charged base") - require.Equal(t, "proration", snap.source) - require.EqualValues(t, usage.BaseFeeMicros, snap.snap.BaseMicros) -} - -func TestRegisterApp_RetryAfterCreationPeriodClosedNeverCharges(t *testing.T) { - // FINDING-1 pin: a RegisterApp retry that lands AFTER the creation period - // closed must NOT charge — the proration window is derived from the mirror - // row's created_at (May 20 → [May 4, Jun 4)), that window ended before now - // (Jul 1), and every later period belongs to the boundary advance leg - // (which already billed [Jun 4, Jul 4) for pre-existing apps). Pre-fix the - // window was recomputed from NOW, ProratedBaseMicros saw creation-day ≤ - // period start, and the retry double-charged the FULL current-period base. - store := newFakeStore() - user, _ := registeredAccount(store) - sc := newFakeStripe() - svc := appsSvc(store, sc) - appID := uuid.New() - req := cycle.RegisterAppRequest{ - OwnerUserID: user, - AppID: appID, - CreatedAt: time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC), // period [May 4, Jun 4) — long closed at appsNow (Jul 1) - } - - resp, err := svc.RegisterApp(context.Background(), req) - require.NoError(t, err) - - // Row recorded, NO charge, guard UNARMED, no snapshot. - require.Contains(t, store.apps, appID) - require.Empty(t, resp.ProrationInvoiceID) - require.Empty(t, sc.itemCalls, "a closed creation period must never be prorated late") require.Empty(t, sc.invoiceCalls) - require.Empty(t, store.apps[appID].ProrationInvoiceID, "guard stays unarmed") - require.Empty(t, store.baseSnapshots) - - // Deterministic: every further retry skips identically (never "eventually - // charges"). - resp, err = svc.RegisterApp(context.Background(), req) - require.NoError(t, err) - require.Empty(t, resp.ProrationInvoiceID) - require.Empty(t, sc.itemCalls) - require.Empty(t, store.apps[appID].ProrationInvoiceID) -} - -func TestRegisterApp_CurrentPeriodProrationUnchangedByWindowFix(t *testing.T) { - // FINDING-1 regression guard: for an app created in a period that is - // still CURRENT, deriving the window from created_at instead of now must - // change NOTHING — same window, same 3-day proration, snapshot frozen at - // the anchored period start with the prorated amount. - store := newFakeStore() - user, _ := registeredAccount(store) - sc := newFakeStripe() - - resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, - AppID: uuid.New(), - CreatedAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), // inside the current [Jun 4, Jul 4) - }) - require.NoError(t, err) - require.EqualValues(t, 200, resp.ProrationCents, "3 of 30 days of $20 → 200 cents, exactly as before the fix") - require.Len(t, sc.itemCalls, 1) } // --- RegisterApp: account resolution + validation ----------------------------- @@ -607,16 +472,19 @@ func TestRunBillingCycle_ReclaimedRunNoDoubleBase(t *testing.T) { require.Equal(t, cycle.RunStatusSkippedNoPM, first.Status) require.Empty(t, sc.invoiceCalls) - // PM bound; an app is created Jul 2 (inside the new period [Jul 1, Aug 1)) - // and RegisterApp prorates it: 30 of 31 days of $20 → 19_354_839 micros. + // PM bound; an app is created Jul 2 (inside the new period [Jul 1, Aug 1)). + // RegisterApp only mirrors it; the creation-proration charge (the leg the + // grace sweep drives) prorates 30 of 31 days of $20 → 19_354_839 micros. store.hasPM = true newApp := uuid.New() - reg, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ OwnerUserID: user, AppID: newApp, CreatedAt: time.Date(2026, 7, 2, 10, 0, 0, 0, time.UTC), }) require.NoError(t, err) + reg, err := svc.ChargeCreationProration(context.Background(), newApp) + require.NoError(t, err) require.EqualValues(t, 1_935, reg.ProrationCents) require.Len(t, sc.invoiceCalls, 1) diff --git a/internal/account/cycle/migration029_integration_test.go b/internal/account/cycle/migration029_integration_test.go new file mode 100644 index 0000000..119188b --- /dev/null +++ b/internal/account/cycle/migration029_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 029 (apps_pending_proration_idx) — verifies the SQL-level semantics +// the unit fakes only model for the creation-grace sweep: the AppsPendingProration +// work-list filter (past grace AND live AND unarmed) and the ChargeProrationLocked +// FOR UPDATE section (deleted / already-armed short-circuits, and the atomic +// charge → mirror → snapshot → arm on a live unarmed app). + +func TestAppsPendingProration_Integration_Filter(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + cutoff := mustTime(t, "2026-06-27T00:00:00Z") // now − GraceDays for a sweep on Jun 30 + + pending := uuid.New() // created before cutoff, live, unarmed → returned + young := uuid.New() // created after cutoff (within grace) → excluded + deleted := uuid.New() // before cutoff but soft-deleted → excluded + charged := uuid.New() // before cutoff but guard armed → excluded + require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, mustTime(t, "2026-06-29T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) + require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) + require.NoError(t, store.MarkAppDeleted(ctx, deleted)) + require.NoError(t, store.SetAppProrationInvoice(ctx, charged, "in_already")) + + ids, err := store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{pending}, ids, "only the past-grace, live, unarmed app is pending") +} + +func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + periodStart := mustTime(t, "2026-06-04T00:00:00Z") + periodEnd := mustTime(t, "2026-07-04T00:00:00Z") + + // mkCharge builds the payload the service's charge callback would return. + mkCharge := func(appID uuid.UUID, invID string) *cycle.ProrationCharge { + return &cycle.ProrationCharge{ + InvoiceID: invID, + Cents: 200, + Invoice: cycle.InvoiceMirror{ + AccountID: acct, StripeInvoiceID: invID, Status: "open", + AmountDueCents: 200, Currency: "usd", + PeriodStart: mustTime(t, "2026-07-01T00:00:00Z"), PeriodEnd: periodEnd, + }, + Snapshot: cycle.AppBaseSnapshot{ + AppID: appID, PeriodStart: periodStart, PeriodEnd: periodEnd, + ModuleCount: 0, BaseMicros: 2_000_000, + }, + } + } + + // 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"))) + called := false + outcome, invID, err := store.ChargeProrationLocked(ctx, live, func(l cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + require.Equal(t, live, l.AppID) + return mkCharge(live, "in_live"), nil + }) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, cycle.ProrationLockedCharged, outcome) + require.Equal(t, "in_live", invID) + app, _, err := store.AppMirror(ctx, live) + require.NoError(t, err) + require.Equal(t, "in_live", app.ProrationInvoiceID, "the guard is armed under the lock") + + // Re-run on the now-armed app → AlreadyCharged, callback NOT invoked, no + // second invoice. + called = false + outcome, invID, err = store.ChargeProrationLocked(ctx, live, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + return mkCharge(live, "in_second"), nil + }) + require.NoError(t, err) + require.False(t, called, "an armed guard short-circuits before the Stripe charge") + require.Equal(t, cycle.ProrationLockedAlreadyCharged, outcome) + require.Equal(t, "in_live", invID) + + // Deleted app → Deleted, callback NOT invoked (a within-grace delete is never + // charged, and a delete that won the race no-ops the charge). + deleted := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + require.NoError(t, store.MarkAppDeleted(ctx, deleted)) + called = false + outcome, _, err = store.ChargeProrationLocked(ctx, deleted, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + return mkCharge(deleted, "in_deleted"), nil + }) + require.NoError(t, err) + require.False(t, called, "a deleted app is never charged") + require.Equal(t, cycle.ProrationLockedDeleted, outcome) + + // 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"))) + outcome, _, err = store.ChargeProrationLocked(ctx, zero, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + return nil, nil + }) + require.NoError(t, err) + require.Equal(t, cycle.ProrationLockedNoCharge, outcome) + app, _, err = store.AppMirror(ctx, zero) + require.NoError(t, err) + require.Empty(t, app.ProrationInvoiceID, "a declined charge leaves the guard unarmed") +} diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go new file mode 100644 index 0000000..95abd20 --- /dev/null +++ b/internal/account/cycle/proration.go @@ -0,0 +1,314 @@ +package cycle + +// Creation-proration charge + sweep (creation grace, owner spec 2026-07-05, +// D1e follow-up). RegisterApp used to charge an app's creation-period base +// synchronously at creation; it no longer does (see apps.go). Instead a newly +// created app enters a GRACE window and is charged only once it has SURVIVED it: +// +// - RegisterApp mirrors the roster row (created_at, account, module_count) and +// charges NOTHING; +// - a periodic sweep (SweepCreationProrations, driven by cmd/billing-cycle) +// finds apps past grace (created_at <= now − GraceDays) that are still LIVE +// (deleted_at IS NULL) and NOT yet charged (proration_invoice_id IS NULL), +// and charges each the SAME creation-period proration RegisterApp used to — +// identical ProratedBaseMicros math, anchored to the TRUE created_at, so the +// app pays only for the days it actually existed. Grace delays WHEN the +// charge fires, never WHAT it covers. +// +// An app soft-deleted within grace is thus NEVER charged (the sweep excludes +// deleted rows), and the charge is race-safe against a concurrent delete via a +// FOR UPDATE row lock (see ChargeProrationLocked). + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" +) + +// ProrationStatus classifies one ChargeCreationProration outcome for the sweep's +// tally + per-app log line. Only ProrationStatusCharged mints a new invoice; the +// rest are legitimate no-charge outcomes (D1d/D1e) or the idempotent guard. +type ProrationStatus string + +const ( + // ProrationStatusCharged: the creation-proration invoice was created and the + // one-shot guard armed on THIS call. + ProrationStatusCharged ProrationStatus = "charged" + // ProrationStatusAlreadyCharged: the guard was already armed (a prior sweep, + // or a concurrent one, charged) — idempotent success, no second invoice. + ProrationStatusAlreadyCharged ProrationStatus = "already_charged" + // ProrationStatusDeleted: the app is soft-deleted (within grace, or a delete + // that won the race under the lock) → never charged (D1e, no refunds). + ProrationStatusDeleted ProrationStatus = "skipped_deleted" + // ProrationStatusUnactivated: the owner account never bound a card → never + // charged (D1d, no retroactive catch-up on later activation in v1). + ProrationStatusUnactivated ProrationStatus = "skipped_unactivated" + // ProrationStatusNoPM: activated but no usable default PM → skipped, same + // posture as the boundary spine; re-attempted on the next sweep. + ProrationStatusNoPM ProrationStatus = "skipped_no_pm" + // ProrationStatusNoCharge: the proration rounded to 0 cents (effectively + // unreachable for a real survived app whose base is ≥ $20) → nothing to + // invoice, guard left unarmed. + ProrationStatusNoCharge ProrationStatus = "no_charge" + // ProrationStatusNotFound: no roster row for the app id (never registered). + ProrationStatusNotFound ProrationStatus = "not_found" +) + +// ProrationResult reports what ChargeCreationProration did. ProrationInvoiceID is +// set on ProrationStatusCharged (the new invoice) and ProrationStatusAlreadyCharged +// (the pre-existing one); ProrationCents only on a fresh charge. +type ProrationResult struct { + AppID uuid.UUID + Status ProrationStatus + ProrationInvoiceID string + ProrationCents int64 +} + +// ProrationOutcome is the store's report from the locked charge section +// (ChargeProrationLocked), decided UNDER the row lock where the terminal +// deleted/guard state is authoritative. +type ProrationOutcome int + +const ( + // ProrationLockedNotFound: the row vanished between the sweep's read and the + // lock (unregistered / cascade-deleted). + ProrationLockedNotFound ProrationOutcome = iota + // ProrationLockedDeleted: deleted_at is set under the lock — a delete won. + ProrationLockedDeleted + // ProrationLockedAlreadyCharged: proration_invoice_id is set under the lock. + ProrationLockedAlreadyCharged + // ProrationLockedNoCharge: the charge callback declined (0 cents) — nothing + // persisted, guard unarmed. + ProrationLockedNoCharge + // ProrationLockedCharged: the charge fired, was mirrored + snapshotted, and + // the guard armed, all committed atomically. + ProrationLockedCharged +) + +// ProrationCharge is the persistence payload the charge callback returns from +// inside the locked transaction: the created Stripe invoice to mirror, the base +// snapshot to freeze (migration 028, source='proration'), and the invoice id +// that arms the one-shot guard. Cents is echoed to the caller. A nil return from +// the callback means "nothing to charge" (0 cents) — the store persists nothing. +type ProrationCharge struct { + InvoiceID string + Cents int64 + Invoice InvoiceMirror + Snapshot AppBaseSnapshot +} + +// ChargeCreationProration charges (once) the creation-period base proration for +// ONE app that has survived the grace window — the shared charge leg the sweep +// invokes per pending app. It is idempotent (the one-shot proration_invoice_id +// guard) and race-safe against a concurrent soft-delete (the FOR UPDATE section). +// +// The amount is the SAME as the pre-grace RegisterApp charge: +// +// ProratedBaseMicros(AppBaseFeeMicros(BaseFeeMicros, module_count), +// created_at, the anchored period CONTAINING created_at) +// +// anchored to the TRUE created_at (NOT now), so the app pays only for the whole +// UTC days it existed in its creation period, creation day inclusive — grace +// only delayed WHEN this fires. It is deliberately NOT gated on whether the +// creation period has since ended: that period is billed by NO other leg (the +// boundary advance leg only ever bills an app's SUBSEQUENT periods, never the one +// containing its creation), so charging it whenever the guard is unarmed is +// always correct and can never double-bill. +// +// Cheap gates that don't need the row lock (unregistered / already-charged / +// deleted / unactivated / no-PM) short-circuit first; the actual charge + arm +// runs under the lock (ChargeProrationLocked), which re-verifies the deleted + +// guard state authoritatively. +func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) (*ProrationResult, error) { + if appID == uuid.Nil { + return nil, billing.InvalidInput("app_id required") + } + + app, found, err := s.store.AppMirror(ctx, appID) + if err != nil { + return nil, billing.Internal("app mirror lookup failed", err) + } + if !found { + return &ProrationResult{AppID: appID, Status: ProrationStatusNotFound}, nil + } + // Idempotent short-circuit: a prior (or concurrent) sweep already charged. + if app.ProrationInvoiceID != "" { + return &ProrationResult{AppID: appID, Status: ProrationStatusAlreadyCharged, ProrationInvoiceID: app.ProrationInvoiceID}, nil + } + // Deleted within grace (or after) → never charged (D1e). The locked section + // re-checks this authoritatively; this is the cheap early-out. + if app.Deleted { + return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil + } + + // Activation + PM gates (D1d), same posture as the boundary spine: an + // unactivated account (never bound a card) is never charged, and an activated + // one with no usable default PM is skipped and re-attempted next sweep. + activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("account activation lookup failed", err) + } + if !activated { + return &ProrationResult{AppID: appID, Status: ProrationStatusUnactivated}, nil + } + hasPM, err := s.store.HasUsableDefaultPM(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("usable PM check failed", err) + } + if !hasPM { + return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil + } + if s.stripe == nil { + return nil, billing.Internal("ChargeCreationProration requires a Stripe client", nil) + } + custID, err := s.store.AccountStripeCustomer(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + // A usable PM implies a Customer (same anomaly posture as the spine). + return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) + } + + // The charge callback runs INSIDE the locked transaction (ChargeProrationLocked): + // the not-deleted re-check, the Stripe charge, and the guard-arm are one atomic + // unit so a racing delete and this charge are mutually exclusive (no refund path). + var cents int64 + outcome, invID, err := s.store.ChargeProrationLocked(ctx, appID, func(locked AppMirror) (*ProrationCharge, error) { + // Window = the anchored period CONTAINING the app's created_at (ADR 0005 + // anchor from the activation day). Derived from created_at, NEVER from now, + // so the amount is deterministic regardless of when the sweep fires. + periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(locked.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) + prorated := usage.ProratedBaseMicros( + usage.AppBaseFeeMicros(usage.BaseFeeMicros, locked.ModuleCount), + locked.CreatedAt, periodStart, periodEnd, + ) + c, err := centsFromMicros(prorated) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) + } + if c == 0 { + return nil, nil // rounds to 0 cents → nothing to invoice (guard stays unarmed) + } + + desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { + return nil, billing.StripeError("proration invoice item failed", err) + } + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, appProrationInvoiceIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration invoice failed", err) + } + + // Mirror the PARTIAL window [creation day, period end) — the same coverage + // start ProratedBaseMicros priced, so mirror and amount agree by construction. + partialStart := usage.ProrationCoverageStart(locked.CreatedAt, periodStart) + cents = c + return &ProrationCharge{ + InvoiceID: inv.ID, + Cents: c, + Invoice: InvoiceMirror{ + AccountID: locked.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: partialStart, + PeriodEnd: periodEnd, + }, + // Freeze what was billed keyed by the FULL anchored period_start (the + // display identity, migration 028); BaseMicros is the prorated amount. + Snapshot: AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + ModuleCount: locked.ModuleCount, + BaseMicros: prorated, + }, + }, nil + }) + if err != nil { + // A billing.Error from the charge callback (Stripe / conversion) is already + // classified — surface it verbatim; anything else is a store/tx failure. + if _, ok := err.(*billing.Error); ok { + return nil, err + } + return nil, billing.Internal("locked creation-proration charge failed", err) + } + + switch outcome { + case ProrationLockedCharged: + return &ProrationResult{AppID: appID, Status: ProrationStatusCharged, ProrationInvoiceID: invID, ProrationCents: cents}, nil + case ProrationLockedAlreadyCharged: + return &ProrationResult{AppID: appID, Status: ProrationStatusAlreadyCharged, ProrationInvoiceID: invID}, nil + case ProrationLockedDeleted: + return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil + case ProrationLockedNotFound: + return &ProrationResult{AppID: appID, Status: ProrationStatusNotFound}, nil + default: // ProrationLockedNoCharge + return &ProrationResult{AppID: appID, Status: ProrationStatusNoCharge}, nil + } +} + +// SweepProrationsResult tallies one SweepCreationProrations batch for the +// cmd/billing-cycle log line + exit code. +type SweepProrationsResult struct { + Pending int // apps past grace with an unarmed guard (the work list size) + Charged int // creation-proration invoices minted this sweep + Skipped int // legitimate no-charge outcomes (deleted / unactivated / no-PM / already / 0¢) + Failed int // per-app errors (charge failures) — retried next sweep +} + +// SweepCreationProrations charges the creation-period base for every app that has +// survived the grace window as of `at`: it lists the pending apps (created_at <= +// at − GraceDays, guard unarmed, not deleted) and runs ChargeCreationProration on +// each. Idempotent + resumable: an app charged on a prior sweep drops out of the +// work list (guard armed), and a per-app failure is counted but never aborts the +// batch (the next sweep retries it through the same deterministic Stripe keys). +func (s *Service) SweepCreationProrations(ctx context.Context, at time.Time) (*SweepProrationsResult, error) { + if at.IsZero() { + return nil, billing.InvalidInput("sweep instant required") + } + createdBefore := at.UTC().AddDate(0, 0, -usage.GraceDays) + appIDs, err := s.store.AppsPendingProration(ctx, createdBefore) + if err != nil { + return nil, billing.Internal("list pending prorations failed", err) + } + + res := &SweepProrationsResult{Pending: len(appIDs)} + for _, id := range appIDs { + r, err := s.ChargeCreationProration(ctx, id) + if err != nil { + slog.ErrorContext(ctx, "creation-proration charge failed", + "app_id", id, "error", err) + res.Failed++ + continue + } + if r.Status == ProrationStatusCharged { + res.Charged++ + } else { + res.Skipped++ + } + slog.InfoContext(ctx, "creation-proration", + "app_id", id, "status", string(r.Status), + "invoice_id", r.ProrationInvoiceID, "cents", r.ProrationCents) + } + return res, nil +} + +// appProrationItemIdemKey / appProrationInvoiceIdemKey build the deterministic +// Stripe Idempotency-Keys for the creation-proration charge. The APP id is the +// stable charge identity (each app prorates at most once — the one-shot +// proration_invoice_id guard), so a re-attempt (a retried sweep after a crash +// between the Stripe call and the guard-arm) reuses the SAME Stripe objects and +// can never double-charge even before the guard is armed. +func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } +func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go new file mode 100644 index 0000000..ebd228c --- /dev/null +++ b/internal/account/cycle/proration_test.go @@ -0,0 +1,316 @@ +package cycle_test + +// ChargeCreationProration + SweepCreationProrations (creation grace, owner spec +// 2026-07-05). Reuses the in-memory fakeStore (service_test.go) + fakeStripe +// (charge_test.go) and the appsNow / registeredAccount helpers (apps_test.go). + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" +) + +// registerMirror seeds a roster row through RegisterApp (which only mirrors — no +// charge) so the app is owned by the fully-chargeable registeredAccount. +func registerMirror(t *testing.T, svc *cycle.Service, user, appID uuid.UUID, created time.Time, moduleCount int) { + t.Helper() + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: moduleCount, CreatedAt: created, + }) + require.NoError(t, err) +} + +// --- (a) grace holds: an app charged before GraceDays elapse is impossible --- + +func TestSweep_SkipsAppsWithinGrace(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Sweep only 2 days later (< GraceDays=3): the app is INSIDE the grace window + // (created_at Jul 1 > cutoff Jul 3 − 3d = Jun 30), so it is not even a + // candidate — no charge, nothing pending. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "an app younger than GraceDays is not swept") + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls, "charging within grace is impossible") + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +// --- (b) an app deleted within grace is NEVER charged, even by a later sweep -- + +func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Deleted on day 0–1 (well within grace). + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.True(t, store.apps[appID].Deleted) + + // A sweep 9 days later (long past grace) must still NEVER charge it: the + // deleted row is excluded from the work list. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "a deleted app is excluded from the sweep") + require.Empty(t, sc.itemCalls, "an app deleted within grace is never charged") + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +// --- (c) a survivor is charged EXACTLY ONCE even if the sweep runs twice ------ + +func TestSweep_ChargesSurvivorExactlyOnceAcrossReruns(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // First sweep past grace → charges the creation proration once. + first, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, first.Pending) + require.Equal(t, 1, first.Charged) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) // 3 of 30 days of $20 + armed := store.apps[appID].ProrationInvoiceID + require.NotEmpty(t, armed) + + // Second sweep (a re-fire the next day): the guard is armed, so the app is no + // longer pending and no second invoice is created. + second, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, second.Pending, "an already-charged app drops out of the work list") + require.Equal(t, 0, second.Charged) + require.Len(t, sc.itemCalls, 1, "the re-fire must never charge twice") + require.Equal(t, armed, store.apps[appID].ProrationInvoiceID) +} + +// --- (d) the proration $ amount is unchanged from the pre-grace charge -------- + +func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { + // The SAME numbers the pre-grace RegisterApp charge produced, now via the + // delayed charge leg: 20e6 × 3/30 = 2_000_000 micros → 200 cents, mirrored + // with the PARTIAL window [creation day, period end), snapshot frozen at the + // FULL anchored period start with the prorated amount. + store := newFakeStore() + user, acct := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 200, resp.ProrationCents) + + require.Len(t, sc.itemCalls, 1) + require.Len(t, sc.invoiceCalls, 1) + require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) + require.Equal(t, "cus_apps_1", sc.itemCalls[0].custID) + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + require.Equal(t, "app-inv-"+appID.String(), sc.invoiceCalls[0].idemKey) + require.True(t, sc.invoiceCalls[0].autoAdvance) + + require.Equal(t, sc.invoiceID, resp.ProrationInvoiceID) + mirror := store.invoices[sc.invoiceID] + require.Equal(t, acct, mirror.AccountID) + require.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) // partial coverage start + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + require.Equal(t, sc.invoiceID, store.apps[appID].ProrationInvoiceID) + + snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the charge freezes its base keyed on the FULL anchored period start") + require.Equal(t, "proration", snap.source) + require.EqualValues(t, 2_000_000, snap.snap.BaseMicros) + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), snap.snap.PeriodEnd) + require.Equal(t, 0, snap.snap.ModuleCount) +} + +func TestChargeCreationProration_IncludesModuleOverage(t *testing.T) { + // module_count 7 → base 20e6 + 2×3e6 = 26e6; 15 of 30 remaining days → + // 13e6 micros → 1300 cents. Identical to the pre-grace math. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 1300, resp.ProrationCents) + require.Equal(t, 7, store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}].snap.ModuleCount) +} + +func TestChargeCreationProration_CreatedOnBoundaryChargesFullNewPeriodBase(t *testing.T) { + // Created exactly ON an anchor boundary (Jul 4 00:00): half-open windows put + // it at the START of the NEW period [Jul 4, Aug 4) → the FULL base ($20 → + // 2000 cents), snapshot keyed on Jul 4. Unchanged from the pre-grace charge. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 2_000, resp.ProrationCents, "creation-day == period start → full base") + mirror := store.invoices[sc.invoiceID] + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, "proration", snap.source) + require.EqualValues(t, usage.BaseFeeMicros, snap.snap.BaseMicros) +} + +func TestChargeCreationProration_DelayedPastPeriodEndStillCharges(t *testing.T) { + // Delayed billing (grace point 5): the charge is anchored to the TRUE + // created_at, NOT now, so even when grace pushes the charge past the creation + // period's end, the creation-period proration STILL fires — that period is + // billed by NO other leg (the boundary advance leg only ever bills an app's + // SUBSEQUENT periods), so charging it is correct and never double-bills. + // App created May 20 → period [May 4, Jun 4), long closed by the sweep in + // July: 15 of 31 days of $20 = round_half_up(9_677_419.8) → 968 cents. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC), 0) + + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 968, sc.itemCalls[0].amountCfg) + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, "proration", snap.source) +} + +// --- ChargeCreationProration: idempotency + gates ---------------------------- + +func TestChargeCreationProration_IdempotentGuard(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, first.Status) + + second, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusAlreadyCharged, second.Status) + require.Equal(t, first.ProrationInvoiceID, second.ProrationInvoiceID) + require.Len(t, sc.itemCalls, 1, "the one-shot guard prevents a second charge") +} + +func TestChargeCreationProration_SkipsDeleted(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusDeleted, resp.Status) + require.Empty(t, sc.itemCalls) +} + +func TestChargeCreationProration_SkipsUnactivatedAndNoPM(t *testing.T) { + // Unactivated account → skipped_unactivated (D1d, no retroactive catch-up). + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, resp.Status) + require.Empty(t, sc.itemCalls) + + // Activated but no usable PM → skipped_no_pm (re-attempted next sweep). + store.activation[acct] = time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + store.hasPM = false + resp, err = svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusNoPM, resp.Status) + require.Empty(t, sc.itemCalls) + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +func TestChargeCreationProration_NotFound(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + resp, err := svc.ChargeCreationProration(context.Background(), uuid.New()) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusNotFound, resp.Status) +} + +func TestChargeCreationProration_Validation(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + _, err := svc.ChargeCreationProration(context.Background(), uuid.Nil) + requireCode(t, err, billing.CodeInvalidInput) +} + +func TestSweepCreationProrations_Validation(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + _, err := svc.SweepCreationProrations(context.Background(), time.Time{}) + requireCode(t, err, billing.CodeInvalidInput) +} + +// --- Sweep: multiple apps, mixed states -------------------------------------- + +func TestSweep_ChargesOnlyPastGraceLiveUnchargedApps(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + + past := uuid.New() // past grace, live, uncharged → charged + young := uuid.New() // within grace → skipped + gone := uuid.New() // past grace but deleted → skipped + registerMirror(t, svc, user, past, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) + registerMirror(t, svc, user, young, time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC), 0) + registerMirror(t, svc, user, gone, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: gone, Deleted: true}) + require.NoError(t, err) + + // Sweep as of Jun 30 → cutoff Jun 27: past (Jun 20) qualifies; young (Jun 29) + // is within grace; gone is deleted. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Pending) + require.Equal(t, 1, res.Charged) + require.NotEmpty(t, store.apps[past].ProrationInvoiceID) + require.Empty(t, store.apps[young].ProrationInvoiceID) + require.Empty(t, store.apps[gone].ProrationInvoiceID) +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 2026d7f..39b84fe 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math" + "sort" "testing" "time" @@ -76,36 +77,38 @@ type fakeStore struct { invoices map[string]cycle.InvoiceMirror // stripe_invoice_id → mirror // injected errors - errOpen error - errRaw error - errPrice error - errUpsert error - errIncome error - errVis error - errSettle error - errInsertRun error - errTotal error - errPM error - errCustomer error - errInvoice error - errMarkRun error - errUnbilled error - errUsageEvents error - errActivated error // ActivatedAccounts - errLatestPeriod error // LatestClosedPeriodEnd - errCollection error // AccountCollection - errUpdateColl error // UpdateAccountCollection - errUnpaid error // HasUnpaidInvoice - errEnsureAcct error // EnsureAccountForUser - errActivation error // AccountActivation - errAppInsert error // InsertAppMirror - errAppMirror error // AppMirror - errSetProration error // SetAppProrationInvoice - errSetCount error // SetAppModuleCount - errMarkDeleted error // MarkAppDeleted - errLiveCounts error // LiveAppsCreatedBefore - errProrationSnap error // UpsertProrationBaseSnapshot - errAdvanceSnap error // InsertAdvanceBaseSnapshot + errOpen error + errRaw error + errPrice error + errUpsert error + errIncome error + errVis error + errSettle error + errInsertRun error + errTotal error + errPM error + errCustomer error + errInvoice error + errMarkRun error + errUnbilled error + errUsageEvents error + errActivated error // ActivatedAccounts + errLatestPeriod error // LatestClosedPeriodEnd + errCollection error // AccountCollection + errUpdateColl error // UpdateAccountCollection + errUnpaid error // HasUnpaidInvoice + errEnsureAcct error // EnsureAccountForUser + errActivation error // AccountActivation + errAppInsert error // InsertAppMirror + errAppMirror error // AppMirror + errSetProration error // SetAppProrationInvoice + errSetCount error // SetAppModuleCount + errMarkDeleted error // MarkAppDeleted + errLiveCounts error // LiveAppsCreatedBefore + errProrationSnap error // UpsertProrationBaseSnapshot + errAdvanceSnap error // InsertAdvanceBaseSnapshot + errPendingProration error // AppsPendingProration + errChargeLocked error // ChargeProrationLocked } // snapKey mirrors the app_base_snapshots PRIMARY KEY (app_id, period_start). @@ -440,6 +443,57 @@ func (f *fakeStore) MarkAppDeleted(_ context.Context, appID uuid.UUID) error { return nil } +func (f *fakeStore) AppsPendingProration(_ context.Context, createdBefore time.Time) ([]uuid.UUID, error) { + if f.errPendingProration != nil { + return nil, f.errPendingProration + } + // Mirrors the SQL: created_at <= cutoff AND proration_invoice_id IS NULL AND + // deleted_at IS NULL. Sorted by created_at for a deterministic sweep order. + var out []uuid.UUID + for id, app := range f.apps { + if app.ProrationInvoiceID == "" && !app.Deleted && !app.CreatedAt.After(createdBefore) { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { + return f.apps[out[i]].CreatedAt.Before(f.apps[out[j]].CreatedAt) + }) + return out, nil +} + +// ChargeProrationLocked models the pgxStore's FOR UPDATE-locked charge section: +// it re-checks the terminal state (the fake's in-memory row is the "locked" +// read), invokes charge only when still chargeable, and persists the invoice + +// snapshot + arms the guard on a non-nil payload — exactly what the real tx does +// atomically. +func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, charge func(cycle.AppMirror) (*cycle.ProrationCharge, error)) (cycle.ProrationOutcome, string, error) { + if f.errChargeLocked != nil { + return 0, "", f.errChargeLocked + } + app, ok := f.apps[appID] + if !ok { + return cycle.ProrationLockedNotFound, "", nil + } + if app.Deleted { + return cycle.ProrationLockedDeleted, "", nil + } + if app.ProrationInvoiceID != "" { + return cycle.ProrationLockedAlreadyCharged, app.ProrationInvoiceID, nil + } + pc, err := charge(app) + if err != nil { + return 0, "", err + } + if pc == nil { + return cycle.ProrationLockedNoCharge, "", nil + } + f.invoices[pc.Invoice.StripeInvoiceID] = pc.Invoice + f.baseSnapshots[snapKey{pc.Snapshot.AppID, pc.Snapshot.PeriodStart}] = fakeBaseSnapshot{snap: pc.Snapshot, source: "proration"} + app.ProrationInvoiceID = pc.InvoiceID // first-charge-wins, like WHERE … IS NULL under the lock + f.apps[appID] = app + return cycle.ProrationLockedCharged, pc.InvoiceID, nil +} + func (f *fakeStore) LiveAppsCreatedBefore(_ context.Context, accountID uuid.UUID, createdBefore time.Time) ([]cycle.AppModuleCount, error) { if f.errLiveCounts != nil { return nil, f.errLiveCounts diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3e248cd..30fd37f 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -183,6 +183,27 @@ type Store interface { // deletion semantics). found=false → the app was never registered. AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, bool, error) + // AppsPendingProration returns the app ids past the creation grace window + // (created_at <= createdBefore = now − GraceDays) that are still LIVE + // (deleted_at IS NULL) and NOT yet charged (proration_invoice_id IS NULL) — + // the creation-proration sweep's work list. An app deleted within grace, or + // already charged, is excluded. + AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) + + // ChargeProrationLocked runs the creation-proration charge for ONE app inside + // a single transaction that SELECT ... FOR UPDATE-locks the roster row. Under + // the lock it re-verifies the row is still chargeable (deleted_at IS NULL AND + // proration_invoice_id IS NULL); only then does it invoke charge — which + // performs the Stripe calls and returns the payload to persist — and, STILL + // under the same lock, mirrors the invoice, freezes the base snapshot, and + // arms the one-shot guard, committing atomically. Holding the lock across the + // Stripe call is deliberate: it makes a concurrent soft-delete (MarkAppDeleted) + // and this charge mutually exclusive (no refund path, D1e) — whichever commits + // first wins and the loser sees the other's write and no-ops. charge returning + // (nil, nil) means "nothing to charge" (0 cents) → nothing is persisted. The + // returned invoice id is the armed (or pre-armed) guard's. + ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(locked AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) + // SetAppProrationInvoice arms the ONE-SHOT creation-proration guard: it // records the Stripe invoice id, first-charge-wins (UPDATE … WHERE // proration_invoice_id IS NULL). An already-armed guard is NOT an error — @@ -848,6 +869,109 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b }, true, nil } +func (s *pgxStore) AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) { + rows, err := s.q.AppsPendingProration(ctx, createdBefore) + if err != nil { + return nil, err + } + return parseUUIDs(rows) +} + +func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, "", err + } + defer func() { _ = tx.Rollback(ctx) }() // no-op after a successful Commit + + qtx := s.q.WithTx(tx) + + // Lock the row and re-verify the terminal state UNDER the lock: a soft-delete + // that committed before us is now visible; one racing us blocks here until we + // commit (charge wins), then no-ops on its own WHERE deleted_at IS NULL. + row, err := qtx.SelectAppMirrorForUpdate(ctx, appID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return ProrationLockedNotFound, "", nil + } + if err != nil { + return 0, "", err + } + if row.DeletedAt.Valid { + return ProrationLockedDeleted, "", nil + } + if row.ProrationInvoiceID.Valid { + return ProrationLockedAlreadyCharged, row.ProrationInvoiceID.String, nil + } + + app, err := uuid.Parse(row.AppID) + if err != nil { + return 0, "", err + } + acct, err := uuid.Parse(row.AccountID) + if err != nil { + return 0, "", err + } + locked := AppMirror{ + AppID: app, + AccountID: acct, + ModuleCount: int(row.ModuleCount), + CreatedAt: row.CreatedAt, + } + + // Stripe charge happens INSIDE the tx (the callback), so the lock spans it — + // the not-deleted check, the charge, and the guard-arm are one atomic unit. + pc, err := charge(locked) + if err != nil { + return 0, "", err // rollback; guard unarmed → the next sweep retries (idem keys) + } + if pc == nil { + return ProrationLockedNoCharge, "", nil // 0 cents — nothing to invoice + } + + due, err := centsNumeric(pc.Invoice.AmountDueCents) + if err != nil { + return 0, "", err + } + paid, err := centsNumeric(pc.Invoice.AmountPaidCents) + if err != nil { + return 0, "", err + } + if err := qtx.UpsertInvoice(ctx, db.UpsertInvoiceParams{ + AccountID: pc.Invoice.AccountID.String(), + StripeInvoiceID: pc.Invoice.StripeInvoiceID, + Status: pc.Invoice.Status, + AmountDue: due, + AmountPaid: paid, + Currency: pc.Invoice.Currency, + PeriodStart: pgtype.Timestamptz{Time: pc.Invoice.PeriodStart, Valid: !pc.Invoice.PeriodStart.IsZero()}, + PeriodEnd: pgtype.Timestamptz{Time: pc.Invoice.PeriodEnd, Valid: !pc.Invoice.PeriodEnd.IsZero()}, + }); err != nil { + return 0, "", err + } + if err := qtx.UpsertProrationBaseSnapshot(ctx, db.UpsertProrationBaseSnapshotParams{ + AppID: pc.Snapshot.AppID.String(), + PeriodStart: pc.Snapshot.PeriodStart, + PeriodEnd: pc.Snapshot.PeriodEnd, + ModuleCount: int32(pc.Snapshot.ModuleCount), //nolint:gosec // count comes from the locked apps row, whose writers validate 0 ≤ count ≤ maxModuleCount + BaseMicros: pc.Snapshot.BaseMicros, + }); err != nil { + return 0, "", err + } + // Arm the one-shot guard. WHERE proration_invoice_id IS NULL is belt-and- + // suspenders — the FOR UPDATE lock already guarantees no concurrent arm. + if _, err := qtx.SetAppProrationInvoice(ctx, db.SetAppProrationInvoiceParams{ + AppID: appID.String(), + ProrationInvoiceID: pgtype.Text{String: pc.InvoiceID, Valid: true}, + }); err != nil { + return 0, "", err + } + + if err := tx.Commit(ctx); err != nil { + return 0, "", err + } + return ProrationLockedCharged, pc.InvoiceID, nil +} + func (s *pgxStore) SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, stripeInvoiceID string) error { // 0 rows = the guard was already armed (first-charge-wins) — not an error: // the deterministic Stripe idem keys guarantee the concurrent charger diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 140c140..052be1c 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -12,6 +12,42 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const appsPendingProration = `-- name: AppsPendingProration :many +SELECT app_id +FROM ms_billing.apps +WHERE created_at <= $1 + AND proration_invoice_id IS NULL + AND deleted_at IS NULL +ORDER BY created_at +` + +// AppsPendingProration is the creation-proration sweep's work list: apps that +// have survived the grace window ($1 = now() − GraceDays) and were never charged +// their creation-period base. proration_invoice_id IS NULL is the one-shot guard +// (an already-charged app drops out); deleted_at IS NULL excludes apps +// soft-deleted within grace (they are NEVER charged). Ordered by created_at so +// the oldest pending app charges first. Backed by the partial index +// apps_pending_proration_idx (migration 029). +func (q *Queries) AppsPendingProration(ctx context.Context, createdAt time.Time) ([]string, error) { + rows, err := q.db.Query(ctx, appsPendingProration, createdAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var app_id string + if err := rows.Scan(&app_id); err != nil { + return nil, err + } + items = append(items, app_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertAdvanceBaseSnapshot = `-- name: InsertAdvanceBaseSnapshot :execrows INSERT INTO ms_billing.app_base_snapshots (app_id, period_start, period_end, module_count, base_micros, source) @@ -265,6 +301,44 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM return i, err } +const selectAppMirrorForUpdate = `-- name: SelectAppMirrorForUpdate :one +SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +FROM ms_billing.apps +WHERE app_id = $1 +FOR UPDATE +` + +type SelectAppMirrorForUpdateRow struct { + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedAt time.Time `json:"created_at"` + ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +// SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — +// the creation-proration charge's race-safety primitive. In ONE transaction the +// charge locks the row here, re-verifies deleted_at IS NULL and +// proration_invoice_id IS NULL under the lock, performs the Stripe charge, and +// arms the guard. A concurrent SyncAppModules soft-delete (MarkAppDeleted) is +// thereby mutually exclusive: whichever of {delete, charge} commits first wins +// and the loser blocks on the lock, then sees the winner's write and no-ops — so +// a within-grace delete can never coincide with a charge (no refund path, D1e). +func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (SelectAppMirrorForUpdateRow, error) { + row := q.db.QueryRow(ctx, selectAppMirrorForUpdate, appID) + var i SelectAppMirrorForUpdateRow + err := row.Scan( + &i.AppID, + &i.AccountID, + &i.ModuleCount, + &i.CreatedAt, + &i.ProrationInvoiceID, + &i.DeletedAt, + ) + return i, err +} + const setAppModuleCount = `-- name: SetAppModuleCount :execrows UPDATE ms_billing.apps SET module_count = $2 diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index e827cfd..a8cd433 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -22,6 +22,35 @@ SELECT app_id, account_id, module_count, created_at, proration_invoice_id, delet FROM ms_billing.apps WHERE app_id = $1; +-- SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — +-- the creation-proration charge's race-safety primitive. In ONE transaction the +-- charge locks the row here, re-verifies deleted_at IS NULL and +-- proration_invoice_id IS NULL under the lock, performs the Stripe charge, and +-- arms the guard. A concurrent SyncAppModules soft-delete (MarkAppDeleted) is +-- thereby mutually exclusive: whichever of {delete, charge} commits first wins +-- and the loser blocks on the lock, then sees the winner's write and no-ops — so +-- a within-grace delete can never coincide with a charge (no refund path, D1e). +-- name: SelectAppMirrorForUpdate :one +SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +FROM ms_billing.apps +WHERE app_id = $1 +FOR UPDATE; + +-- AppsPendingProration is the creation-proration sweep's work list: apps that +-- have survived the grace window ($1 = now() − GraceDays) and were never charged +-- their creation-period base. proration_invoice_id IS NULL is the one-shot guard +-- (an already-charged app drops out); deleted_at IS NULL excludes apps +-- soft-deleted within grace (they are NEVER charged). Ordered by created_at so +-- the oldest pending app charges first. Backed by the partial index +-- apps_pending_proration_idx (migration 029). +-- name: AppsPendingProration :many +SELECT app_id +FROM ms_billing.apps +WHERE created_at <= $1 + AND proration_invoice_id IS NULL + AND deleted_at IS NULL +ORDER BY created_at; + -- SetAppProrationInvoice arms the ONE-SHOT proration guard: it records the -- Stripe invoice id of the creation-proration charge, and the WHERE -- proration_invoice_id IS NULL makes the write first-charge-wins — a retry or diff --git a/internal/account/usage/bill.go b/internal/account/usage/bill.go index d33d262..61893e3 100644 --- a/internal/account/usage/bill.go +++ b/internal/account/usage/bill.go @@ -61,6 +61,16 @@ const ( // Only reached once a subscription earns it; subscription-gated OFF in v1 (see // paasCreditMicros), so today the credit is always 0. PaasCreditPct = 30 + + // GraceDays is the creation grace window (owner spec 2026-07-05, D1e + // follow-up). A newly created app is NOT charged its creation-period base + // synchronously; a periodic sweep (cmd/billing-cycle) charges it only once it + // has SURVIVED this many whole days past created_at, so an app soft-deleted + // within the window is NEVER billed. A survivor is charged the SAME + // creation-period proration (identical ProratedBaseMicros math, anchored to + // the TRUE created_at) — grace delays WHEN the charge fires, never WHAT it + // covers. Tunable. + GraceDays = 3 ) // Plan is the account/org billing plan. v1 has NO real plan system — this is the diff --git a/migrations/billing/029_apps_proration_sweep_idx.down.sql b/migrations/billing/029_apps_proration_sweep_idx.down.sql new file mode 100644 index 0000000..4c16c2e --- /dev/null +++ b/migrations/billing/029_apps_proration_sweep_idx.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; diff --git a/migrations/billing/029_apps_proration_sweep_idx.up.sql b/migrations/billing/029_apps_proration_sweep_idx.up.sql new file mode 100644 index 0000000..aa934b2 --- /dev/null +++ b/migrations/billing/029_apps_proration_sweep_idx.up.sql @@ -0,0 +1,24 @@ +-- Migration 029 — index for the creation-proration grace sweep. +-- +-- Creation grace (owner spec 2026-07-05, D1e follow-up): a newly created app is +-- no longer charged its creation-period base synchronously in RegisterApp. A +-- periodic sweep (cmd/billing-cycle) instead charges apps that have SURVIVED the +-- grace window, so an app soft-deleted within grace is never billed. The sweep's +-- work list is: +-- +-- SELECT app_id FROM ms_billing.apps +-- WHERE created_at <= now() - '3 days' +-- AND proration_invoice_id IS NULL -- one-shot guard: not yet charged +-- AND deleted_at IS NULL -- excludes apps deleted within grace +-- +-- A PARTIAL index on created_at, restricted to the exact NULL predicates the +-- sweep filters on, keeps the index tiny (it holds ONLY the still-pending apps — +-- every charged or deleted app drops out) and lets the sweep range-scan by +-- created_at without touching the full roster. It is preferred over a plain +-- composite (deleted_at, proration_invoice_id, created_at) because almost every +-- row is eventually charged (proration_invoice_id set) and thus excluded from +-- the partial index, so it stays a fraction of the table's size. + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL AND proration_invoice_id IS NULL; From 46e6684b7c6904fa82fb248d79efc0cd7656ce45 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:17:37 +0800 Subject: [PATCH 03/30] feat: account-wide pooled module overage (migration 030) Move module overage from PER-APP to a single ACCOUNT-WIDE POOL of 5 included modules, per the owner spec 2026-07-05 (a deliberate reversal of the base-fee v1 per-app tier). - Schema (030): accounts.overage_since (one grace timer per account) + account_overage_snapshots (per-(account, period) charge ledger, the double-charge guard mirroring app_base_snapshots). - Pricing: per-app base is now FLAT $20; overage = $3 x max(0, SUM(live-app module_count) - 5) at the account level (usage.AccountOverageMicros / ProratedOverageMicros). - Timer: RegisterApp / SyncAppModules recompute the pool after every module_count write and arm/clear accounts.overage_since (first-cross-wins / idempotent clear, no refund on drop). - Mid-period grace charge: a new cmd/billing-cycle sweep charges the pooled overage prorated from grace-end to the period end once the 3-day grace window elapses (a deliberate mid-period charge; base-fee mid-period behavior is unchanged). Deterministic per-(account, period) Stripe idem keys + the snapshot ledger make it money-safe. - Boundary advance leg: each app now contributes only its flat base; a SEPARATE pooled-overage term bills the closing period ONCE, skipped when the sweep already billed it (snapshot guard), source='advance'. - Display: GetAccountBillResponse gains AccountOverageMicros (additive, snapshot-first else live pooled estimate); per-app base display is flat. Tests cover: pool crossing 5 arms the timer, dropping under clears it, grace holds for exactly 3 days, the mid-period charge fires once and is excluded from the boundary (no double-charge), interleaved installs sum to the account pool, and uninstall never refunds. mirrorstack-docs/db/ms_billing/ needs a companion update for the new account_overage_snapshots table + accounts.overage_since column. Co-Authored-By: Claude Opus 4.8 --- cmd/account-api/infra_route_test.go | 6 + cmd/billing-cycle/main.go | 55 ++- cmd/infra-egress-sync/main_test.go | 6 + internal/account/cycle/apps.go | 49 ++- internal/account/cycle/apps_test.go | 52 ++- internal/account/cycle/charge.go | 96 +++-- internal/account/cycle/overage.go | 268 ++++++++++++++ internal/account/cycle/overage_test.go | 334 ++++++++++++++++++ internal/account/cycle/service_test.go | 97 +++++ internal/account/cycle/store.go | 151 ++++++++ internal/account/cycle/types.go | 21 +- internal/account/db/models.go | 13 + internal/account/db/overage.sql.go | 201 +++++++++++ internal/account/db/queries/overage.sql | 83 +++++ internal/account/usage/accountbill.go | 46 ++- internal/account/usage/accountbill_test.go | 57 +-- internal/account/usage/basefee.go | 59 +++- internal/account/usage/basefee_test.go | 51 ++- internal/account/usage/bill.go | 56 +-- internal/account/usage/bill_test.go | 46 +-- internal/account/usage/service_test.go | 42 +++ internal/account/usage/store.go | 42 +++ internal/account/usage/types.go | 21 +- .../billing/030_account_wide_overage.down.sql | 10 + .../billing/030_account_wide_overage.up.sql | 88 +++++ 25 files changed, 1775 insertions(+), 175 deletions(-) create mode 100644 internal/account/cycle/overage.go create mode 100644 internal/account/cycle/overage_test.go create mode 100644 internal/account/db/overage.sql.go create mode 100644 internal/account/db/queries/overage.sql create mode 100644 migrations/billing/030_account_wide_overage.down.sql create mode 100644 migrations/billing/030_account_wide_overage.up.sql diff --git a/cmd/account-api/infra_route_test.go b/cmd/account-api/infra_route_test.go index f25aa33..40f705f 100644 --- a/cmd/account-api/infra_route_test.go +++ b/cmd/account-api/infra_route_test.go @@ -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() diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index fc70e75..64daec6 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -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) } @@ -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. @@ -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 @@ -222,9 +232,50 @@ 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 { + 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 diff --git a/cmd/infra-egress-sync/main_test.go b/cmd/infra-egress-sync/main_test.go index a5bef57..cb5e1a9 100644 --- a/cmd/infra-egress-sync/main_test.go +++ b/cmd/infra-egress-sync/main_test.go @@ -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) } diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 6952799..9e50677 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -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 @@ -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 != "" { @@ -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) @@ -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") @@ -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, diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index fb0195c..467869c 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -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{ @@ -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) { @@ -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 @@ -460,14 +467,14 @@ 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) @@ -475,8 +482,16 @@ func TestRunBillingCycle_InvoicesUsagePlusAdvanceBase(t *testing.T) { 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) { @@ -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) { diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index cde5378..a562cc4 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -31,20 +31,24 @@ import ( // LIVE ms_billing.apps rows (deleted_at IS NULL — a deleted app stops // accruing base, D1e, though its usage arrears above still bill) that // EXISTED BEFORE the new period opened (created_at < the closed window's -// period_end) of AppBaseFeeMicros = BaseFee + Overage × max(0, -// module_count − included). An app created INSIDE the new period is -// excluded — RegisterApp's creation-proration leg already charged its -// new-period base (full or prorated); it joins the advance leg at the -// NEXT boundary. module_count is snapshotted AT CHARGE TIME (D1b — -// mid-period installs / uninstalls take effect at this boundary, never -// mid-period), and each billed app-period is frozen into -// ms_billing.app_base_snapshots (migration 028) so the display always -// shows what was invoiced. The allowance nets USAGE only, never the base +// period_end) of the FLAT BaseFeeMicros (module overage is account-wide +// pooled now, migration 030 — no longer a per-app tier here). PLUS a +// SEPARATE account-level POOLED overage term for the CLOSING period ($3 × +// max(0, Σ live-app module_count − included)), charged ONCE only when the +// mid-period grace sweep did NOT already bill it (no +// account_overage_snapshots row for the period — the double-charge guard); +// when the boundary does charge it, it writes its own 'advance' snapshot. +// An app created INSIDE the new period is excluded — RegisterApp's +// creation-proration leg already charged its new-period base (full or +// prorated); it joins the advance leg at the NEXT boundary. module_count is +// snapshotted AT CHARGE TIME, and each billed app-period is frozen into +// ms_billing.app_base_snapshots (migration 028) so the display always shows +// what was invoiced. The allowance nets USAGE only, never the base/overage // (it offsets ModuleUsage+Infra in the display math too). An account with // NO mirror rows (pre-backfill) gets base 0 — exactly the pre-027 // arrears-only invoice — until the api-platform backfill populates the // roster. -// 4. arrears + base == 0 (BOTH zero) → MarkBillingRun('invoiced') with NO +// 4. arrears + base + pooled overage == 0 (ALL zero) → MarkBillingRun('invoiced') with NO // Stripe call. We NEVER auto-create a Stripe Customer with nothing to // charge (design §4 Axis 4). // 5. no usable default PM → MarkBillingRun('skipped_no_pm'). The usage is @@ -142,19 +146,44 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("live app roster read failed", err) } + // Each live app contributes ONLY its FLAT base (migration 030 — module + // overage is account-wide pooled, no longer a per-app tier). The pooled + // module count for the ACCOUNT overage term below is Σ these apps' counts + // (the same roster the base bills — apps created inside the new period are + // excluded, so the closing period's pool is exact). var advanceBase int64 + var pooledModuleCount int for _, a := range apps { - advanceBase += usage.AppBaseFeeMicros(usage.BaseFeeMicros, a.ModuleCount) + advanceBase += usage.BaseFeeMicros + pooledModuleCount += a.ModuleCount } - summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase} + // SEPARATE account-level POOLED overage term for the closing period, charged + // ONCE per account. If this period's pooled overage already has an + // account_overage_snapshots row (the mid-period grace sweep billed it), it is + // NOT charged again here — the ledger is the double-charge guard. Otherwise + // (grace never expired within the period, or overage started too late for the + // sweep to run) the boundary charges the FULL pooled overage for the account + // and writes its own snapshot (source='advance'), so every over-the-pool + // period is billed exactly once. + _, overageAlreadyBilled, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) + if err != nil { + return nil, billing.Internal("account overage snapshot lookup failed", err) + } + var advanceOverage int64 + overCount := pooledModuleCount - usage.IncludedModules + if !overageAlreadyBilled && overCount > 0 { + advanceOverage = usage.AccountOverageMicros(pooledModuleCount) + } + + summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase, AccountOverageMicros: advanceOverage} - // Zero-skip: only when arrears AND base are BOTH zero (empty/zero period - // with no live apps) is there nothing to invoice — mark invoiced with NO - // Stripe call, never auto-create a Customer with nothing to charge. A zero - // total can never breach a limit/ceiling, so this short-circuits ahead of - // the risk gate. - if arrears == 0 && advanceBase == 0 { + // Zero-skip: only when arrears, base AND pooled overage are ALL zero + // (empty/zero period with no live apps) is there nothing to invoice — mark + // invoiced with NO Stripe call, never auto-create a Customer with nothing to + // charge. A zero total can never breach a limit/ceiling, so this + // short-circuits ahead of the risk gate. + if arrears == 0 && advanceBase == 0 && advanceOverage == 0 { if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero arrears) failed", err) } @@ -268,9 +297,10 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } // One invoice, one pooled line: closed period's netted usage arrears + the - // new period's advance base, converted micros → whole cents ONCE at the - // Stripe boundary (a single deterministic rounding point for the total). - cents, err := centsFromMicros(arrears + advanceBase) + // new period's advance base + the closing period's account-wide pooled + // overage, converted micros → whole cents ONCE at the Stripe boundary (a + // single deterministic rounding point for the total). + cents, err := centsFromMicros(arrears + advanceBase + advanceOverage) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } @@ -278,7 +308,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // Charge. A failure after the PM gate marks the run 'failed' (auditable) and // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0) + inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0 || advanceOverage > 0) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -315,12 +345,32 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri PeriodStart: periodEnd, // the new period opens where the closed one ends PeriodEnd: newPeriodEnd, ModuleCount: a.ModuleCount, - BaseMicros: usage.AppBaseFeeMicros(usage.BaseFeeMicros, a.ModuleCount), + BaseMicros: usage.BaseFeeMicros, // FLAT per-app base (overage is pooled, migration 030) }); err != nil { return nil, billing.Internal("advance base snapshot insert failed", err) } } + // Freeze the account-wide pooled overage this boundary billed for the CLOSING + // period (migration 030, source='advance') so the mid-period sweep + display + // agree it is already billed — the double-charge guard. Keyed by the closing + // period_start; ON CONFLICT DO NOTHING (a mid-period 'grace' row wins if the + // race ever writes both). Skipped when nothing was billed (already billed + // mid-period, or the account was under the pool). + if advanceOverage > 0 { + if err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ + AccountID: accountID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + OverCount: overCount, + ChargedMicros: advanceOverage, + Source: "advance", + InvoiceItemID: invoiceItemIdemKey(runID), // the boundary's pooled item id (the run's ii- key) + }); err != nil { + return nil, billing.Internal("account overage snapshot insert failed", err) + } + } + if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, inv.ID, cents); err != nil { return nil, billing.Internal("mark billing run (invoiced) failed", err) } diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go new file mode 100644 index 0000000..d1ca569 --- /dev/null +++ b/internal/account/cycle/overage.go @@ -0,0 +1,268 @@ +package cycle + +// Account-wide POOLED module overage (migration 030, owner spec 2026-07-05, +// confirmed reversal of the per-app overage tier). Overage moved from PER-APP +// to a single ACCOUNT-WIDE POOL of IncludedModules: overage = $3 × max(0, +// Σ live-app module_count − IncludedModules), charged ONCE per account per +// period. This file owns: +// +// - recomputeAccountOverage: after any module_count write (RegisterApp / +// SyncAppModules) it re-derives the pool and arms/clears the account's ONE +// grace timer (accounts.overage_since); +// - the mid-period GRACE SWEEP (ChargeAccountOverage + AccountsInOverageGrace): +// when the pool has been over for the full grace window, it charges the +// pooled overage prorated from grace-end to the period end — a DELIBERATE +// mid-period charge (the D1b "no mid-period charges" rule is now stale for +// the OVERAGE leg specifically; base-fee mid-period behavior is unchanged). +// +// The boundary advance leg's pooled-overage term lives in charge.go alongside +// the base leg. Both legs guard against double-charging the same period through +// the account_overage_snapshots ledger (keyed (account_id, period_start)) plus +// the deterministic per-(account, period) Stripe Idempotency-Keys below — the +// same money-safety pattern app_base_snapshots gives the per-app base. + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" +) + +// overageGraceWindow is the ONE grace timer per account: when the pooled module +// count first crosses IncludedModules the timer starts (accounts.overage_since), +// and only after this window elapses does the mid-period sweep charge the +// overage. If the pool drops back to ≤ IncludedModules before it elapses the +// timer is cleared and nothing is charged. Owner spec 2026-07-05: 3 days. +const overageGraceWindow = 3 * 24 * time.Hour + +// OverageChargeStatus is the terminal classification of a ChargeAccountOverage +// attempt, for the cron sweep's tally + logging. +type OverageChargeStatus string + +const ( + // OverageCharged: the pooled overage was invoiced for the period. + OverageCharged OverageChargeStatus = "charged" + // OverageSkippedAlreadyCharged: this period's pooled overage already has an + // account_overage_snapshots row (a prior sweep or the boundary billed it). + OverageSkippedAlreadyCharged OverageChargeStatus = "already_charged" + // OverageSkippedUnderPool: the pool dropped back to ≤ IncludedModules by the + // time the sweep ran — nothing to charge; the timer is cleared. + OverageSkippedUnderPool OverageChargeStatus = "under_pool" + // OverageSkippedGraceHolding: the grace window has not elapsed yet (defensive + // — the work-list query already excludes these). + OverageSkippedGraceHolding OverageChargeStatus = "grace_holding" + // OverageSkippedZeroCents: the prorated overage rounded to 0 cents (grace-end + // lands at/after the period end, or a sub-cent remainder) — nothing to + // invoice this period; a later period picks it up. + OverageSkippedZeroCents OverageChargeStatus = "zero_cents" + // OverageSkippedNoPM: no usable default payment method — retained, re-attempted + // on the next sweep (the SAME per-(account, period) Stripe idem key stays + // stable), never a failure. + OverageSkippedNoPM OverageChargeStatus = "skipped_no_pm" +) + +// OverageChargeSummary reports what one ChargeAccountOverage call did. +type OverageChargeSummary struct { + Status OverageChargeStatus + PeriodStart time.Time + OverCount int + ChargedCents int64 + // StripeInvoiceID is set only when Status == OverageCharged. + StripeInvoiceID string +} + +// AccountsInOverageGrace returns the accounts whose grace timer has EXPIRED as +// of `at` (overage_since <= at − overageGraceWindow) and are chargeable — the +// mid-period sweep's work list. A thin pass-through to the store. +func (s *Service) AccountsInOverageGrace(ctx context.Context, at time.Time) ([]OverageGraceCandidate, error) { + cands, err := s.store.AccountsInOverageGrace(ctx, at.Add(-overageGraceWindow)) + if err != nil { + return nil, billing.Internal("list overage-grace accounts failed", err) + } + return cands, nil +} + +// recomputeAccountOverage re-derives the account's pooled module count after a +// module_count write and arms/clears its ONE grace timer accordingly: pool > +// IncludedModules and not yet armed → stamp overage_since (StartAccountOverage +// is first-crossing-wins); pool ≤ IncludedModules → clear it (ClearAccountOverage +// is idempotent). No refund on the clear (D1e) — it only stops FUTURE accrual; +// overage already charged this period stays billed via its snapshot row. Called +// by RegisterApp (after the initial insert) and SyncAppModules (after any count +// / delete write) so the timer always reflects the live pool. +func (s *Service) recomputeAccountOverage(ctx context.Context, accountID uuid.UUID) error { + pooled, err := s.store.PooledModuleCount(ctx, accountID) + if err != nil { + return billing.Internal("pooled module count lookup failed", err) + } + if pooled > usage.IncludedModules { + if err := s.store.StartAccountOverage(ctx, accountID, s.nowFn().UTC()); err != nil { + return billing.Internal("start account overage timer failed", err) + } + return nil + } + if err := s.store.ClearAccountOverage(ctx, accountID); err != nil { + return billing.Internal("clear account overage timer failed", err) + } + return nil +} + +// ChargeAccountOverage is the mid-period grace charge for ONE account whose +// pooled overage has survived the grace window. It: +// +// 1. resolves the account's CURRENT anchored period (from activated_at) and the +// grace-end instant (overage_since + overageGraceWindow); +// 2. skips if this period's pooled overage already has a snapshot (a prior +// sweep or the boundary billed it — the double-charge guard); +// 3. reads the CURRENT pool; if it dropped back to ≤ IncludedModules, clears +// the timer and skips (no refund of anything already charged); +// 4. prices the pooled overage PRORATED from grace-end to the period end +// (ProratedOverageMicros) → whole cents at the Stripe boundary; a 0-cent +// result skips (grace ends at/after the period end); +// 5. charges via the SAME Stripe plumbing as the other legs with the +// deterministic per-(account, period) Idempotency-Keys, mirrors the invoice, +// and freezes the account_overage_snapshots row (source='grace'). +// +// Gated on a usable default PM exactly like the spine (the candidate is already +// activated). A failure after the charge leaves no snapshot; the next sweep +// re-attempts through the SAME idem key (Stripe dedupes) — retry-safe, never a +// double charge. +func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCandidate, at time.Time) (*OverageChargeSummary, error) { + if cand.ID == uuid.Nil { + return nil, billing.InvalidInput("account_id required") + } + if s.stripe == nil { + return nil, billing.Internal("ChargeAccountOverage requires a Stripe client", nil) + } + + graceEnd := cand.OverageSince.Add(overageGraceWindow) + if at.UTC().Before(graceEnd) { + // Defensive: the work-list query already excludes still-in-grace accounts. + return &OverageChargeSummary{Status: OverageSkippedGraceHolding}, nil + } + + anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) + periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(at.UTC(), anchorDay) + summary := &OverageChargeSummary{PeriodStart: periodStart} + + // Double-charge guard: this period's pooled overage was already billed (by a + // prior sweep run OR the boundary that closed a prior period into this one). + if _, snapped, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart); err != nil { + return nil, billing.Internal("account overage snapshot lookup failed", err) + } else if snapped { + summary.Status = OverageSkippedAlreadyCharged + return summary, nil + } + + pooled, err := s.store.PooledModuleCount(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("pooled module count lookup failed", err) + } + overCount := pooled - usage.IncludedModules + if overCount <= 0 { + // Dropped back under the pool since the timer was read — clear the stale + // timer and charge nothing (no refund of anything already billed, D1e). + if err := s.store.ClearAccountOverage(ctx, cand.ID); err != nil { + return nil, billing.Internal("clear account overage timer failed", err) + } + summary.Status = OverageSkippedUnderPool + return summary, nil + } + summary.OverCount = overCount + + // Prorate the pooled overage from grace-end to the period end. A 0-cent + // result (grace ends at/after this period's end) means nothing to bill this + // period — a later period picks it up; leave the timer armed, no snapshot. + proratedMicros := usage.ProratedOverageMicros(usage.AccountOverageMicros(pooled), graceEnd, periodStart, periodEnd) + cents, err := centsFromMicros(proratedMicros) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) + } + if cents == 0 { + summary.Status = OverageSkippedZeroCents + return summary, nil + } + summary.ChargedCents = cents + + hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("usable PM check failed", err) + } + if !hasPM { + summary.Status = OverageSkippedNoPM + return summary, nil // retained; re-attempted next sweep through the same idem key + } + custID, err := s.store.AccountStripeCustomer(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) + } + + desc := fmt.Sprintf("MirrorStack module overage (account pool, %d over) — account %s", overCount, cand.ID) + item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, accountOverageItemIdemKey(cand.ID, periodStart)) + if err != nil { + return nil, billing.StripeError("overage invoice item failed", err) + } + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, accountOverageInvoiceIdemKey(cand.ID, periodStart)) + if err != nil { + return nil, billing.StripeError("overage invoice failed", err) + } + + if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ + AccountID: cand.ID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + // The mirrored window is the PARTIAL coverage [grace-end day, period end), + // the SAME instant ProratedOverageMicros priced, so the shown window and + // the charged amount agree by construction. + PeriodStart: usage.ProrationCoverageStart(graceEnd, periodStart), + PeriodEnd: periodEnd, + }); err != nil { + return nil, billing.Internal("invoice mirror upsert failed", err) + } + + // Freeze the ledger row keyed by the FULL anchored period_start (the display + + // double-charge identity) — source='grace', the prorated amount actually + // invoiced. ON CONFLICT DO NOTHING makes a retry idempotent. + if err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ + AccountID: cand.ID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + OverCount: overCount, + ChargedMicros: proratedMicros, + Source: "grace", + InvoiceItemID: item.ID, + }); err != nil { + return nil, billing.Internal("account overage snapshot insert failed", err) + } + + summary.Status = OverageCharged + summary.StripeInvoiceID = inv.ID + return summary, nil +} + +// accountOverageItemIdemKey / accountOverageInvoiceIdemKey build the +// deterministic per-(account, period) Stripe Idempotency-Keys for the pooled +// overage charge. The (account, period_start) pair is the stable charge +// identity — each account's pooled overage bills at most once per period (the +// account_overage_snapshots guard) — so both the mid-period grace sweep and the +// boundary leg, if they ever target the SAME period, reuse the SAME Stripe +// objects and can never double-charge. Mirrors the app-ii-/app-inv- pattern. +func accountOverageItemIdemKey(accountID uuid.UUID, periodStart time.Time) string { + return "acct-overage-ii-" + accountID.String() + "-" + strconv.FormatInt(periodStart.UTC().Unix(), 10) +} + +func accountOverageInvoiceIdemKey(accountID uuid.UUID, periodStart time.Time) string { + return "acct-overage-inv-" + accountID.String() + "-" + strconv.FormatInt(periodStart.UTC().Unix(), 10) +} diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go new file mode 100644 index 0000000..3bcbd56 --- /dev/null +++ b/internal/account/cycle/overage_test.go @@ -0,0 +1,334 @@ +package cycle_test + +// Account-wide POOLED module overage (migration 030): the grace-timer recompute +// (RegisterApp / SyncAppModules), the mid-period grace sweep (ChargeAccountOverage +// + AccountsInOverageGrace), and the no-double-charge interaction with the +// boundary advance leg. Reuses the in-memory fakeStore (service_test.go) + +// fakeStripe (charge_test.go). + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" +) + +func overageSvc(store *fakeStore, sc *fakeStripe, now time.Time) *cycle.Service { + return cycle.NewService(store, sc).WithNow(func() time.Time { return now }) +} + +// --- recompute: the timer arms / clears on pool crossings ------------------- + +func TestRecompute_PoolCrossingFiveArmsTimer(t *testing.T) { + // Two apps' installs INTERLEAVE and SUM to the account pool: app1=3 (pool 3, + // under 5 → not armed), then app2=4 (pool 7, over 5 → arms overage_since at + // the register instant). Proves the timer keys off the ACCOUNT-WIDE sum, not + // either app alone. + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + store := newFakeStore() + user, acct := registeredAccount(store) + svc := overageSvc(store, newFakeStripe(), now) + + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: uuid.New(), ModuleCount: 3, CreatedAt: now, + }) + require.NoError(t, err) + require.NotContains(t, store.overageSince, acct, "pool 3 is under 5 → timer not armed") + + _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: uuid.New(), ModuleCount: 4, CreatedAt: now, + }) + require.NoError(t, err) + require.Contains(t, store.overageSince, acct, "pool 3+4=7 crosses 5 → timer armed") + require.Equal(t, now, store.overageSince[acct], "armed at the crossing instant") +} + +func TestRecompute_FirstCrossingWinsTimerNotMoved(t *testing.T) { + // A LATER recompute that finds the pool still over must NOT move the anchor + // (first-crossing-wins) — the grace window is measured from the FIRST cross. + first := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + store := newFakeStore() + user, acct := registeredAccount(store) + appID := uuid.New() + + _, err := overageSvc(store, newFakeStripe(), first).RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: 6, CreatedAt: first, + }) + require.NoError(t, err) + require.Equal(t, first, store.overageSince[acct]) + + // Later: bump the count further; the pool is still over, so the anchor stays. + later := first.AddDate(0, 0, 2) + _, err = overageSvc(store, newFakeStripe(), later).SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ + AppID: appID, ModuleCount: intPtr(9), + }) + require.NoError(t, err) + require.Equal(t, first, store.overageSince[acct], "still-over recompute must not move the first-cross anchor") +} + +func TestRecompute_DroppingUnderFiveClearsTimer(t *testing.T) { + // Pool over 5 arms the timer; a later uninstall that drops the pool back to + // ≤5 CLEARS it (no charge — the grace never elapsed). + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + store := newFakeStore() + user, acct := registeredAccount(store) + svc := overageSvc(store, newFakeStripe(), now) + appID := uuid.New() + + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: 7, CreatedAt: now, + }) + require.NoError(t, err) + require.Contains(t, store.overageSince, acct) + + _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: intPtr(2)}) + require.NoError(t, err) + require.NotContains(t, store.overageSince, acct, "dropping to pool 2 clears the grace timer") +} + +func TestRecompute_DeleteDroppingUnderFiveClearsTimer(t *testing.T) { + // Deleting an app (not just a count sync) also drops the pool and clears the + // timer — deleted apps leave the live pool. + now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) + store := newFakeStore() + user, acct := registeredAccount(store) + svc := overageSvc(store, newFakeStripe(), now) + a1, a2 := uuid.New(), uuid.New() + + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{OwnerUserID: user, AppID: a1, ModuleCount: 4, CreatedAt: now}) + require.NoError(t, err) + _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{OwnerUserID: user, AppID: a2, ModuleCount: 4, CreatedAt: now}) + require.NoError(t, err) + require.Contains(t, store.overageSince, acct, "pool 8 armed") + + _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: a2, Deleted: true}) + require.NoError(t, err) + require.NotContains(t, store.overageSince, acct, "deleting one app drops the pool to 4 → timer cleared") +} + +// --- grace window: holds for exactly the grace period ----------------------- + +func TestAccountsInOverageGrace_HoldsForExactlyThreeDays(t *testing.T) { + // The sweep work list includes an account only once its grace timer has + // elapsed: overage_since <= at − 3d. Pin the boundary at EXACTLY 3 days. + store := newFakeStore() + acct := uuid.New() + since := time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC) + store.overageSince[acct] = since + store.activation[acct] = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + // 1s BEFORE the 3-day mark → still in grace, excluded. + cands, err := cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), since.Add(3*24*time.Hour-time.Second)) + require.NoError(t, err) + require.Empty(t, cands, "grace still holding just under 3 days") + + // EXACTLY 3 days → grace elapsed, included. + cands, err = cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), since.Add(3*24*time.Hour)) + require.NoError(t, err) + require.Len(t, cands, 1, "grace elapsed at exactly 3 days") + require.Equal(t, acct, cands[0].ID) + require.Equal(t, since, cands[0].OverageSince) +} + +func TestAccountsInOverageGrace_ExcludesUnactivated(t *testing.T) { + // An un-activated account (no card) is never charged, so it never enters the + // sweep even past the grace window. + store := newFakeStore() + acct := uuid.New() + store.overageSince[acct] = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + // no activation row + + cands, err := cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Empty(t, cands) +} + +// --- mid-period grace charge ------------------------------------------------ + +// armedOverageAccount seeds a fully-chargeable account (activation anchor day 1, +// usable PM, Stripe customer) with `n` apps of `perApp` modules each, created +// before `createdBefore`, and its grace timer armed at `since`. Returns the +// account id. +func armedOverageAccount(store *fakeStore, n, perApp int, since, createdBefore time.Time) uuid.UUID { + acct := uuid.New() + store.activation[acct] = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) // anchor day 1 → calendar-month periods + store.hasPM = true + store.stripeCustomer = "cus_overage" + store.overageSince[acct] = since + for i := 0; i < n; i++ { + seedAppCreated(store, acct, perApp, false, createdBefore.AddDate(0, 0, -1)) + } + return acct +} + +func TestChargeAccountOverage_ChargesFullWhenGraceEndedBeforePeriodStart(t *testing.T) { + // Grace ended May 31 (before the current period [Jun 1, Jul 1)), so the + // pooled overage is charged in FULL for the period. Pool = 2 apps × 4 = 8 → + // 3 over → $9 → 900 cents. One invoice item with the deterministic + // per-(account, period) idem key; one 'grace' snapshot frozen. + at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // graceEnd May 31 < Jun 1 + store := newFakeStore() + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + acct := armedOverageAccount(store, 2, 4, since, jun1) + sc := newFakeStripe() + + summary, err := overageSvc(store, sc, at).ChargeAccountOverage(context.Background(), cycle.OverageGraceCandidate{ + ID: acct, OverageSince: since, ActivatedAt: store.activation[acct], + }, at) + require.NoError(t, err) + require.Equal(t, cycle.OverageCharged, summary.Status) + require.Equal(t, 3, summary.OverCount) + require.EqualValues(t, 900, summary.ChargedCents) + + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 900, sc.itemCalls[0].amountCfg) + require.Contains(t, sc.itemCalls[0].idemKey, "acct-overage-ii-"+acct.String()) + require.Len(t, sc.invoiceCalls, 1) + + snap, ok := store.overageSnaps[acctSnapKey{acct, jun1}] + require.True(t, ok, "the grace charge must freeze a snapshot for the period") + require.Equal(t, "grace", snap.Source) + require.Equal(t, 3, snap.OverCount) + require.EqualValues(t, 9_000_000, snap.ChargedMicros) +} + +func TestChargeAccountOverage_ProratesFromGraceEndMidPeriod(t *testing.T) { + // Grace ends mid-period (Jun 4 → coverage [Jun 4, Jul 1) = 27 of 30 days). + // Pool = 6 → 1 over → $3 → prorated 3e6 × 27/30 = 2_700_000 → 270 cents. + at := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 6, 1, 6, 0, 0, 0, time.UTC) // graceEnd Jun 4 (truncated day) + store := newFakeStore() + acct := armedOverageAccount(store, 1, 6, since, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + sc := newFakeStripe() + + summary, err := overageSvc(store, sc, at).ChargeAccountOverage(context.Background(), cycle.OverageGraceCandidate{ + ID: acct, OverageSince: since, ActivatedAt: store.activation[acct], + }, at) + require.NoError(t, err) + require.Equal(t, cycle.OverageCharged, summary.Status) + require.EqualValues(t, 270, summary.ChargedCents) +} + +func TestChargeAccountOverage_FiresOnceAndExcludedFromBoundary(t *testing.T) { + // THE double-charge invariant. The mid-period sweep charges the pooled + // overage for [Jun 1, Jul 1); a SECOND sweep is a no-op (the snapshot guards + // it); and the BOUNDARY that closes [Jun 1, Jul 1) must NOT charge the + // overage again (it sees the snapshot) — it bills only the flat advance base. + at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // full overage this period + store := newFakeStore() + store.chargedTotal = 0 + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + acct := armedOverageAccount(store, 2, 4, since, jun1) // pool 8 → $9 overage + sc := newFakeStripe() + svc := overageSvc(store, sc, at) + cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + + // Sweep #1 charges. + first, err := svc.ChargeAccountOverage(context.Background(), cand, at) + require.NoError(t, err) + require.Equal(t, cycle.OverageCharged, first.Status) + require.Len(t, sc.invoiceCalls, 1) + + // Sweep #2 (same period) is a no-op — the snapshot guards it. + second, err := svc.ChargeAccountOverage(context.Background(), cand, at.Add(time.Hour)) + require.NoError(t, err) + require.Equal(t, cycle.OverageSkippedAlreadyCharged, second.Status) + require.Len(t, sc.invoiceCalls, 1, "no second overage invoice") + + // The BOUNDARY closing [Jun 1, Jul 1): flat advance base (2 × $20 = 40e6), + // and ZERO pooled overage — the mid-period snapshot excludes it. + resp, err := svc.RunBillingCycle(context.Background(), acct, jun1, jul1, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) + require.EqualValues(t, 0, resp.AccountOverageMicros, "the mid-period overage is NOT charged again at the boundary") + require.EqualValues(t, 4_000, resp.ChargedCents) // base only + + // Exactly two invoices total: the grace overage + the boundary base. + require.Len(t, sc.invoiceCalls, 2) + // The 'grace' snapshot (not 'advance') still owns the period row. + require.Equal(t, "grace", store.overageSnaps[acctSnapKey{acct, jun1}].Source) +} + +func TestRunBillingCycle_BoundaryChargesFullPooledOverageWhenNoSweep(t *testing.T) { + // When the grace sweep never billed a period (e.g. grace expired at cutover), + // the boundary charges the FULL pooled overage for the closing period and + // writes its own 'advance' snapshot. Pool = 8 → 3 over → $9 on top of the + // flat base. + store := newFakeStore() + store.chargedTotal = 0 + store.hasPM = true + store.stripeCustomer = "cus_bnd_overage" + seedApp(store, chargeAccount, 4, false) + seedApp(store, chargeAccount, 4, false) + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) + require.EqualValues(t, 9_000_000, resp.AccountOverageMicros) + require.EqualValues(t, 4_900, resp.ChargedCents) // (40e6 + 9e6) / 10_000 + + snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] + require.True(t, ok) + require.Equal(t, "advance", snap.Source) + require.Equal(t, 3, snap.OverCount) + require.EqualValues(t, 9_000_000, snap.ChargedMicros) +} + +func TestChargeAccountOverage_UninstallDoesNotRefund(t *testing.T) { + // After the pooled overage was charged for a period, dropping back under the + // pool clears the timer but NEVER refunds the charge already taken (D1e). A + // later sweep in the SAME period finds the snapshot and no-ops; nothing + // negative is ever invoiced. + at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) + store := newFakeStore() + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + acct := armedOverageAccount(store, 2, 4, since, jun1) + sc := newFakeStripe() + svc := overageSvc(store, sc, at) + cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + + _, err := svc.ChargeAccountOverage(context.Background(), cand, at) + require.NoError(t, err) + require.Len(t, sc.invoiceCalls, 1) + require.EqualValues(t, 9_000_000, store.overageSnaps[acctSnapKey{acct, jun1}].ChargedMicros) + + // Now every module is uninstalled down under the pool via SyncAppModules — + // the recompute CLEARS the timer, but the already-charged snapshot (the money + // taken) is untouched: no refund (D1e). + for id, app := range store.apps { + if app.AccountID == acct { + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: id, ModuleCount: intPtr(0)}) + require.NoError(t, err) + } + } + require.NotContains(t, store.overageSince, acct, "dropping under the pool clears the timer") + require.EqualValues(t, 9_000_000, store.overageSnaps[acctSnapKey{acct, jun1}].ChargedMicros, + "the charged snapshot survives — uninstall never refunds") + require.Len(t, sc.invoiceCalls, 1, "no refund / negative invoice on uninstall") + + // And a re-run of the sweep in the SAME period is a no-op (snapshot guard) — + // definitely no refund/re-charge. + again, err := svc.ChargeAccountOverage(context.Background(), cand, at.Add(time.Hour)) + require.NoError(t, err) + require.Equal(t, cycle.OverageSkippedAlreadyCharged, again.Status) + require.Len(t, sc.invoiceCalls, 1) +} + +// Guard: the overage amount is exactly the pooled tier, never a per-app tier. +func TestAccountOverageMicros_IsPooledNotPerApp(t *testing.T) { + // Two apps of 4 modules each: per-app each is UNDER the old 5 tier (would be + // $0 overage each pre-030), but POOLED they are 8 → 3 over → $9. This is the + // whole point of the reversal. + require.EqualValues(t, 9_000_000, usage.AccountOverageMicros(4+4)) +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 2026d7f..0adea43 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -69,6 +69,13 @@ type fakeStore struct { activation map[uuid.UUID]time.Time baseSnapshots map[snapKey]fakeBaseSnapshot + // account-wide POOLED overage state (migration 030). overageSince models + // accounts.overage_since (present → armed at that instant); overageSnaps + // models account_overage_snapshots keyed (account, period_start) like the + // PRIMARY KEY, recording the row each charge leg froze. + overageSince map[uuid.UUID]time.Time + overageSnaps map[acctSnapKey]cycle.AccountOverageSnapshot + // captured charge writes insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) runStatus map[uuid.UUID]cycle.BillingRunStatus // run id → current status (models the DB row's terminal state) @@ -106,6 +113,20 @@ type fakeStore struct { errLiveCounts error // LiveAppsCreatedBefore errProrationSnap error // UpsertProrationBaseSnapshot errAdvanceSnap error // InsertAdvanceBaseSnapshot + + errPooledCount error // PooledModuleCount + errStartOverage error // StartAccountOverage + errClearOverage error // ClearAccountOverage + errOverageGrace error // AccountsInOverageGrace + errOverageSnap error // AccountOverageSnapshot + errInsertOverageSnap error // InsertAccountOverageSnapshot +} + +// acctSnapKey mirrors the account_overage_snapshots PRIMARY KEY +// (account_id, period_start). +type acctSnapKey struct { + account uuid.UUID + periodStart time.Time } // snapKey mirrors the app_base_snapshots PRIMARY KEY (app_id, period_start). @@ -145,6 +166,8 @@ func newFakeStore() *fakeStore { accountsByUser: map[uuid.UUID]uuid.UUID{}, activation: map[uuid.UUID]time.Time{}, baseSnapshots: map[snapKey]fakeBaseSnapshot{}, + overageSince: map[uuid.UUID]time.Time{}, + overageSnaps: map[acctSnapKey]cycle.AccountOverageSnapshot{}, // Default collection state: arrears mode with a high credit limit + no // spend ceiling, so the existing charge tests (which don't set risk // fields) flow through the gate to the charge path unchanged. Risk tests @@ -480,6 +503,80 @@ func (f *fakeStore) InsertAdvanceBaseSnapshot(_ context.Context, snap cycle.AppB return nil } +// --- account-wide POOLED overage fake (migration 030) ----------------------- + +// PooledModuleCount sums module_count over the account's live apps, mirroring +// the SQL SUM(module_count) WHERE account_id = ? AND deleted_at IS NULL. +func (f *fakeStore) PooledModuleCount(_ context.Context, accountID uuid.UUID) (int, error) { + if f.errPooledCount != nil { + return 0, f.errPooledCount + } + sum := 0 + for _, app := range f.apps { + if app.AccountID == accountID && !app.Deleted { + sum += app.ModuleCount + } + } + return sum, nil +} + +func (f *fakeStore) StartAccountOverage(_ context.Context, accountID uuid.UUID, since time.Time) error { + if f.errStartOverage != nil { + return f.errStartOverage + } + if _, armed := f.overageSince[accountID]; !armed { + f.overageSince[accountID] = since // first-crossing-wins (WHERE overage_since IS NULL) + } + return nil +} + +func (f *fakeStore) ClearAccountOverage(_ context.Context, accountID uuid.UUID) error { + if f.errClearOverage != nil { + return f.errClearOverage + } + delete(f.overageSince, accountID) // idempotent (WHERE overage_since IS NOT NULL) + return nil +} + +func (f *fakeStore) AccountsInOverageGrace(_ context.Context, cutoff time.Time) ([]cycle.OverageGraceCandidate, error) { + if f.errOverageGrace != nil { + return nil, f.errOverageGrace + } + out := []cycle.OverageGraceCandidate{} + for id, since := range f.overageSince { + activatedAt, activated := f.activation[id] + if !activated { + continue // activated_at IS NOT NULL gate + } + if since.After(cutoff) { + continue // grace still holding (overage_since <= cutoff) + } + out = append(out, cycle.OverageGraceCandidate{ID: id, OverageSince: since, ActivatedAt: activatedAt}) + } + return out, nil +} + +func (f *fakeStore) AccountOverageSnapshot(_ context.Context, accountID uuid.UUID, periodStart time.Time) (cycle.AccountOverageSnapshot, bool, error) { + if f.errOverageSnap != nil { + return cycle.AccountOverageSnapshot{}, false, f.errOverageSnap + } + snap, ok := f.overageSnaps[acctSnapKey{accountID, periodStart}] + return snap, ok, nil +} + +func (f *fakeStore) InsertAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) error { + if f.errInsertOverageSnap != nil { + return f.errInsertOverageSnap + } + // ON CONFLICT (account_id, period_start) DO NOTHING: an existing row wins. + k := acctSnapKey{snap.AccountID, snap.PeriodStart} + if _, exists := f.overageSnaps[k]; exists { + return nil + } + f.overageSnaps[k] = snap + return nil +} + // --- helpers -------------------------------------------------------------- func requireCode(t *testing.T, err error, want billing.Code) { diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3e248cd..c8efdbd 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -221,6 +221,71 @@ type Store interface { // proration snapshot, or a prior reclaimed attempt's own row) wins, so a // re-run never rewrites what was already recorded as billed. InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSnapshot) error + + // --- account-wide POOLED module overage (migration 030) ----------------- + + // PooledModuleCount returns the account-wide pooled installed-module count: + // SUM(module_count) over the account's LIVE apps. The overage timer recompute + // and the mid-period grace sweep tier on it (overage = $3 × max(0, this − + // IncludedModules)). + PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) + + // StartAccountOverage stamps the account's grace-timer anchor (overage_since) + // the FIRST time its pool crosses IncludedModules — WHERE overage_since IS + // NULL, so it is first-crossing-wins/idempotent (a later recompute that finds + // it already armed is a no-op). + StartAccountOverage(ctx context.Context, accountID uuid.UUID, since time.Time) error + + // ClearAccountOverage disarms the grace timer (overage_since → NULL) when the + // pool drops back to ≤ IncludedModules — WHERE overage_since IS NOT NULL, so + // it is idempotent. No refund (D1e): clearing only stops FUTURE accrual. + ClearAccountOverage(ctx context.Context, accountID uuid.UUID) error + + // AccountsInOverageGrace returns every account whose grace timer has EXPIRED + // as of cutoff (overage_since <= cutoff) and that is chargeable (activated) — + // the mid-period grace sweep's work list, with each account's overage_since + // (grace anchor) and activated_at (period anchor). + AccountsInOverageGrace(ctx context.Context, cutoff time.Time) ([]OverageGraceCandidate, error) + + // AccountOverageSnapshot reads the frozen pooled overage a charge leg billed + // for ONE (account, period) — the double-charge guard both the grace sweep + // and the boundary consult (found=true → this period's pooled overage was + // already billed, skip it). found=false → never charged. + AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (snap AccountOverageSnapshot, found bool, err error) + + // InsertAccountOverageSnapshot freezes what a charge leg billed the account + // for one period's pooled overage (migration 030) with ON CONFLICT + // (account_id, period_start) DO NOTHING — an existing row (a prior grace + // charge, or a reclaimed boundary attempt's own row) wins, so a re-run never + // rewrites what was already recorded as billed. + InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error +} + +// OverageGraceCandidate is one account the mid-period grace sweep evaluates: its +// id plus the two anchors the sweep needs — OverageSince (the grace timer's +// start; grace ends at OverageSince + the grace window) and ActivatedAt (the +// billing-period anchor, ADR 0005, used to resolve the current window). +type OverageGraceCandidate struct { + ID uuid.UUID + OverageSince time.Time + ActivatedAt time.Time +} + +// AccountOverageSnapshot is the in-memory form of a +// ms_billing.account_overage_snapshots row (migration 030): what one charge leg +// billed one account for one period's POOLED module overage. PeriodStart is the +// display + double-charge lookup key; ChargedMicros is the exact overage the +// invoice billed (prorated for a 'grace' row, full for an 'advance' row); +// OverCount is the pooled over-count it tiered on; Source is 'grace' or +// 'advance'; InvoiceItemID is the Stripe item id (empty for a 0-cent charge). +type AccountOverageSnapshot struct { + AccountID uuid.UUID + PeriodStart time.Time + PeriodEnd time.Time + OverCount int + ChargedMicros int64 + Source string + InvoiceItemID string } // AppModuleCount pairs one live roster app with its module_count snapshot — @@ -917,6 +982,92 @@ func (s *pgxStore) InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSn return err } +// --- account-wide POOLED module overage (migration 030) -------------------- + +func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) { + sum, err := s.q.SumLiveModuleCount(ctx, accountID.String()) + if err != nil { + return 0, err + } + return int(sum), nil +} + +func (s *pgxStore) StartAccountOverage(ctx context.Context, accountID uuid.UUID, since time.Time) error { + // 0 rows = already armed (first-crossing-wins, WHERE overage_since IS NULL) — + // a no-op, not an error: the original anchor survives. + _, err := s.q.StartAccountOverage(ctx, db.StartAccountOverageParams{ + ID: accountID.String(), + OverageSince: pgtype.Timestamptz{Time: since, Valid: true}, + }) + return err +} + +func (s *pgxStore) ClearAccountOverage(ctx context.Context, accountID uuid.UUID) error { + // 0 rows = already clear (WHERE overage_since IS NOT NULL) — idempotent no-op. + _, err := s.q.ClearAccountOverage(ctx, accountID.String()) + return err +} + +func (s *pgxStore) AccountsInOverageGrace(ctx context.Context, cutoff time.Time) ([]OverageGraceCandidate, error) { + rows, err := s.q.AccountsInOverageGrace(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true}) + if err != nil { + return nil, err + } + out := make([]OverageGraceCandidate, 0, len(rows)) + for _, r := range rows { + id, err := uuid.Parse(r.ID) + if err != nil { + return nil, err + } + // The query filters both columns NOT NULL, so a non-Valid value here is a + // driver anomaly; skip it defensively rather than anchor on the zero time. + if !r.OverageSince.Valid || !r.ActivatedAt.Valid { + continue + } + out = append(out, OverageGraceCandidate{ + ID: id, + OverageSince: r.OverageSince.Time, + ActivatedAt: r.ActivatedAt.Time, + }) + } + return out, nil +} + +func (s *pgxStore) AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (AccountOverageSnapshot, bool, error) { + row, err := s.q.SelectAccountOverageSnapshot(ctx, db.SelectAccountOverageSnapshotParams{ + AccountID: accountID.String(), + PeriodStart: periodStart, + }) + if errors.Is(err, pgx.ErrNoRows) { + return AccountOverageSnapshot{}, false, nil + } + if err != nil { + return AccountOverageSnapshot{}, false, err + } + return AccountOverageSnapshot{ + AccountID: accountID, + PeriodStart: periodStart, + OverCount: int(row.OverCount), + ChargedMicros: row.ChargedMicros, + Source: row.Source, + }, true, nil +} + +func (s *pgxStore) InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error { + // 0 rows = ON CONFLICT DO NOTHING kept an existing row (a prior grace charge, + // or a reclaimed boundary attempt's write) — success either way. + _, err := s.q.InsertAccountOverageSnapshot(ctx, db.InsertAccountOverageSnapshotParams{ + AccountID: snap.AccountID.String(), + PeriodStart: snap.PeriodStart, + PeriodEnd: snap.PeriodEnd, + OverCount: int32(snap.OverCount), //nolint:gosec // over_count = pooled sum − IncludedModules; the pool is Σ validated module_counts (each ≤ maxModuleCount), far below int32 max + ChargedMicros: snap.ChargedMicros, + Source: snap.Source, + InvoiceItemID: pgtype.Text{String: snap.InvoiceItemID, Valid: snap.InvoiceItemID != ""}, + }) + return err +} + // parseUUIDs parses a slice of UUID-as-string account ids (the form the sqlc // NOT NULL uuid → string override yields) into uuid.UUID. func parseUUIDs(ids []string) ([]uuid.UUID, error) { diff --git a/internal/account/cycle/types.go b/internal/account/cycle/types.go index f8d0a86..40db176 100644 --- a/internal/account/cycle/types.go +++ b/internal/account/cycle/types.go @@ -205,15 +205,24 @@ type ChargeSummary struct { // allowanceMicros) the cycle computed for the CLOSED period. ArrearsMicros int64 - // AdvanceBaseMicros is the NEW period's advance base fee (base-fee v1): - // Σ over the account's live apps of BaseFee + Overage × max(0, - // module_count − IncludedModules). 0 for a pre-backfill account (no - // mirror rows). The invoice total is ArrearsMicros + AdvanceBaseMicros; - // only when BOTH are 0 is the Stripe call skipped. + // AdvanceBaseMicros is the NEW period's advance base fee: Σ over the + // account's live apps of the FLAT BaseFeeMicros (module overage is + // account-wide pooled, migration 030 — no longer a per-app tier here). 0 for + // a pre-backfill account (no mirror rows). AdvanceBaseMicros int64 + // AccountOverageMicros is the CLOSING period's account-wide POOLED module + // overage (migration 030): $3 × max(0, Σ live-app module_count − + // IncludedModules), charged ONCE at the boundary only when the mid-period + // grace sweep did NOT already bill it (no account_overage_snapshots row for + // the period). 0 when already billed mid-period or the account is under the + // pool. The invoice total is ArrearsMicros + AdvanceBaseMicros + + // AccountOverageMicros; only when ALL are 0 is the Stripe call skipped. + AccountOverageMicros int64 + // ChargedCents is the whole-cent amount sent to Stripe (micros → cents - // round-half-up over arrears + advance base). 0 when no charge happened. + // round-half-up over arrears + advance base + pooled overage). 0 when no + // charge happened. ChargedCents int64 // StripeInvoiceID is the created Stripe invoice id, empty when no charge diff --git a/internal/account/db/models.go b/internal/account/db/models.go index d9e8e21..73b077b 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -331,6 +331,19 @@ type MsBillingAccount struct { SpendCeilingMicros pgtype.Int8 `json:"spend_ceiling_micros"` // UTC instant the account bound its FIRST credit card (billing-account activation). Immutable, first-bind-wins; billing-period anchor day = activated_at day-of-month (ADR 0005). NULL = never activated -> skipped by cmd/billing-cycle. ActivatedAt pgtype.Timestamptz `json:"activated_at"` + // UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 (account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account. + OverageSince pgtype.Timestamptz `json:"overage_since"` +} + +type MsBillingAccountOverageSnapshot struct { + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + OverCount int32 `json:"over_count"` + ChargedMicros int64 `json:"charged_micros"` + Source string `json:"source"` + InvoiceItemID pgtype.Text `json:"invoice_item_id"` + CreatedAt time.Time `json:"created_at"` } type MsBillingAddCardRequest struct { diff --git a/internal/account/db/overage.sql.go b/internal/account/db/overage.sql.go new file mode 100644 index 0000000..4edd8f5 --- /dev/null +++ b/internal/account/db/overage.sql.go @@ -0,0 +1,201 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: overage.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const accountsInOverageGrace = `-- name: AccountsInOverageGrace :many +SELECT id, overage_since, activated_at +FROM ms_billing.accounts +WHERE overage_since IS NOT NULL + AND overage_since <= $1 + AND activated_at IS NOT NULL +` + +type AccountsInOverageGraceRow struct { + ID string `json:"id"` + OverageSince pgtype.Timestamptz `json:"overage_since"` + ActivatedAt pgtype.Timestamptz `json:"activated_at"` +} + +// AccountsInOverageGrace returns every account whose grace timer has EXPIRED as +// of the cutoff (overage_since <= $1 = now − 3 days) and that can be charged +// (activated_at IS NOT NULL — same activation gate as the spine). The +// mid-period sweep iterates these, derives each account's current anchored +// period from activated_at, and charges the pooled overage once per period +// (guarded by account_overage_snapshots). overage_since is returned so the +// sweep prorates from grace-end (overage_since + 3d) to the period end. +func (q *Queries) AccountsInOverageGrace(ctx context.Context, overageSince pgtype.Timestamptz) ([]AccountsInOverageGraceRow, error) { + rows, err := q.db.Query(ctx, accountsInOverageGrace, overageSince) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AccountsInOverageGraceRow{} + for rows.Next() { + var i AccountsInOverageGraceRow + if err := rows.Scan(&i.ID, &i.OverageSince, &i.ActivatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const clearAccountOverage = `-- name: ClearAccountOverage :execrows +UPDATE ms_billing.accounts +SET overage_since = NULL +WHERE id = $1 + AND overage_since IS NOT NULL +` + +// ClearAccountOverage disarms the timer when the pool drops back to ≤5: +// overage_since → NULL. WHERE overage_since IS NOT NULL keeps it idempotent (a +// recompute on an already-under account affects 0 rows). No refund is issued +// (D1e) — clearing only stops FUTURE accrual; overage already charged this +// period stays billed via its account_overage_snapshots row. +func (q *Queries) ClearAccountOverage(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, clearAccountOverage, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const insertAccountOverageSnapshot = `-- name: InsertAccountOverageSnapshot :execrows +INSERT INTO ms_billing.account_overage_snapshots + (account_id, period_start, period_end, over_count, charged_micros, source, invoice_item_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (account_id, period_start) DO NOTHING +` + +type InsertAccountOverageSnapshotParams struct { + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + OverCount int32 `json:"over_count"` + ChargedMicros int64 `json:"charged_micros"` + Source string `json:"source"` + InvoiceItemID pgtype.Text `json:"invoice_item_id"` +} + +// InsertAccountOverageSnapshot records what a charge leg billed the account for +// one period's pooled overage (migration 030). ON CONFLICT (account_id, +// period_start) DO NOTHING: an existing row — a prior grace charge, or a prior +// reclaimed boundary attempt's own row — wins, so a re-run never rewrites what +// was already recorded as billed. :execrows so the caller can observe the no-op, +// though both outcomes are success (the Stripe idempotency key on the same +// (account, period) already deduped the money). +func (q *Queries) InsertAccountOverageSnapshot(ctx context.Context, arg InsertAccountOverageSnapshotParams) (int64, error) { + result, err := q.db.Exec(ctx, insertAccountOverageSnapshot, + arg.AccountID, + arg.PeriodStart, + arg.PeriodEnd, + arg.OverCount, + arg.ChargedMicros, + arg.Source, + arg.InvoiceItemID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const selectAccountOverageSnapshot = `-- name: SelectAccountOverageSnapshot :one +SELECT over_count, charged_micros, source +FROM ms_billing.account_overage_snapshots +WHERE account_id = $1 + AND period_start = $2 +` + +type SelectAccountOverageSnapshotParams struct { + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` +} + +type SelectAccountOverageSnapshotRow struct { + OverCount int32 `json:"over_count"` + ChargedMicros int64 `json:"charged_micros"` + Source string `json:"source"` +} + +// SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg +// billed for ONE (account, period) — the double-charge guard for the charge +// side (a row means "this period's pooled overage was already billed, skip it") +// AND the authoritative display value for GetAccountBill's pooled-overage line. +// Exact period_start match (both the grace sweep and the boundary leg key on the +// anchored window start); no row → never charged → the caller skips (charge +// side) or falls back to the live pooled estimate (display side). +func (q *Queries) SelectAccountOverageSnapshot(ctx context.Context, arg SelectAccountOverageSnapshotParams) (SelectAccountOverageSnapshotRow, error) { + row := q.db.QueryRow(ctx, selectAccountOverageSnapshot, arg.AccountID, arg.PeriodStart) + var i SelectAccountOverageSnapshotRow + err := row.Scan(&i.OverCount, &i.ChargedMicros, &i.Source) + return i, err +} + +const startAccountOverage = `-- name: StartAccountOverage :execrows +UPDATE ms_billing.accounts +SET overage_since = $2 +WHERE id = $1 + AND overage_since IS NULL +` + +type StartAccountOverageParams struct { + ID string `json:"id"` + OverageSince pgtype.Timestamptz `json:"overage_since"` +} + +// StartAccountOverage arms the account's grace timer: it stamps overage_since +// the FIRST time the pool crosses 5. WHERE overage_since IS NULL makes it +// first-crossing-wins (idempotent — a later recompute that finds the pool still +// over affects 0 rows and never moves the anchor), exactly like activated_at's +// first-bind-wins. :execrows so the caller can observe (and tolerate) the +// already-armed no-op. +func (q *Queries) StartAccountOverage(ctx context.Context, arg StartAccountOverageParams) (int64, error) { + result, err := q.db.Exec(ctx, startAccountOverage, arg.ID, arg.OverageSince) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const sumLiveModuleCount = `-- name: SumLiveModuleCount :one + +SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count +FROM ms_billing.apps +WHERE account_id = $1 + AND deleted_at IS NULL +` + +// Queries backing the account-wide POOLED module overage (migration 030, owner +// spec 2026-07-05). The pool is SUM(module_count) over the account's LIVE apps; +// overage = $3 × max(0, pool − 5), charged once per account per period through +// the account_overage_snapshots ledger (the double-charge guard). These queries +// serve BOTH the charge side (cycle package: the recompute + the mid-period +// grace sweep + the boundary leg) and the display side (usage package: +// GetAccountBill's pooled-overage line). Money never lives on the apps mirror; +// the pool is derived on read. +// SumLiveModuleCount returns the account-wide POOLED installed-module count: +// SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps. This is +// the single source of the pool both the overage timer recompute and the charge +// legs tier on: overage applies to max(0, this − IncludedModules). COALESCE to 0 +// so an account with no live apps sums to 0 (never over). ::bigint keeps the +// aggregate a non-nullable scalar. +func (q *Queries) SumLiveModuleCount(ctx context.Context, accountID string) (int64, error) { + row := q.db.QueryRow(ctx, sumLiveModuleCount, accountID) + var pooled_count int64 + err := row.Scan(&pooled_count) + return pooled_count, err +} diff --git a/internal/account/db/queries/overage.sql b/internal/account/db/queries/overage.sql new file mode 100644 index 0000000..bd63bf1 --- /dev/null +++ b/internal/account/db/queries/overage.sql @@ -0,0 +1,83 @@ +-- Queries backing the account-wide POOLED module overage (migration 030, owner +-- spec 2026-07-05). The pool is SUM(module_count) over the account's LIVE apps; +-- overage = $3 × max(0, pool − 5), charged once per account per period through +-- the account_overage_snapshots ledger (the double-charge guard). These queries +-- serve BOTH the charge side (cycle package: the recompute + the mid-period +-- grace sweep + the boundary leg) and the display side (usage package: +-- GetAccountBill's pooled-overage line). Money never lives on the apps mirror; +-- the pool is derived on read. + +-- SumLiveModuleCount returns the account-wide POOLED installed-module count: +-- SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps. This is +-- the single source of the pool both the overage timer recompute and the charge +-- legs tier on: overage applies to max(0, this − IncludedModules). COALESCE to 0 +-- so an account with no live apps sums to 0 (never over). ::bigint keeps the +-- aggregate a non-nullable scalar. +-- name: SumLiveModuleCount :one +SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count +FROM ms_billing.apps +WHERE account_id = $1 + AND deleted_at IS NULL; + +-- StartAccountOverage arms the account's grace timer: it stamps overage_since +-- the FIRST time the pool crosses 5. WHERE overage_since IS NULL makes it +-- first-crossing-wins (idempotent — a later recompute that finds the pool still +-- over affects 0 rows and never moves the anchor), exactly like activated_at's +-- first-bind-wins. :execrows so the caller can observe (and tolerate) the +-- already-armed no-op. +-- name: StartAccountOverage :execrows +UPDATE ms_billing.accounts +SET overage_since = $2 +WHERE id = $1 + AND overage_since IS NULL; + +-- ClearAccountOverage disarms the timer when the pool drops back to ≤5: +-- overage_since → NULL. WHERE overage_since IS NOT NULL keeps it idempotent (a +-- recompute on an already-under account affects 0 rows). No refund is issued +-- (D1e) — clearing only stops FUTURE accrual; overage already charged this +-- period stays billed via its account_overage_snapshots row. +-- name: ClearAccountOverage :execrows +UPDATE ms_billing.accounts +SET overage_since = NULL +WHERE id = $1 + AND overage_since IS NOT NULL; + +-- AccountsInOverageGrace returns every account whose grace timer has EXPIRED as +-- of the cutoff (overage_since <= $1 = now − 3 days) and that can be charged +-- (activated_at IS NOT NULL — same activation gate as the spine). The +-- mid-period sweep iterates these, derives each account's current anchored +-- period from activated_at, and charges the pooled overage once per period +-- (guarded by account_overage_snapshots). overage_since is returned so the +-- sweep prorates from grace-end (overage_since + 3d) to the period end. +-- name: AccountsInOverageGrace :many +SELECT id, overage_since, activated_at +FROM ms_billing.accounts +WHERE overage_since IS NOT NULL + AND overage_since <= $1 + AND activated_at IS NOT NULL; + +-- SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg +-- billed for ONE (account, period) — the double-charge guard for the charge +-- side (a row means "this period's pooled overage was already billed, skip it") +-- AND the authoritative display value for GetAccountBill's pooled-overage line. +-- Exact period_start match (both the grace sweep and the boundary leg key on the +-- anchored window start); no row → never charged → the caller skips (charge +-- side) or falls back to the live pooled estimate (display side). +-- name: SelectAccountOverageSnapshot :one +SELECT over_count, charged_micros, source +FROM ms_billing.account_overage_snapshots +WHERE account_id = $1 + AND period_start = $2; + +-- InsertAccountOverageSnapshot records what a charge leg billed the account for +-- one period's pooled overage (migration 030). ON CONFLICT (account_id, +-- period_start) DO NOTHING: an existing row — a prior grace charge, or a prior +-- reclaimed boundary attempt's own row — wins, so a re-run never rewrites what +-- was already recorded as billed. :execrows so the caller can observe the no-op, +-- though both outcomes are success (the Stripe idempotency key on the same +-- (account, period) already deduped the money). +-- name: InsertAccountOverageSnapshot :execrows +INSERT INTO ms_billing.account_overage_snapshots + (account_id, period_start, period_end, over_count, charged_micros, source, invoice_item_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (account_id, period_start) DO NOTHING; diff --git a/internal/account/usage/accountbill.go b/internal/account/usage/accountbill.go index daebdb9..6a8667d 100644 --- a/internal/account/usage/accountbill.go +++ b/internal/account/usage/accountbill.go @@ -5,6 +5,7 @@ import ( "context" "slices" "sort" + "time" "github.com/google/uuid" @@ -66,8 +67,11 @@ const ( // 5. applies the PaaS credit ONCE at the ACCOUNT level: the same ACTIVE-SaaS // gate as the per-app credit (v1 has no subscription system → always 0), // capped at ModuleUsageTotal + InfraTotal so it never eats base fees, -// 6. TotalMicros = BaseFeeTotal + ModuleUsageTotal + InfraTotal − PaasCredit -// (≥ 0 by the cap), plus the v1 plan stub with RenewsAt = the period end. +// 6. adds the account-wide POOLED module overage (migration 030) once, +// snapshot-first (the frozen charge, else a live estimate from the pool), +// 7. TotalMicros = BaseFeeTotal + ModuleUsageTotal + InfraTotal + AccountOverage +// − PaasCredit (≥ 0 by the cap), plus the v1 plan stub with RenewsAt = the +// period end. func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) (*GetAccountBillResponse, error) { if req.OwnerUserID == uuid.Nil && req.OwnerOrgID == uuid.Nil { return nil, billing.InvalidInput("owner_user_id or owner_org_id required") @@ -168,6 +172,21 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) infraTotal += parts.InfraTotalMicros } + // Account-wide POOLED module overage (migration 030), SNAPSHOT-FIRST exactly + // like the per-app base: a charged period reads the frozen + // account_overage_snapshots row (what the grace sweep or the boundary + // actually invoiced), so the displayed overage IS what was billed even after + // later SyncAppModules pool changes. An un-charged period (pre-030 history, + // an account under the pool, or an in-progress period no leg has billed yet) + // falls back to the LIVE estimate from the CURRENT pooled sum — the full + // monthly overage the account would owe at steady state (the display doesn't + // prorate the estimate; the charge legs own proration). It is an ACCOUNT + // line, never allocated back per app. + accountOverage, err := s.accountOverageMicros(ctx, accountID, periodStart) + if err != nil { + return nil, err + } + // TODO(subscription): same gate as GetAppBill — v1 has no subscription // system, so the credit is subscription-gated OFF and resolves to 0. const subscriptionActive = false @@ -185,11 +204,32 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) BaseFeeTotalMicros: baseFeeTotal, ModuleUsageTotalMicros: moduleUsageTotal, InfraTotalMicros: infraTotal, + AccountOverageMicros: accountOverage, PaasCreditMicros: paasCredit, - TotalMicros: baseFeeTotal + moduleUsageTotal + infraTotal - paasCredit, + TotalMicros: baseFeeTotal + moduleUsageTotal + infraTotal + accountOverage - paasCredit, }, nil } +// accountOverageMicros resolves the account-wide pooled overage for the display, +// snapshot-first: the frozen account_overage_snapshots charge for the period if +// a leg billed it, else the live estimate AccountOverageMicros(current pooled +// sum). The PaaS credit never offsets it (overage rides ON TOP, like the base +// fee) so it is resolved outside the credit math. +func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (int64, error) { + charged, snapped, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) + if err != nil { + return 0, billing.Internal("account overage snapshot lookup failed", err) + } + if snapped { + return charged, nil + } + pooled, err := s.store.PooledModuleCount(ctx, accountID) + if err != nil { + return 0, billing.Internal("pooled module count lookup failed", err) + } + return AccountOverageMicros(pooled), nil +} + // accountPaasCreditMicros is the ACCOUNT-level PaaS credit: the same // subscription-gated pct-of-infra magnitude as GetAppBill's per-app // paasCreditMicros, computed over the ACCOUNT-WIDE infra total and applied diff --git a/internal/account/usage/accountbill_test.go b/internal/account/usage/accountbill_test.go index 6d86162..1566946 100644 --- a/internal/account/usage/accountbill_test.go +++ b/internal/account/usage/accountbill_test.go @@ -35,9 +35,11 @@ func TestGetAccountBill_AggregatesUsageMirrorAndBothApps(t *testing.T) { // half; base = legacy flat + usage-proxy overage (1 module → flat). // appB — mirror-only, ZERO usage (just created, nothing metered): must // still appear with its full base (created long before the period). - // appC — both halves: synced count 7 → base $20 + 2×$3; usage + module- - // attributed infra. - // Every per-app number is exactly what GetAppBill would return for that app. + // appC — both halves: synced count 7 → FLAT $20 base (overage is pooled, + // migration 030); usage + module-attributed infra. + // The account-wide pool is 0 (appB) + 7 (appC) = 7 → 2 over → $6 pooled + // overage on the ACCOUNT (not on any app). Every per-app number is exactly + // what GetAppBill would return for that app. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -79,26 +81,33 @@ func TestGetAccountBill_AggregatesUsageMirrorAndBothApps(t *testing.T) { require.EqualValues(t, 200, resp.Apps[1].InfraMicros) require.Equal(t, usage.BaseFeeMicros+1200, resp.Apps[1].TotalMicros) - // appC: synced count 7 → base + 2 × overage; module-attributed infra folds in. - wantBaseC := usage.BaseFeeMicros + 2*usage.ModuleOverageFeeMicros - require.Equal(t, wantBaseC, resp.Apps[2].BaseFeeMicros) + // appC: synced count 7 → FLAT base (overage is pooled, not per-app); + // module-attributed infra folds in. + require.Equal(t, usage.BaseFeeMicros, resp.Apps[2].BaseFeeMicros) require.EqualValues(t, 500, resp.Apps[2].ModuleUsageMicros) require.EqualValues(t, 24, resp.Apps[2].InfraMicros) - require.Equal(t, wantBaseC+524, resp.Apps[2].TotalMicros) + require.Equal(t, usage.BaseFeeMicros+524, resp.Apps[2].TotalMicros) - // Account totals are the column sums; credit is gated off (v1) → total is - // the plain sum and apps[].total_micros (pre-credit) reconcile exactly. - require.Equal(t, 2*usage.BaseFeeMicros+wantBaseC, resp.BaseFeeTotalMicros) + // Account totals are the column sums plus the account-wide POOLED overage. + // All three apps are flat, so BaseFeeTotal = 3 × $20. Pool 7 → 2 over → $6. + require.Equal(t, 3*usage.BaseFeeMicros, resp.BaseFeeTotalMicros) require.EqualValues(t, 1500, resp.ModuleUsageTotalMicros) require.EqualValues(t, 224, resp.InfraTotalMicros) + require.Equal(t, usage.AccountOverageMicros(7), resp.AccountOverageMicros) + require.EqualValues(t, 6_000_000, resp.AccountOverageMicros) // 2 over × $3 require.Zero(t, resp.PaasCreditMicros) - require.Equal(t, resp.BaseFeeTotalMicros+resp.ModuleUsageTotalMicros+resp.InfraTotalMicros, resp.TotalMicros) - var preCredit int64 + require.Equal(t, + resp.BaseFeeTotalMicros+resp.ModuleUsageTotalMicros+resp.InfraTotalMicros+resp.AccountOverageMicros, + resp.TotalMicros) + // apps[].total_micros are PRE-credit AND exclude the account-level pooled + // overage (it is never allocated per-app), so Σ apps == account total − + // overage + credit. + var perApp int64 for _, a := range resp.Apps { - preCredit += a.TotalMicros + perApp += a.TotalMicros } - require.Equal(t, resp.TotalMicros+resp.PaasCreditMicros, preCredit, - "Σ apps[].total (pre-credit) == account total + credit") + require.Equal(t, resp.TotalMicros-resp.AccountOverageMicros+resp.PaasCreditMicros, perApp, + "Σ apps[].total (pre-credit) == account total − pooled overage + credit") } func TestGetAccountBill_DeterministicAppOrdering(t *testing.T) { @@ -171,10 +180,11 @@ func TestGetAccountBill_MirrorLifecycleAcrossTheWindow(t *testing.T) { // --- snapshot-first base ---------------------------------------------------- func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { - // The account bill inherits GetAppBill's snapshot-first base: the May - // period was CHARGED at count 5 (flat $20 snapshot), then SyncAppModules - // moved the mirror to 8. The frozen period must show the snapshot base; - // the CURRENT (un-snapshotted) period estimates from the live count 8. + // The account bill inherits GetAppBill's snapshot-first PER-APP base: the May + // period was CHARGED at a flat $20 snapshot, then SyncAppModules moved the + // mirror to 8. The per-app base is flat in both periods (overage is pooled). + // The CURRENT (un-snapshotted) period's account-wide overage estimates from + // the live pool 8 → 3 over → $9. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -182,7 +192,7 @@ func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { app := seqUUID(1) store.appMirrors[app] = usage.AppMirrorInfo{ModuleCount: 8, CreatedAt: time.Date(2026, 1, 10, 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.AppBaseFeeMicros(usage.BaseFeeMicros, 5), + BaseMicros: usage.BaseFeeMicros, } resp, err := newService(store).GetAccountBill(context.Background(), usage.GetAccountBillRequest{ @@ -190,13 +200,14 @@ func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { }) require.NoError(t, err) require.Len(t, resp.Apps, 1) - require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "charged period shows the invoiced snapshot, not the resynced count") + require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "charged period shows the invoiced flat snapshot") resp, err = newService(store).GetAccountBill(context.Background(), usage.GetAccountBillRequest{OwnerUserID: owner}) require.NoError(t, err) require.Len(t, resp.Apps, 1) - require.Equal(t, usage.BaseFeeMicros+3*usage.ModuleOverageFeeMicros, resp.Apps[0].BaseFeeMicros, - "un-snapshotted current period estimates from the synced count 8") + require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "per-app base is flat regardless of count") + require.EqualValues(t, 9_000_000, resp.AccountOverageMicros, + "un-snapshotted current period estimates the pooled overage from the live pool 8 → 3 over → $9") } // --- period resolution -------------------------------------------------------- diff --git a/internal/account/usage/basefee.go b/internal/account/usage/basefee.go index da47975..718a0e7 100644 --- a/internal/account/usage/basefee.go +++ b/internal/account/usage/basefee.go @@ -2,29 +2,50 @@ package usage import "time" -// This file is the SINGLE home of the base-fee math (owner spec 2026-07-05, -// DESIGN.md "Base fee — v1 spec"). Both consumers — the display read -// (GetAppBill, this package) and the charge spine (cycle: the RegisterApp -// creation-proration charge + the boundary advance leg) — compute an app's -// per-period base through these two functions, so the bill page, the invoice, -// and the mirror can never disagree by construction. All money is integer -// micro-dollars; the arithmetic here is pure int64 (no big.Rat needed: the -// operands are bounded — see ProratedBaseMicros). +// This file is the SINGLE home of the base-fee + pooled-overage math (owner +// spec 2026-07-05, DESIGN.md "Base fee — v1 spec"; account-wide overage +// reversal, migration 030). Both consumers — the display read (GetAppBill / +// GetAccountBill, this package) and the charge spine (cycle: the RegisterApp +// creation-proration charge, the boundary advance leg, the mid-period grace +// sweep) — compute the per-app FLAT base and the account-wide pooled overage +// through these functions, so the bill page, the invoice, and the mirror can +// never disagree by construction. All money is integer micro-dollars; the +// arithmetic here is pure int64 (no big.Rat needed: the operands are bounded — +// see ProratedBaseMicros). +// +// Overage moved from PER-APP to ACCOUNT-WIDE POOLED (migration 030): the flat +// $20/app base is per-app and unchanged, but the $3/module surcharge now +// applies ONCE per account to max(0, Σ live-app module_count − IncludedModules) +// — see AccountOverageMicros. There is deliberately NO per-app overage helper +// anymore: an app's base is just the flat (plan-resolved) fee. -// AppBaseFeeMicros is an app's FULL per-period base fee: +// AccountOverageMicros is the account-wide POOLED module overage for one +// period: // -// base + ModuleOverageFeeMicros × max(0, moduleCount − IncludedModules) +// ModuleOverageFeeMicros × max(0, pooledModuleCount − IncludedModules) // -// baseFeeMicros is the plan-resolved flat fee (resolveBaseFeeMicros; the -// charge spine passes BaseFeeMicros until a plan resolver exists) so the -// plan seam stays in ONE place and this function stays pure tier math. -// A negative moduleCount cannot occur (DB CHECK module_count >= 0); the -// max(0, …) clamp still makes the function total. -func AppBaseFeeMicros(baseFeeMicros int64, moduleCount int) int64 { - if extra := moduleCount - IncludedModules; extra > 0 { - return baseFeeMicros + ModuleOverageFeeMicros*int64(extra) +// pooledModuleCount is SUM(module_count) over the account's LIVE apps (one pool +// of IncludedModules for the WHOLE account, not per app). A pooledModuleCount +// ≤ IncludedModules yields 0 (the max(0, …) clamp makes the function total; +// a negative count cannot occur — the sum of non-negative DB-CHECKed counts). +func AccountOverageMicros(pooledModuleCount int) int64 { + if extra := pooledModuleCount - IncludedModules; extra > 0 { + return ModuleOverageFeeMicros * int64(extra) } - return baseFeeMicros + return 0 +} + +// ProratedOverageMicros prorates an account-wide pooled overage amount for the +// period [periodStart, periodEnd), covering [grace-end day, periodEnd): the +// SAME day-count round-half-up math as ProratedBaseMicros (the overage amount +// is prorated exactly like a base amount), but ANCHORED on the grace-end +// instant instead of an app's creation instant. graceEnd on/before periodStart +// → the FULL overage (the account was over for the whole period); graceEnd +// on/after periodEnd → 0 (grace ends after this period — nothing to charge +// yet). Kept as a named wrapper so the semantic ("prorate FROM grace-end") is +// legible at the mid-period grace sweep's call site. +func ProratedOverageMicros(overageMicros int64, graceEnd, periodStart, periodEnd time.Time) int64 { + return ProratedBaseMicros(overageMicros, graceEnd, periodStart, periodEnd) } // ProratedBaseMicros prorates an app's per-period base fee for the period diff --git a/internal/account/usage/basefee_test.go b/internal/account/usage/basefee_test.go index 69db875..59c721f 100644 --- a/internal/account/usage/basefee_test.go +++ b/internal/account/usage/basefee_test.go @@ -13,24 +13,27 @@ func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } -// --- AppBaseFeeMicros: overage tier boundaries ------------------------------ +// --- AccountOverageMicros: account-wide POOLED overage tier boundaries ------- -func TestAppBaseFeeMicros_OverageBoundaries(t *testing.T) { - // Owner spec 2026-07-05: base + $3 × max(0, count − 5). The boundary cases - // pin the tier edges: 5 included modules cost nothing extra, the 6th costs - // exactly one overage, 0 modules never discount below the base. +func TestAccountOverageMicros_PooledBoundaries(t *testing.T) { + // Owner spec 2026-07-05 / migration 030: $3 × max(0, pooledCount − 5), where + // pooledCount is Σ live-app module_count for the WHOLE account. The boundary + // cases pin the tier edges: ≤5 pooled modules cost nothing, the 6th costs + // exactly one overage, and the overage is NOT the flat base (it is the + // account-level surcharge only). for _, tc := range []struct { - name string - count int - want int64 + name string + pooled int + want int64 }{ - {"zero modules → flat base (no negative overage)", 0, usage.BaseFeeMicros}, - {"exactly included (5) → flat base", usage.IncludedModules, usage.BaseFeeMicros}, - {"included+1 (6) → one $3 overage", usage.IncludedModules + 1, usage.BaseFeeMicros + usage.ModuleOverageFeeMicros}, - {"included+10 → ten overages", usage.IncludedModules + 10, usage.BaseFeeMicros + 10*usage.ModuleOverageFeeMicros}, + {"zero pooled → no overage", 0, 0}, + {"under pool (3) → no overage", 3, 0}, + {"exactly included (5) → no overage", usage.IncludedModules, 0}, + {"included+1 (6) → one $3 overage", usage.IncludedModules + 1, usage.ModuleOverageFeeMicros}, + {"included+10 → ten overages", usage.IncludedModules + 10, 10 * usage.ModuleOverageFeeMicros}, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, usage.AppBaseFeeMicros(usage.BaseFeeMicros, tc.count)) + require.Equal(t, tc.want, usage.AccountOverageMicros(tc.pooled)) }) } } @@ -124,10 +127,20 @@ func TestProratedBaseMicros_ClampedAnchorMonth(t *testing.T) { require.EqualValues(t, 19_354_839, got) } -func TestProratedBaseMicros_ProratesTheOverageToo(t *testing.T) { - // The prorated amount is the FULL app base (base + overage), not the flat - // fee: 7 modules → 26e6; half of a 30-day period (15 days) → 13e6. - appBase := usage.AppBaseFeeMicros(usage.BaseFeeMicros, 7) - got := usage.ProratedBaseMicros(appBase, day(2026, 6, 19), day(2026, 6, 4), day(2026, 7, 4)) - require.EqualValues(t, 13_000_000, got) +func TestProratedOverageMicros_ProratesPooledOverageFromGraceEnd(t *testing.T) { + // Migration 030: the mid-period sweep prorates the account-wide POOLED + // overage from grace-end to the period end with the SAME day-count math as + // ProratedBaseMicros. Pool of 7 → 2 over → $6/period; grace ends mid-period + // (Jun 19 → 15 of a 30-day [Jun 4, Jul 4) period) → half → $3. + overage := usage.AccountOverageMicros(7) + require.EqualValues(t, 6_000_000, overage) + got := usage.ProratedOverageMicros(overage, day(2026, 6, 19), day(2026, 6, 4), day(2026, 7, 4)) + require.EqualValues(t, 3_000_000, got) + + // grace-end on/before the period start → the FULL pooled overage (over the + // whole period). + require.EqualValues(t, overage, usage.ProratedOverageMicros(overage, day(2026, 6, 4), day(2026, 6, 4), day(2026, 7, 4))) + + // grace-end on/after the period end → 0 (grace ends after this period). + require.EqualValues(t, 0, usage.ProratedOverageMicros(overage, day(2026, 7, 4), day(2026, 6, 4), day(2026, 7, 4))) } diff --git a/internal/account/usage/bill.go b/internal/account/usage/bill.go index d33d262..947c20e 100644 --- a/internal/account/usage/bill.go +++ b/internal/account/usage/bill.go @@ -32,9 +32,10 @@ import ( const ( // BaseFeeMicros is 基本費用 — the fixed per-app/period platform base fee on the - // DEFAULT plan. It BUNDLES the PaaS infra credit (surfaced as PaasCreditMicros) - // and INCLUDES up to IncludedModules installed modules; each installed module - // beyond that adds ModuleOverageFeeMicros. Tunable. Default $20. + // DEFAULT plan. It BUNDLES the PaaS infra credit (surfaced as PaasCreditMicros). + // It is FLAT per app: the IncludedModules allowance + the ModuleOverageFeeMicros + // surcharge are ACCOUNT-WIDE POOLED (migration 030 — see AccountOverageMicros), + // NOT folded into this per-app fee. Tunable. Default $20. BaseFeeMicros int64 = 20_000_000 // $20.00 // ProBaseFeeMicros is the base fee on the Pro org plan. TODO(plan): wire a real @@ -43,16 +44,17 @@ const ( // value — tune with the real Pro plan. ProBaseFeeMicros int64 = 50_000_000 // $50.00 (placeholder) - // IncludedModules is how many installed modules the base fee bundles before the - // per-module surcharge kicks in. Owner spec 2026-07-05 (base-fee v1, DESIGN.md - // D1): 5 included. Tunable ("may change"); becomes plan-resolved later. + // IncludedModules is the ACCOUNT-WIDE POOL of installed modules the base fee + // bundles before the per-module surcharge kicks in (migration 030 — ONE pool + // of 5 for the whole account, summed over its live apps, NOT 5 per app). Owner + // spec 2026-07-05. Tunable ("may change"); becomes plan-resolved later. IncludedModules = 5 - // ModuleOverageFeeMicros is the surcharge added to the base fee for EACH - // installed module beyond IncludedModules. Owner spec 2026-07-05 (base-fee v1): - // $3.00/module/period. Tunable; becomes plan-resolved later. App base for a - // period = BaseFee + Overage × max(0, module_count − IncludedModules) — see - // AppBaseFeeMicros, the ONE place that formula lives. + // ModuleOverageFeeMicros is the surcharge for EACH installed module beyond the + // account-wide pooled IncludedModules. Owner spec 2026-07-05: $3.00/module/ + // period. Account overage for a period = Overage × max(0, Σ live-app + // module_count − IncludedModules) — see AccountOverageMicros, the ONE place + // that formula lives. Tunable; becomes plan-resolved later. ModuleOverageFeeMicros int64 = 3_000_000 // $3.00 // PaasCreditPct is PaaS 額度 — the percentage of the 基礎設施 InfraTotal credited @@ -133,11 +135,12 @@ func pctMicros(base int64, pct int) (int64, error) { // match), so the displayed base IS what the invoice charged even after // later SyncAppModules count changes. Only an un-snapshotted period // (pre-feature history, unactivated account, in-progress period) falls -// back to the live ESTIMATE from the mirror (migration 027): -// AppBaseFeeMicros(resolveBaseFeeMicros(plan), module_count), PRORATED via +// back to the live ESTIMATE from the mirror (migration 027): the FLAT +// plan-resolved base (resolveBaseFeeMicros(plan)), PRORATED via // ProratedBaseMicros when the app's created_at falls inside the period. -// An app ABSENT from the mirror (pre-backfill) falls back to the usage-proxy -// count below — today's behavior, until the api-platform backfill lands, +// Module overage is NOT in the per-app base anymore — it is account-wide +// pooled (migration 030, surfaced on GetAccountBill). An app ABSENT from +// the mirror (pre-backfill) falls back to the flat plan base below, // 5. computes PaaS 額度 credit = PaasCreditPct% of the infra total, but ONLY when // an active SaaS subscription earns it — v1 has no subscription system, so the // credit is subscription-gated OFF and is 0 (the wire field stays for back-compat), @@ -293,10 +296,10 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found // Keep only the non-reserved rows → module usage (displayed lines); the // reserved infra.* / platform.* rows are dropped here (infra is sourced - // per-metric from the catalog below). Count DISTINCT non-reserved modules as - // the installed-module proxy for the base-fee tier. + // per-metric from the catalog below). Module overage is now account-wide + // pooled (migration 030), so the per-app base no longer needs a + // distinct-module proxy count here. moduleUsage := make([]AppMetricUsage, 0, len(lines)) - installedModules := make(map[uuid.UUID]struct{}) var moduleUsageTotal int64 for _, r := range lines { if isReservedMetric(r.Metric) { @@ -317,7 +320,6 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found ChargedMicros: r.ChargedMicros, }) moduleUsageTotal += r.ChargedMicros - installedModules[r.ModuleID] = struct{}{} } // 基礎設施: source infra per-metric from the CATALOG (metric_definitions), NOT @@ -394,14 +396,16 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found // period leaves that period's base spent, so it falls through below. baseFee = 0 case mirrored: - // No snapshot → estimate from the mirror's current count, the SAME - // AppBaseFeeMicros + ProratedBaseMicros math the charge legs bill. - baseFee = ProratedBaseMicros( - AppBaseFeeMicros(resolveBaseFeeMicros(plan), mirror.ModuleCount), - mirror.CreatedAt, periodStart, periodEnd, - ) + // No snapshot → estimate the FLAT per-app base from the plan (module + // overage is now account-wide pooled, migration 030 — never folded + // into the per-app base here), the SAME ProratedBaseMicros math the + // charge legs bill, prorated when created_at falls inside the period. + baseFee = ProratedBaseMicros(resolveBaseFeeMicros(plan), mirror.CreatedAt, periodStart, periodEnd) default: - baseFee = AppBaseFeeMicros(resolveBaseFeeMicros(plan), len(installedModules)) + // No mirror row (pre-backfill): the flat per-app base. The pre-030 + // usage-proxy overage is gone — overage is pooled at the account + // level, so a per-app read never estimates it. + baseFee = resolveBaseFeeMicros(plan) } } diff --git a/internal/account/usage/bill_test.go b/internal/account/usage/bill_test.go index 392ac80..1fb239a 100644 --- a/internal/account/usage/bill_test.go +++ b/internal/account/usage/bill_test.go @@ -59,8 +59,11 @@ func TestGetAppBill_BaseFeeBundlesUpToIncludedModules(t *testing.T) { require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "5 modules is within the bundle → no surcharge") } -func TestGetAppBill_BaseFeeSurchargesModulesBeyondIncluded(t *testing.T) { - // 7 distinct modules → base = BaseFeeMicros + ModuleOverageFeeMicros × (7 − 5). +func TestGetAppBill_PerAppBaseIsFlatEvenWithManyModules(t *testing.T) { + // Migration 030: module overage is ACCOUNT-WIDE POOLED, so the PER-APP base + // is the FLAT $20 regardless of how many modules the app uses — 7 distinct + // modules no longer surcharge THIS app's base (the pooled overage surfaces on + // GetAccountBill instead). store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -70,8 +73,7 @@ func TestGetAppBill_BaseFeeSurchargesModulesBeyondIncluded(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: uuid.New()}) require.NoError(t, err) - want := usage.BaseFeeMicros + usage.ModuleOverageFeeMicros*2 - require.Equal(t, want, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "per-app base is flat; overage is pooled at the account") } func TestGetAppBill_ModuleCountIsDistinctModulesNotLines(t *testing.T) { @@ -518,10 +520,10 @@ func mirrorPeriod(store *fakeStore) uuid.UUID { return pid } -func TestGetAppBill_MirroredAppUsesSyncedModuleCount(t *testing.T) { - // A mirrored app's overage comes from the SYNCED module_count (7 → +2×$3), - // NOT the usage-proxy distinct-module count (only 1 module has usage here - // — the proxy would have said "flat base"). +func TestGetAppBill_MirroredAppShowsFlatBase(t *testing.T) { + // Migration 030: a mirrored app's per-app base is the FLAT $20 regardless of + // its synced module_count (7 here) — module overage is account-wide pooled, + // no longer part of the per-app base. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -535,7 +537,7 @@ func TestGetAppBill_MirroredAppUsesSyncedModuleCount(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app, PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+2*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "per-app base is flat (overage is pooled)") } func TestGetAppBill_MirroredAppProratesCreationPeriod(t *testing.T) { @@ -602,9 +604,10 @@ func TestGetAppBill_AppDeletedDuringPeriodKeepsSpentBase(t *testing.T) { require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros) } -func TestGetAppBill_UnmirroredAppKeepsProxyFallback(t *testing.T) { - // No mirror row (pre-backfill) → today's behavior survives verbatim: flat - // base + the usage-proxy overage (7 distinct modules with usage → +2×$3). +func TestGetAppBill_UnmirroredAppShowsFlatBase(t *testing.T) { + // No mirror row (pre-backfill) → the FLAT per-app base. Migration 030 retired + // the pre-030 usage-proxy overage: overage is account-wide pooled, never + // estimated per app, so even 7 distinct modules with usage show flat $20 here. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -615,7 +618,7 @@ func TestGetAppBill_UnmirroredAppKeepsProxyFallback(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: uuid.New(), PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+2*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros) } func TestGetAppBill_InternalOnAppMirrorError(t *testing.T) { @@ -645,21 +648,22 @@ func TestGetAppBill_SnapshotFreezesChargedPeriodBaseAfterSync(t *testing.T) { CreatedAt: time.Date(2026, 1, 10, 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.AppBaseFeeMicros(usage.BaseFeeMicros, 5), // what the boundary actually invoiced + BaseMicros: usage.BaseFeeMicros, // what the boundary actually invoiced (flat base) } - // The charged period displays the frozen count-5 base, NOT a recompute - // from the mirror's current count 8. + // The charged period displays the frozen snapshot base, NOT a recompute from + // the mirror's current count 8. resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app, PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "charged period shows what was invoiced (count 5 → flat base)") + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "charged period shows what was invoiced (flat base)") - // The CURRENT period has no snapshot yet → live ESTIMATE from the synced - // count 8. + // The CURRENT period has no snapshot yet → live ESTIMATE, which is the FLAT + // per-app base (migration 030 — overage is pooled, not per-app), unaffected + // by the synced count 8. resp, err = newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+3*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros, - "un-snapshotted period estimates from the current count 8") + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, + "un-snapshotted period estimates the flat per-app base regardless of count") } func TestGetAppBill_ProrationSnapshotFreezesCreationPeriodBase(t *testing.T) { diff --git a/internal/account/usage/service_test.go b/internal/account/usage/service_test.go index cc1b9de..147d7a1 100644 --- a/internal/account/usage/service_test.go +++ b/internal/account/usage/service_test.go @@ -40,6 +40,13 @@ type fakeStore struct { appMirrors map[uuid.UUID]usage.AppMirrorInfo // app_id → ms_billing.apps roster row (migration 027) baseSnapshots map[string]usage.AppBaseSnapshotInfo // app_id/period_start → charged-base snapshot (migration 028) + // account-wide POOLED overage (migration 030): PooledModuleCount sums the + // appMirrors' module_count (like the SQL over ms_billing.apps); overageSnaps + // keys account_id/period_start → the frozen pooled-overage charged_micros. + overageSnaps map[string]int64 + errPooledCount error + errOverageSnap error + // usageAppIDs is what AppIDsWithUsage enumerates (the usage half of // GetAccountBill's roster); the mirror half is DERIVED from appMirrors with // the real overlap rule (see MirroredAppIDs). @@ -121,6 +128,7 @@ func newFakeStore() *fakeStore { visibility: map[uuid.UUID]usage.Visibility{}, appMirrors: map[uuid.UUID]usage.AppMirrorInfo{}, baseSnapshots: map[string]usage.AppBaseSnapshotInfo{}, + overageSnaps: map[string]int64{}, appBillRowsByApp: map[uuid.UUID][]usage.AppMetricUsageRaw{}, appInfraBillRowsByApp: map[uuid.UUID][]usage.AppInfraUsage{}, appModuleInfraBillRowsByApp: map[uuid.UUID][]usage.AppModuleInfraUsage{}, @@ -183,6 +191,40 @@ func (f *fakeStore) AppBaseSnapshot(_ context.Context, appID uuid.UUID, periodSt return s, ok, nil } +// overageSnapKey mirrors the account_overage_snapshots PRIMARY KEY +// (account_id, period_start). +func overageSnapKey(accountID uuid.UUID, periodStart time.Time) string { + return accountID.String() + "/" + periodStart.UTC().Format(time.RFC3339Nano) +} + +// PooledModuleCount returns the account's live pooled module count (migration +// 030): Σ module_count over the non-deleted appMirrors, mirroring the SQL over +// ms_billing.apps. The single-account usage fake holds one account's roster, so +// it sums every live mirror row. +func (f *fakeStore) PooledModuleCount(_ context.Context, _ uuid.UUID) (int, error) { + if f.errPooledCount != nil { + return 0, f.errPooledCount + } + sum := 0 + for _, m := range f.appMirrors { + if !m.Deleted { + sum += m.ModuleCount + } + } + return sum, nil +} + +// AccountOverageSnapshot returns the fake migration-030 pooled-overage charge +// for one (account, period_start); absent → never charged, so GetAccountBill +// falls back to the live pooled estimate. +func (f *fakeStore) AccountOverageSnapshot(_ context.Context, accountID uuid.UUID, periodStart time.Time) (int64, bool, error) { + if f.errOverageSnap != nil { + return 0, false, f.errOverageSnap + } + m, ok := f.overageSnaps[overageSnapKey(accountID, periodStart)] + return m, ok, nil +} + // AccountAnchorDay returns the configured anchor day for an account, defaulting // to 1 (the UTC calendar month) so tests that don't set one keep the pre-anchor // window behavior. diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index 3a737d3..54d983f 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -208,6 +208,21 @@ type Store interface { // deleted BEFORE the period opened is excluded (base 0, no new usage — // residual ledger rows still enumerate through AppIDsWithUsage). MirroredAppIDs(ctx context.Context, accountID uuid.UUID, periodStart, periodEnd time.Time) ([]uuid.UUID, error) + + // PooledModuleCount returns the account-wide POOLED installed-module count: + // SUM(module_count) over the account's LIVE apps (migration 030). It is the + // live input to GetAccountBill's account-wide overage ESTIMATE when the + // current period has no frozen account_overage_snapshots row yet. + PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) + + // AccountOverageSnapshot reads the frozen pooled overage a charge leg billed + // for ONE (account, period) — the AUTHORITATIVE display value for + // GetAccountBill's account-wide overage line (what the grace sweep or the + // boundary actually invoiced). found=false → the period's pooled overage was + // never charged (pre-030 history, an account under the pool, or an + // in-progress period no leg has billed yet) and the caller falls back to the + // live pooled ESTIMATE. + AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (chargedMicros int64, found bool, err error) } // AppBaseSnapshotInfo is the display-read projection of a @@ -605,6 +620,33 @@ func (s *pgxStore) MirroredAppIDs(ctx context.Context, accountID uuid.UUID, peri return parseAppIDs(rows) } +// PooledModuleCount sums module_count over the account's live apps (migration +// 030) — the live input to GetAccountBill's account-wide overage estimate. +func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) { + sum, err := s.q.SumLiveModuleCount(ctx, accountID.String()) + if err != nil { + return 0, err + } + return int(sum), nil +} + +// AccountOverageSnapshot reads the frozen pooled overage charged for one +// (account, period) — pgx.ErrNoRows → found=false (the service falls back to +// the live pooled estimate). +func (s *pgxStore) AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (int64, bool, error) { + row, err := s.q.SelectAccountOverageSnapshot(ctx, db.SelectAccountOverageSnapshotParams{ + AccountID: accountID.String(), + PeriodStart: periodStart, + }) + if errors.Is(err, pgx.ErrNoRows) { + return 0, false, nil // never overage-charged → live estimate + } + if err != nil { + return 0, false, err + } + return row.ChargedMicros, true, nil +} + // parseAppIDs decodes a generated query's text app_id column into uuid.UUIDs, // shared by the two GetAccountBill enumeration reads. func parseAppIDs(rows []string) ([]uuid.UUID, error) { diff --git a/internal/account/usage/types.go b/internal/account/usage/types.go index 5b0f1f6..36c3946 100644 --- a/internal/account/usage/types.go +++ b/internal/account/usage/types.go @@ -392,9 +392,10 @@ type GetAppBillResponse struct { PeriodStart time.Time `json:"period_start"` PeriodEnd time.Time `json:"period_end"` - // BaseFeeMicros is 基本費用 — the fixed per-app/period platform fee, PLUS the - // per-module surcharge for each installed module beyond IncludedModules (see - // the bill.go consts). Bundles the PaaS infra credit surfaced below. + // BaseFeeMicros is 基本費用 — the FLAT fixed per-app/period platform fee (see + // the bill.go consts). Module overage is NO LONGER folded in here: it is + // account-wide pooled (migration 030) and surfaced on GetAccountBill's + // AccountOverageMicros. Bundles the PaaS infra credit surfaced below. BaseFeeMicros int64 `json:"base_fee_micros"` // ModuleUsage is 模組使用量 — one line per (module, metric, model, @@ -551,6 +552,16 @@ type GetAccountBillResponse struct { ModuleUsageTotalMicros int64 `json:"module_usage_total_micros"` InfraTotalMicros int64 `json:"infra_total_micros"` + // AccountOverageMicros is the account-wide POOLED module overage for the + // period (migration 030): $3 × max(0, Σ live-app module_count − + // IncludedModules), charged ONCE per account (NOT per app, NOT folded into + // any Apps[].base_fee_micros). It is snapshot-first like the per-app base: + // the current period's account_overage_snapshots row when a charge leg + // billed it (the grace sweep or the boundary), else a live estimate from the + // CURRENT pooled sum. Additive field (pre-030 consumers that ignore it read + // an unchanged response shape); included in TotalMicros below. + AccountOverageMicros int64 `json:"account_overage_micros"` + // PaasCreditMicros is the ACCOUNT-level PaaS credit, applied ONCE here // (never per-app): the same ACTIVE-SaaS-subscription gate as GetAppBill's // per-app credit (v1 has no subscription system → always 0), CAPPED at @@ -558,8 +569,8 @@ type GetAccountBillResponse struct { // the same usage-only offset posture as the charge spine's allowance. PaasCreditMicros int64 `json:"paas_credit_micros"` - // TotalMicros is 最終費用 = BaseFeeTotal + ModuleUsageTotal + InfraTotal − - // PaasCredit, ≥ 0 by the credit cap. + // TotalMicros is 最終費用 = BaseFeeTotal + ModuleUsageTotal + InfraTotal + + // AccountOverage − PaasCredit, ≥ 0 by the credit cap. TotalMicros int64 `json:"total_micros"` } diff --git a/migrations/billing/030_account_wide_overage.down.sql b/migrations/billing/030_account_wide_overage.down.sql new file mode 100644 index 0000000..7163aa7 --- /dev/null +++ b/migrations/billing/030_account_wide_overage.down.sql @@ -0,0 +1,10 @@ +-- Down for 030 — drop the account-wide pooled overage timer + snapshot ledger. +-- Rolling back reverts overage to whatever the code expects: the display read +-- treats a missing snapshot as "no pooled overage charged" and a missing +-- overage_since column as never-over. Money already charged stays in +-- invoices/billing_runs (unaffected). Drop the table first, then the column. + +DROP TABLE IF EXISTS ms_billing.account_overage_snapshots; + +ALTER TABLE ms_billing.accounts + DROP COLUMN IF EXISTS overage_since; diff --git a/migrations/billing/030_account_wide_overage.up.sql b/migrations/billing/030_account_wide_overage.up.sql new file mode 100644 index 0000000..44c3554 --- /dev/null +++ b/migrations/billing/030_account_wide_overage.up.sql @@ -0,0 +1,88 @@ +-- Migration 030 — account-wide POOLED module overage (owner spec 2026-07-05, +-- confirmed reversal of the per-app overage tier). +-- +-- Base-fee v1 shipped module overage PER-APP: each app's base was +-- BaseFee + $3 × max(0, module_count − 5) +-- with 5 included modules PER APP. This migration moves the overage to an +-- ACCOUNT-WIDE POOL: one allowance of 5 included modules for the ENTIRE +-- account, charged $3/month for each module beyond the pooled 5 across ALL of +-- the account's live (non-deleted) apps. The FLAT per-app base fee ($20/app) +-- is UNCHANGED and stays per-app — only the $3/module overage math moves from +-- per-app to pooled. +-- +-- Two schema additions carry the pool + its grace timer: +-- +-- 1. accounts.overage_since — the UTC instant the account's pooled +-- SUM(module_count) over live apps FIRST crossed the included 5. NULL +-- means the account is not currently over the pool. It arms ONE grace +-- timer per ACCOUNT (not per app, not per module): when the pool first +-- exceeds 5 the timer starts; if the pool drops back to ≤5 before it +-- expires the timer is CLEARED (overage_since → NULL, no charge); if the +-- pool is still >5 after 3 days the mid-period sweep charges the pooled +-- overage prorated from grace-end to the period end. The timer is +-- recomputed by RegisterApp / SyncAppModules after every module_count +-- write (SUM(module_count) WHERE account_id = ? AND deleted_at IS NULL). +-- +-- 2. account_overage_snapshots — the ACCOUNT-scoped analogue of +-- app_base_snapshots (migration 028): one row per (account, period) that +-- FREEZES the pooled overage a charge leg actually billed for that +-- period, so the mid-period grace charge and the boundary advance leg can +-- never double-charge the same period (both consult / write this ledger, +-- keyed (account_id, period_start), the same double-charge guard +-- app_base_snapshots is for the per-app base). source records which leg +-- billed it: 'grace' (the mid-period sweep, prorated from grace-end) or +-- 'advance' (the boundary, full pooled overage for a period no sweep +-- caught) — mirroring app_base_snapshots' 'proration' vs 'advance'. +-- +-- No refunds (D1e philosophy): removing a module that drops the pool back +-- under 5 clears overage_since (stops FUTURE accrual) but never refunds +-- overage already charged this period. +-- +-- Born clean at slot 030. Companion docs update pending in +-- mirrorstack-docs/db/ms_billing/ (tables.md#account_overage_snapshots + +-- the accounts.overage_since column). + +ALTER TABLE ms_billing.accounts + ADD COLUMN overage_since TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.accounts.overage_since IS + 'UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 ' + '(account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. ' + 'Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account.'; + +CREATE TABLE IF NOT EXISTS ms_billing.account_overage_snapshots ( + -- The billing account whose pooled overage this row froze. Cascade: + -- dropping the account drops its overage charge history with it. + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + + -- The FULL anchored billing-period window this overage charge covers. + -- period_start is the display + double-charge lookup key (exact match from + -- both the mid-period sweep and the boundary leg); a grace row keys on the + -- full window's start even though its charged_micros covers only + -- [grace-end, period_end). + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + -- The pooled over-count the charge tiered on: SUM(module_count) over the + -- account's live apps MINUS the included 5, snapshotted at charge time. + over_count INT NOT NULL CHECK (over_count >= 0), + + -- The pooled overage actually billed for this account-period, integer + -- micro-dollars (NEVER float). + charged_micros BIGINT NOT NULL CHECK (charged_micros >= 0), + + -- Which leg billed it: the mid-period grace sweep ('grace', prorated from + -- grace-end) or the boundary advance leg ('advance', full pooled overage). + source TEXT NOT NULL CHECK (source IN ('grace', 'advance')), + + -- The Stripe invoice item id of the overage charge (empty/NULL for a + -- 0-cent rounded charge that recorded nothing) — audit trail only. + invoice_item_id TEXT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One pooled-overage charge per account-period — the "charged exactly + -- once" invariant, enforced at the ledger (the same guard shape as + -- app_base_snapshots' PRIMARY KEY (app_id, period_start)). + PRIMARY KEY (account_id, period_start) +); From 2c81bbc8d94908adeab02eda295b255ca456a29e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:54:14 +0800 Subject: [PATCH 04/30] fix: compare post-rounding cents, not raw micros, in IsLargeAutoCollect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe only ever charges whole cents (round-half-up, cycle.centsFromMicros). IsLargeAutoCollect compared the raw pre-rounding micros amount against the threshold, so a charge strictly between the threshold and threshold+5,000 micros (half a cent) rounds DOWN to exactly the threshold's dollar amount when actually charged, yet was still flagged "large" — contradicting the "exactly at threshold is not large" contract. Round both the charged amount and the threshold to cents (the same conversion Stripe applies) before comparing, so the flag can never disagree with the money that actually moved. Corrects one existing test (TestIsLargeAutoCollect_ZeroOverrideFlagsAny...) whose old assertion baked in the same raw-micros-vs-charged-cents mismatch (a 1-micro "charge" rounds to $0.00 actually charged, so it must not be flagged over a $0 threshold either) — documented inline as a judgment call. Co-Authored-By: Claude Opus 4.8 --- internal/account/collection/collection.go | 38 +++++++++++++++++- .../account/collection/collection_test.go | 40 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/account/collection/collection.go b/internal/account/collection/collection.go index abc1c2e..efcb30c 100644 --- a/internal/account/collection/collection.go +++ b/internal/account/collection/collection.go @@ -241,8 +241,44 @@ func ResolveAutoCollectThreshold(overrideMicros *int64) int64 { // discloses charges over $100, not a charge of exactly $100. This matches // ExceedsSpendCeiling's precise-dollar-cap reading (equal-to is not "above") and // is intentionally distinct from overCreditLimit's inclusive (>=) trust trigger. +// +// The comparison MUST happen in the SAME unit Stripe actually charges: whole +// cents, round-half-up (cycle.centsFromMicros — 1 cent = 10_000 micros). chargedMicros +// is the raw pre-cents-conversion micros value, so a chargedMicros strictly between +// the threshold and threshold+5,000 micros (half a cent) rounds DOWN to the +// threshold's own cents value when actually charged — comparing raw micros would +// flag it "large" even though the money that hit the card is IDENTICAL to the +// threshold's dollar amount. Rounding BOTH sides to cents before comparing (rather +// than comparing raw micros) makes the flag agree with the real charge by +// construction. This package cannot import cycle.centsFromMicros directly (cycle +// imports collection; that would cycle), so centsRoundHalfUp below duplicates the +// identical round-half-up rule in plain integer arithmetic. func IsLargeAutoCollect(chargedMicros int64, overrideMicros *int64) bool { - return chargedMicros > ResolveAutoCollectThreshold(overrideMicros) + chargedCents := centsRoundHalfUp(chargedMicros) + thresholdCents := centsRoundHalfUp(ResolveAutoCollectThreshold(overrideMicros)) + return chargedCents > thresholdCents +} + +// microsPerCent is the micro-dollar value of one cent (1e-2 USD = 10_000 × +// 1e-6 USD) — mirrors cycle.microsPerCent. Duplicated rather than imported: see +// the IsLargeAutoCollect doc on the import-cycle constraint. +const microsPerCent = 10_000 + +// centsRoundHalfUp converts a non-negative micro-dollar amount to whole cents, +// round-half-up — the SAME conversion Stripe amounts undergo at the charge +// boundary (cycle.centsFromMicros, which does the equivalent computation via +// big.Rat). For non-negative int64 operands and an EVEN divisor (10_000), +// floor((micros + microsPerCent/2) / microsPerCent) is exactly round-half-up +// (ties round up), so plain integer arithmetic reproduces that package's +// rounding without depending on it. Charged amounts and thresholds are +// non-negative at every call site (a successful charge, a finance-set +// threshold); a negative input is defensive-clamped to 0 rather than producing +// a sign-flipped cents value from truncating integer division. +func centsRoundHalfUp(micros int64) int64 { + if micros <= 0 { + return 0 + } + return (micros + microsPerCent/2) / microsPerCent } // ExceedsSpendCeiling reports whether the netted arrears would breach the diff --git a/internal/account/collection/collection_test.go b/internal/account/collection/collection_test.go index 1bea39a..7a45e9b 100644 --- a/internal/account/collection/collection_test.go +++ b/internal/account/collection/collection_test.go @@ -248,8 +248,44 @@ func TestIsLargeAutoCollect_CustomOverrideRespected(t *testing.T) { func TestIsLargeAutoCollect_ZeroOverrideFlagsAnyPositiveCharge(t *testing.T) { // A $0 override (a deliberately-disclose-everything account) flags any - // positive charge but not a zero charge (0 is not > 0). + // charge that rounds to a REAL, non-zero cent at the Stripe boundary — NOT + // any nonzero micro value. JUDGMENT (corrected alongside the #1 fix below): + // the previous version of this test asserted IsLargeAutoCollect(1, &zero) + // == true, i.e. "any positive micros is flagged" — but 1 micro rounds DOWN + // to $0.00 when actually charged (centsFromMicros(1) == 0), so flagging it + // as a "large" disclosure over a $0.00 threshold contradicted the very + // charge that fired. That assertion baked in the SAME raw-micros-vs- + // charged-cents mismatch as the near-$100-threshold bug; the fix corrects + // both call sites of the same root cause. 5,000 micros (exactly half a + // cent) round-half-up to $0.01 charged, which correctly IS flagged. zero := int64(0) - require.True(t, collection.IsLargeAutoCollect(1, &zero)) + require.True(t, collection.IsLargeAutoCollect(5_000, &zero), + "half a cent rounds up to a real $0.01 charge over the $0 threshold") + require.False(t, collection.IsLargeAutoCollect(4_999, &zero), + "just under half a cent rounds DOWN to $0.00 actually charged — nothing to disclose") require.False(t, collection.IsLargeAutoCollect(0, &zero)) } + +// --- regression: finding #1 (sub-cent-above-threshold false positive) ------ + +func TestIsLargeAutoCollect_SubCentAboveDefaultThresholdNotFlagged(t *testing.T) { + // FAILS without the fix: a charge of $100.00 + 100 micros ($0.0001) is + // strictly ABOVE the raw $100.00 threshold in micros, so the old + // `chargedMicros > threshold` comparison flagged it "large". But Stripe + // only ever charges whole cents (round-half-up, 1 cent = 10,000 micros): + // centsFromMicros(100_000_100) == round_half_up(10000.01) == 10000 cents, + // i.e. the SAME $100.00 that centsFromMicros(100_000_000) (the threshold + // itself) produces. The money that actually hits the card is IDENTICAL to + // the threshold's dollar amount, so the "exactly at threshold is not + // large" contract requires this NOT be flagged. + chargedMicros := collection.DefaultAutoCollectThresholdMicros + 100 // $100.0001 + require.Less(t, chargedMicros-collection.DefaultAutoCollectThresholdMicros, int64(5_000), + "fixture must stay within the half-a-cent gap the bug lived in") + require.False(t, collection.IsLargeAutoCollect(chargedMicros, nil), + "$100.0001 charges as exactly $100.00 (rounds down) — must not be flagged") + + // Sanity: once chargedMicros crosses the half-cent tie (5,000 micros above + // the threshold), it rounds up to a REAL $100.01 charge and must be flagged. + require.True(t, collection.IsLargeAutoCollect(collection.DefaultAutoCollectThresholdMicros+5_000, nil), + "$100.01 actually charged (rounds up at the tie) — must be flagged") +} From 8c103f17876fde319127033173f6f2101822140e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:54:22 +0800 Subject: [PATCH 05/30] fix: resolve auto-collect threshold at the same point in both charge legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunBillingCycle resolved the account's auto_collect_threshold_micros near the top of the function — before the risk gate, the PM check, and both Stripe HTTP calls — while RegisterApp's proration leg already resolved it AFTER its own Stripe charge succeeded. A concurrent threshold edit landing mid-charge was therefore honored inconsistently between the two call sites, even though both are documented as resolving "at charge time". RunBillingCycle now re-resolves the threshold immediately after its Stripe charge succeeds (the same point RegisterApp already used), so a race lands identically on both legs. Also adds an end-to-end regression for the earlier IsLargeAutoCollect rounding fix, proving the concrete cents Stripe is charged (not just "no error"). Co-Authored-By: Claude Opus 4.8 --- internal/account/cycle/apps.go | 12 ++- internal/account/cycle/charge.go | 18 +++- internal/account/cycle/charge_test.go | 120 ++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 8a9adf6..89290e3 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -258,9 +258,15 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg // Resolve the account's large-charge disclosure threshold AT CHARGE TIME so // the proration invoice's flag reflects what applied when it fired (migration - // 031), the same contract as the RunBillingCycle leg. A prorated app base fee - // rarely crosses the $100 default, but the flag is computed uniformly at every - // off-session charge call site so no successful large debit escapes disclosure. + // 031) — resolved HERE, immediately AFTER the Stripe calls above succeeded, + // which is the SAME point relative to the actual charge that RunBillingCycle's + // boundary leg now resolves its threshold (charge.go, re-resolved right after + // its own Stripe call succeeds rather than reusing a pre-charge snapshot). Both + // charge legs agreeing on this point means a threshold edit landing concurrently + // with a charge is honored identically by both, not one way here and another on + // the boundary leg. A prorated app base fee rarely crosses the $100 default, but + // the flag is computed uniformly at every off-session charge call site so no + // successful large debit escapes disclosure. acct, err := s.store.AccountCollection(ctx, app.AccountID) if err != nil { return nil, billing.Internal("account collection lookup failed", err) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index d7bb72c..4226907 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -295,6 +295,22 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // above) exceeds the account's threshold resolved AT CHARGE TIME (its // per-account override, or the platform default when NULL). Pure disclosure — // it changes NO charging behaviour, only surfaces the already-successful debit. + // + // The threshold is RE-RESOLVED HERE — immediately after the Stripe call + // succeeded — rather than reusing `acct` loaded at the top of this + // function (before the risk gate / PM check / the two Stripe HTTP calls + // above). Resolving up front would let a threshold edit that lands + // CONCURRENTLY with this charge be honored differently than + // RegisterApp's creation-proration leg, which resolves its threshold + // immediately after ITS Stripe charge succeeds (apps.go). Both charge + // legs now resolve at the SAME point relative to the actual charge + // (immediately after Stripe confirms success), so a concurrent edit + // mid-charge is honored identically by both, never one way on the + // boundary leg and another on the proration leg. + postChargeAcct, err := s.store.AccountCollection(ctx, accountID) + if err != nil { + return nil, billing.Internal("account collection lookup failed (post-charge threshold resolve)", err) + } if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ AccountID: accountID, StripeInvoiceID: inv.ID, @@ -304,7 +320,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri Currency: chargeCurrency, PeriodStart: periodStart, PeriodEnd: periodEnd, - IsLargeAutoCollect: collection.IsLargeAutoCollect(arrears+advanceBase, acct.AutoCollectThresholdMicros), + IsLargeAutoCollect: collection.IsLargeAutoCollect(arrears+advanceBase, postChargeAcct.AutoCollectThresholdMicros), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 171d308..2382567 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -35,6 +36,14 @@ type fakeStripe struct { // injected errors errItem error errInvoice error + + // onCreateInvoice, when set, runs INSIDE CreateInvoice right before it + // returns success — modeling a concurrent account mutation (e.g. a + // threshold edit) that lands while the real Stripe HTTP call is in + // flight, i.e. strictly AFTER any pre-charge store read the caller + // already did and strictly BEFORE any post-charge store read the caller + // does once this call returns. Used by the finding-#2 regression test. + onCreateInvoice func() } type itemCall struct { @@ -72,6 +81,9 @@ func (f *fakeStripe) CreateInvoice(_ context.Context, custID string, autoAdvance if f.errInvoice != nil { return billingstripe.Invoice{}, f.errInvoice } + if f.onCreateInvoice != nil { + f.onCreateInvoice() + } return billingstripe.Invoice{ ID: f.invoiceID, Status: f.invoiceStatus, @@ -523,3 +535,111 @@ func TestRunBillingCycle_PerAccountThresholdOverrideRespected(t *testing.T) { require.False(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, "$150 < $200 per-account override → not disclosed despite exceeding the default") } + +// TestRunBillingCycle_SubCentAboveThresholdChargesExactThresholdNotFlagged is +// the end-to-end regression for finding #1 (collection.IsLargeAutoCollect +// compared raw pre-rounding micros against the threshold instead of the SAME +// post-rounding cents Stripe actually charges). +// +// FAILS without the fix: arrears = $100.00 + 100 micros ($100.0001) is +// strictly ABOVE the raw $100,000,000-micro default threshold, so the old +// `chargedMicros > threshold` comparison flagged the mirror row "large" even +// though the money that actually hit the card — the SAME +// centsFromMicros(arrears) conversion this test asserts on the fake Stripe +// call — is EXACTLY $100.00 (round-half-up rounds 100_000_100/10_000 = +// 10000.01 DOWN to 10000 cents), identical to what a charge of exactly the +// threshold itself would produce. Proves the concrete dollar amount, not just +// "no error": the Stripe invoice item is created for precisely 10000 cents +// ($100.00), and the mirror is NOT flagged, matching the "exactly at +// threshold is not large" contract. +func TestRunBillingCycle_SubCentAboveThresholdChargesExactThresholdNotFlagged(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 100_000_100 // $100.00 + 100 micros ($0.0001) — inside the half-cent gap + store.hasPM = true + store.stripeCustomer = "cus_subcent" + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 10000, sc.itemCalls[0].amountCfg, + "Stripe is charged exactly 10000 cents ($100.00), not $100.01 — the same amount charging exactly the threshold would produce") + require.EqualValues(t, 10000, resp.ChargedCents) + + require.False(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, + "a charge that rounds down to EXACTLY the $100 threshold must not be disclosed as large") +} + +// --- regression: finding #2 (threshold resolved at different points relative +// to the charge in RunBillingCycle vs. RegisterApp) ------------------------- +// +// Both tests below charge $150 while a concurrent threshold edit ($100 +// default → $200 override) lands DURING the Stripe CreateInvoice HTTP call — +// i.e. strictly after any pre-charge store read and strictly before any +// post-charge store read. Both call sites must resolve the SAME way (the +// edit that landed mid-charge is picked up), matching the "resolved at charge +// time" contract identically on both legs. +// +// FAILS without the fix: RunBillingCycle read `acct` (and its +// AutoCollectThresholdMicros) at the TOP of the function — before the risk +// gate, the PM check, and both Stripe HTTP calls — so it never observes the +// edit and still uses the stale $100 default, flagging the $150 charge as +// large. RegisterApp, by contrast, already re-resolves the threshold AFTER +// its Stripe call succeeds, so it picks up the new $200 override and does +// NOT flag the same $150 charge. That asymmetry — same race, different +// outcome depending on which leg charged — is exactly what this test +// forbids. + +func TestRunBillingCycle_ConcurrentThresholdEditMidChargeResolvesPostCharge(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 150_000_000 // $150: over the $100 default, under a $200 override + store.hasPM = true + store.stripeCustomer = "cus_race_boundary" + sc := newFakeStripe() + sc.onCreateInvoice = func() { + // The concurrent edit: an operator (or the account owner) raises the + // disclosure threshold to $200 WHILE this charge's Stripe call is in + // flight. + override := int64(200_000_000) + store.collection.AutoCollectThresholdMicros = &override + } + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 15000, resp.ChargedCents, "still charges $150 — the edit only affects disclosure, never the amount") + require.False(t, store.invoices[resp.StripeInvoiceID].IsLargeAutoCollect, + "the threshold is resolved AFTER the Stripe charge succeeds, so the mid-charge $200 edit governs — $150 is not flagged") +} + +func TestRegisterApp_ConcurrentThresholdEditMidChargeResolvesPostCharge(t *testing.T) { + store := newFakeStore() + user, acct := registeredAccount(store) + sc := newFakeStripe() + sc.onCreateInvoice = func() { + override := int64(200_000_000) + store.collection.AutoCollectThresholdMicros = &override + } + appID := uuid.New() + + // module_count=49 → base = $20 + 44×$3 = $152 (44 = 49 − IncludedModules(5) + // over-the-included modules). CreatedAt is exactly the anchored period's + // start (June 4, matching registeredAccount's anchor-4 activation), so + // ProratedBaseMicros charges the FULL $152 base (no proration dampening), + // landing between the $100 stale default and the $200 post-edit override — + // the same $100–$200 straddle the RunBillingCycle test above proves. + resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, + AppID: appID, + ModuleCount: 49, + CreatedAt: time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Equal(t, acct, resp.AccountID) + require.NotEmpty(t, resp.ProrationInvoiceID) + + require.False(t, store.invoices[resp.ProrationInvoiceID].IsLargeAutoCollect, + "the threshold is resolved AFTER the Stripe charge succeeds on this leg too, so the mid-charge $200 edit governs — matches RunBillingCycle's resolution point") +} From ee5043ced1fdb0b5651eaf74bc66ff943d6219b2 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 07:10:29 +0800 Subject: [PATCH 06/30] fix: account-overage cross-leg double-charge + retry livelock (review findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #47 adversarial review found the grace sweep and the boundary leg could both charge the same period's pooled overage if a crash landed between a successful Stripe call and the account_overage_snapshots write (disjoint Idempotency-Key namespaces, so Stripe never deduped it). Adds a status column ('pending'/'charged') to account_overage_snapshots: the claim is now written BEFORE either leg calls Stripe, so a crash anywhere leaves durable evidence the other leg must respect. Also fixes the boundary reclaim livelock (a reclaimed run recomputed the overage to 0 instead of reusing the frozen amount, colliding with its own stable Idempotency-Key) and threads the genuine Stripe invoice item id back from the boundary's combined charge instead of storing the idempotency-key string. Judgment call (no product decision available): pooled-overage growth after a period's grace charge now gets an incremental top-up, conservatively prorated from the sweep's own instant forward (never retroactive) — flagged in cycle/overage.go's topUpGraceOverage doc for owner review. Co-Authored-By: Claude Opus 4.8 --- cmd/billing-cycle/main.go | 4 +- internal/account/cycle/charge.go | 149 +++++++--- internal/account/cycle/overage.go | 277 ++++++++++++++++-- internal/account/cycle/overage_test.go | 200 +++++++++++++ internal/account/cycle/service_test.go | 49 +++- internal/account/cycle/store.go | 90 ++++-- internal/account/db/models.go | 1 + internal/account/db/overage.sql.go | 111 +++++-- internal/account/db/queries/overage.sql | 69 ++++- .../billing/030_account_wide_overage.up.sql | 16 +- 10 files changed, 833 insertions(+), 133 deletions(-) diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index 64daec6..63790e4 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -262,7 +262,9 @@ func runOverageSweep(ctx context.Context, svc *cycle.Service, at time.Time, res res.Failed++ continue } - if summary.Status == cycle.OverageCharged { + 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++ diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index a562cc4..6999236 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -160,20 +160,46 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // SEPARATE account-level POOLED overage term for the closing period, charged // ONCE per account. If this period's pooled overage already has an - // account_overage_snapshots row (the mid-period grace sweep billed it), it is - // NOT charged again here — the ledger is the double-charge guard. Otherwise - // (grace never expired within the period, or overage started too late for the - // sweep to run) the boundary charges the FULL pooled overage for the account - // and writes its own snapshot (source='advance'), so every over-the-pool - // period is billed exactly once. - _, overageAlreadyBilled, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) + // account_overage_snapshots row, its SOURCE decides how the boundary treats + // it — the cross-leg double-charge guard (PR #47 review): + // + // - source='grace': the mid-period sweep already CLAIMED it (pending or + // charged — a crash between Stripe succeeding and the row flipping to + // 'charged' must NOT let the boundary independently charge it too, so + // ANY row, not just a settled one, excludes it here); + // - source='advance': THIS boundary run's OWN prior attempt (a crash + // between claiming the period and MarkBillingRun succeeding reclaims the + // SAME run id) — reuse the FROZEN over_count/charged_micros so the retry + // recomputes the IDENTICAL combined total and the deterministic + // ii-/inv- Idempotency-Keys never see a different amount + // (finding #2 — the boundary retry livelock). + // + // No row yet (grace never expired within the period, or overage started too + // late for the sweep to run): compute the FULL pooled overage for the + // account; it is CLAIMED (a 'pending' row written BEFORE Stripe is called) + // further down, right before the charge — so every over-the-pool period is + // billed exactly once, crash-safe. + existingOverage, overageFound, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) if err != nil { return nil, billing.Internal("account overage snapshot lookup failed", err) } - var advanceOverage int64 - overCount := pooledModuleCount - usage.IncludedModules - if !overageAlreadyBilled && overCount > 0 { - advanceOverage = usage.AccountOverageMicros(pooledModuleCount) + var ( + advanceOverage int64 + overCount int + overageAlreadyClaimed bool // true → no fresh claim-insert needed before charging + ) + switch { + case overageFound && existingOverage.Source == "grace": + overageAlreadyClaimed = true + case overageFound && existingOverage.Source == "advance": + advanceOverage = existingOverage.ChargedMicros + overCount = existingOverage.OverCount + overageAlreadyClaimed = true + default: + overCount = pooledModuleCount - usage.IncludedModules + if overCount > 0 { + advanceOverage = usage.AccountOverageMicros(pooledModuleCount) + } } summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase, AccountOverageMicros: advanceOverage} @@ -296,6 +322,40 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri _, newPeriodEnd = billingperiod.AnchoredPeriodWindow(periodEnd, anchorDay) } + // CLAIM the boundary's pooled-overage line BEFORE calling Stripe (the + // crash-safe marker — cycle/overage.go's header). Skipped when there is no + // overage to charge, or it is already claimed (source='grace' owns it, or + // this run's own reclaimed retry is reusing its own prior 'advance' claim). + if advanceOverage > 0 && !overageAlreadyClaimed { + inserted, err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ + AccountID: accountID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + OverCount: overCount, + ChargedMicros: advanceOverage, + Source: "advance", + Status: OverageSnapshotPending, + }) + if err != nil { + return nil, billing.Internal("account overage snapshot claim failed", err) + } + if !inserted { + // Lost the race to a concurrent claim (almost certainly the mid-period + // grace sweep) — re-read and defer to the winner: never charge an + // overage amount someone else already claimed. + winning, _, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) + if err != nil { + return nil, billing.Internal("account overage snapshot lookup failed", err) + } + advanceOverage, overCount = 0, 0 + if winning.Source == "advance" { + advanceOverage, overCount = winning.ChargedMicros, winning.OverCount + } + overageAlreadyClaimed = true + } + } + summary.AccountOverageMicros = advanceOverage + // One invoice, one pooled line: closed period's netted usage arrears + the // new period's advance base + the closing period's account-wide pooled // overage, converted micros → whole cents ONCE at the Stripe boundary (a @@ -304,11 +364,20 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } + if cents == 0 { + // The claim-race adjustment above dropped the combined total to zero (a + // narrow concurrent-invocation edge case) — never call Stripe for $0. + if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { + return nil, billing.Internal("mark billing run (zero after overage claim race) failed", err) + } + summary.Status = RunStatusInvoiced + return summary, nil + } summary.ChargedCents = cents // Charge. A failure after the PM gate marks the run 'failed' (auditable) and // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0 || advanceOverage > 0) + inv, item, err := s.charge(ctx, runID, custID, cents, advanceBase > 0 || advanceOverage > 0) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -332,6 +401,17 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.Internal("invoice mirror upsert failed", err) } + // Flip the overage claim to 'charged' now that Stripe confirmed it, using + // the GENUINE Stripe invoice item id `item.ID` — never the ii- + // idempotency-key string (finding #4). Covers both a fresh claim (won + // above) and a reused prior 'advance' claim (a reclaimed retry) — both + // need the row settled once this combined charge succeeds. + if advanceOverage > 0 { + if err := s.store.MarkAccountOverageSnapshotCharged(ctx, accountID, periodStart, item.ID); err != nil { + return nil, billing.Internal("account overage snapshot mark-charged failed", err) + } + } + // Freeze what this boundary actually billed per app for the NEW window // (migration 028, source='advance'): the display's authoritative base for // the period, so a later SyncAppModules can never drift the shown base @@ -351,26 +431,6 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } } - // Freeze the account-wide pooled overage this boundary billed for the CLOSING - // period (migration 030, source='advance') so the mid-period sweep + display - // agree it is already billed — the double-charge guard. Keyed by the closing - // period_start; ON CONFLICT DO NOTHING (a mid-period 'grace' row wins if the - // race ever writes both). Skipped when nothing was billed (already billed - // mid-period, or the account was under the pool). - if advanceOverage > 0 { - if err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ - AccountID: accountID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - OverCount: overCount, - ChargedMicros: advanceOverage, - Source: "advance", - InvoiceItemID: invoiceItemIdemKey(runID), // the boundary's pooled item id (the run's ii- key) - }); err != nil { - return nil, billing.Internal("account overage snapshot insert failed", err) - } - } - if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, inv.ID, cents); err != nil { return nil, billing.Internal("mark billing run (invoiced) failed", err) } @@ -381,21 +441,26 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } // charge creates the Stripe invoice item + draft invoice for the boundary -// total (usage arrears + advance base), with the two deterministic -// Idempotency-Keys (ii-, inv-) so a re-run reuses the same Stripe -// objects. withBase only widens the line DESCRIPTION when the total includes -// an advance base fee — a pure-usage invoice keeps the historical line text. -// Returns the created invoice projection (id/status/amounts) for the mirror -// upsert. -func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { +// total (usage arrears + advance base + pooled overage), with the two +// deterministic Idempotency-Keys (ii-, inv-) so a re-run reuses the +// same Stripe objects. withBase only widens the line DESCRIPTION when the +// total includes an advance base fee — a pure-usage invoice keeps the +// historical line text. Returns the created invoice projection (id/status/ +// amounts) for the mirror upsert AND the created invoice item (the caller +// needs its GENUINE id to freeze into account_overage_snapshots when the +// combined line includes pooled overage — finding #4 — rather than the +// idempotency-key string the item was created with). +func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, billingstripe.InvoiceItem, error) { desc := fmt.Sprintf("MirrorStack usage — run %s", runID) if withBase { desc = fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) } - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { - return billingstripe.Invoice{}, err + item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)) + if err != nil { + return billingstripe.Invoice{}, billingstripe.InvoiceItem{}, err } - return s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) + return inv, item, err } // AccountsWithUsageEvents returns the accounts with raw usage_events in the diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index d1ca569..1f1ddfc 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -20,6 +20,18 @@ package cycle // the account_overage_snapshots ledger (keyed (account_id, period_start)) plus // the deterministic per-(account, period) Stripe Idempotency-Keys below — the // same money-safety pattern app_base_snapshots gives the per-app base. +// +// CRASH-SAFETY (PR #47 review — the cross-leg double-charge finding): the +// ledger row is written as a claim BEFORE either leg calls Stripe +// (status='pending'; see migration 030), not only after Stripe succeeds. A +// crash between "Stripe succeeded" and "the row committed" used to leave NO +// row for the OTHER leg to see, so it would independently charge the SAME +// period's overage under a completely disjoint Idempotency-Key namespace — a +// real double charge. Now the claim is visible to both legs the instant it is +// written, before any money moves, so a crash anywhere in the sequence leaves +// evidence the other leg (and the same leg's own retry) can see and must +// respect: ANY row for the period — pending or charged — means "claimed, +// never independently charge it". import ( "context" @@ -65,6 +77,10 @@ const ( // on the next sweep (the SAME per-(account, period) Stripe idem key stays // stable), never a failure. OverageSkippedNoPM OverageChargeStatus = "skipped_no_pm" + // OverageToppedUp: an ALREADY-charged period's pool grew further while the + // period was still open, and the sweep charged the incremental delta + // (finding #3, a judgment call — see topUpGraceOverage's doc). + OverageToppedUp OverageChargeStatus = "topped_up" ) // OverageChargeSummary reports what one ChargeAccountOverage call did. @@ -118,21 +134,33 @@ func (s *Service) recomputeAccountOverage(ctx context.Context, accountID uuid.UU // // 1. resolves the account's CURRENT anchored period (from activated_at) and the // grace-end instant (overage_since + overageGraceWindow); -// 2. skips if this period's pooled overage already has a snapshot (a prior -// sweep or the boundary billed it — the double-charge guard); -// 3. reads the CURRENT pool; if it dropped back to ≤ IncludedModules, clears -// the timer and skips (no refund of anything already charged); -// 4. prices the pooled overage PRORATED from grace-end to the period end -// (ProratedOverageMicros) → whole cents at the Stripe boundary; a 0-cent -// result skips (grace ends at/after the period end); -// 5. charges via the SAME Stripe plumbing as the other legs with the -// deterministic per-(account, period) Idempotency-Keys, mirrors the invoice, -// and freezes the account_overage_snapshots row (source='grace'). +// 2. reads this period's account_overage_snapshots row, if any (the +// double-charge guard): +// - source='advance' (the boundary claimed it, pending or charged) → +// skip, never independently charge it; +// - source='grace', status='charged' → already settled; the only further +// work is a possible TOP-UP if the pool grew further within this SAME +// still-open period (topUpGraceOverage, finding #3); +// - source='grace', status='pending' → THIS leg's own attempt died +// between claiming the period and Stripe confirming (or before Stripe +// was ever called) — resume it, reusing the FROZEN over_count / +// charged_micros so the retry recomputes the IDENTICAL amount and the +// deterministic Idempotency-Key stays valid; +// 3. otherwise (no row): reads the CURRENT pool — ≤ IncludedModules clears the +// timer and skips (no refund of anything already charged, D1e); prices the +// pooled overage PRORATED from grace-end to the period end +// (ProratedOverageMicros) → whole cents; a 0-cent result skips (grace ends +// at/after the period end); +// 4. CLAIMS the period (a 'pending' account_overage_snapshots row) BEFORE +// calling Stripe — the crash-safe marker described in this file's header — +// then charges via the SAME Stripe plumbing as the other legs with the +// deterministic per-(account, period) Idempotency-Keys, mirrors the +// invoice, and flips the row to 'charged' with the genuine Stripe item id. // // Gated on a usable default PM exactly like the spine (the candidate is already -// activated). A failure after the charge leaves no snapshot; the next sweep -// re-attempts through the SAME idem key (Stripe dedupes) — retry-safe, never a -// double charge. +// activated). A failure after the claim leaves the row 'pending'; the next +// sweep resumes through the SAME idem key (Stripe dedupes) — retry-safe, never +// a double charge. func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCandidate, at time.Time) (*OverageChargeSummary, error) { if cand.ID == uuid.Nil { return nil, billing.InvalidInput("account_id required") @@ -149,15 +177,24 @@ func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCan anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(at.UTC(), anchorDay) - summary := &OverageChargeSummary{PeriodStart: periodStart} - // Double-charge guard: this period's pooled overage was already billed (by a - // prior sweep run OR the boundary that closed a prior period into this one). - if _, snapped, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart); err != nil { + existing, found, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart) + if err != nil { return nil, billing.Internal("account overage snapshot lookup failed", err) - } else if snapped { - summary.Status = OverageSkippedAlreadyCharged - return summary, nil + } + if found { + if existing.Source == "advance" { + // The boundary claimed (pending or charged) this period's overage — + // the mid-period sweep must NEVER independently charge it, crash + // window or not (the cross-leg double-charge guard, PR #47 review). + return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil + } + if existing.Status == OverageSnapshotCharged { + return s.topUpGraceOverage(ctx, cand, existing, at, periodStart, periodEnd) + } + // existing.Status == pending, existing.Source == "grace": resume OUR + // own crashed attempt, reusing the frozen amount. + return s.completeGraceCharge(ctx, cand, existing.OverCount, existing.ChargedMicros, graceEnd, periodStart, periodEnd, true /* alreadyClaimed */) } pooled, err := s.store.PooledModuleCount(ctx, cand.ID) @@ -171,10 +208,8 @@ func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCan if err := s.store.ClearAccountOverage(ctx, cand.ID); err != nil { return nil, billing.Internal("clear account overage timer failed", err) } - summary.Status = OverageSkippedUnderPool - return summary, nil + return &OverageChargeSummary{Status: OverageSkippedUnderPool, PeriodStart: periodStart}, nil } - summary.OverCount = overCount // Prorate the pooled overage from grace-end to the period end. A 0-cent // result (grace ends at/after this period's end) means nothing to bill this @@ -185,8 +220,24 @@ func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCan return nil, billing.Internal("micros to cents conversion failed", err) } if cents == 0 { - summary.Status = OverageSkippedZeroCents - return summary, nil + return &OverageChargeSummary{Status: OverageSkippedZeroCents, PeriodStart: periodStart, OverCount: overCount}, nil + } + + return s.completeGraceCharge(ctx, cand, overCount, proratedMicros, graceEnd, periodStart, periodEnd, false /* alreadyClaimed */) +} + +// completeGraceCharge runs the PM/customer gate + the actual Stripe charge for +// the mid-period grace leg, given an already-resolved (overCount, +// chargedMicros) — either freshly computed (alreadyClaimed=false: this call +// still needs to CLAIM the period before Stripe) or reused from an existing +// 'pending' row (alreadyClaimed=true: THIS leg already claimed it on a prior +// attempt; resume straight to the PM/Stripe steps with the SAME frozen amount +// so the deterministic Idempotency-Key never sees a different total). +func (s *Service) completeGraceCharge(ctx context.Context, cand OverageGraceCandidate, overCount int, chargedMicros int64, graceEnd, periodStart, periodEnd time.Time, alreadyClaimed bool) (*OverageChargeSummary, error) { + summary := &OverageChargeSummary{PeriodStart: periodStart, OverCount: overCount} + cents, err := centsFromMicros(chargedMicros) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) } summary.ChargedCents = cents @@ -206,6 +257,28 @@ func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCan return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) } + if !alreadyClaimed { + // CLAIM the period BEFORE calling Stripe (the crash-safe marker — see + // this file's header). inserted=false means we lost a race to a + // concurrent claim (another sweep invocation, or the boundary) — defer + // to the winner instead of charging under our own stale claim. + inserted, err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ + AccountID: cand.ID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + OverCount: overCount, + ChargedMicros: chargedMicros, + Source: "grace", + Status: OverageSnapshotPending, + }) + if err != nil { + return nil, billing.Internal("account overage snapshot claim failed", err) + } + if !inserted { + return s.resumeClaimedOverage(ctx, cand, graceEnd, periodStart, periodEnd) + } + } + desc := fmt.Sprintf("MirrorStack module overage (account pool, %d over) — account %s", overCount, cand.ID) item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, accountOverageItemIdemKey(cand.ID, periodStart)) if err != nil { @@ -232,22 +305,148 @@ func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCan return nil, billing.Internal("invoice mirror upsert failed", err) } - // Freeze the ledger row keyed by the FULL anchored period_start (the display + - // double-charge identity) — source='grace', the prorated amount actually - // invoiced. ON CONFLICT DO NOTHING makes a retry idempotent. - if err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ + // Flip the claim to 'charged' now that Stripe confirmed it, recording the + // GENUINE Stripe invoice item id (never an idempotency-key string). + if err := s.store.MarkAccountOverageSnapshotCharged(ctx, cand.ID, periodStart, item.ID); err != nil { + return nil, billing.Internal("account overage snapshot mark-charged failed", err) + } + + summary.Status = OverageCharged + summary.StripeInvoiceID = inv.ID + return summary, nil +} + +// resumeClaimedOverage handles the (rare, only-possible-under-true-concurrency) +// case where completeGraceCharge's own claim-insert LOST a race: by the time it +// tried to claim the period, some OTHER caller (a concurrent sweep invocation, +// or the boundary) had already inserted a row first. Re-reads the winning row +// and defers to it — an 'advance' winner means the boundary owns this period +// (skip); a 'grace' winner that is already 'charged' means another sweep +// invocation finished first (skip); a 'grace' winner still 'pending' means +// another invocation is mid-flight — resume IT (same frozen amount). +func (s *Service) resumeClaimedOverage(ctx context.Context, cand OverageGraceCandidate, graceEnd, periodStart, periodEnd time.Time) (*OverageChargeSummary, error) { + existing, found, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart) + if err != nil { + return nil, billing.Internal("account overage snapshot lookup failed", err) + } + if !found { + // The conflict that sent us here proves a row exists; a missing read + // immediately after is a driver/consistency anomaly. + return nil, billing.Internal("account overage snapshot claim lost but no row found on re-read", nil) + } + if existing.Source == "advance" || existing.Status == OverageSnapshotCharged { + return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil + } + return s.completeGraceCharge(ctx, cand, existing.OverCount, existing.ChargedMicros, graceEnd, periodStart, periodEnd, true) +} + +// topUpGraceOverage is finding #3's fix — A JUDGMENT CALL (PR #47 review): no +// product decision was available on the exact top-up policy, so this +// implements the safest technically-correct interpretation, flagged here and +// in the PR description. recomputeAccountOverage only ever arms/clears the +// grace timer; it never re-evaluates an ALREADY-CHARGED period's snapshot, so +// pooled-module growth after the period's first grace charge went permanently +// unbilled for that period (and the display, which is snapshot-first, never +// reflected it either). Because the timer stays armed until the pool drops +// back under the pool (recomputeAccountOverage), the sweep keeps re-invoking +// ChargeAccountOverage for this account on every pass — so THIS function now +// re-checks a 'charged' period's current pool against what was billed and, if +// it grew, charges the INCREMENTAL delta, conservatively prorated from THIS +// sweep's instant (`at`) to the period end — never retroactively for time +// before this sweep noticed the growth (the safest, never-overcharge +// interpretation; it may under-bill by the gap between the actual install and +// the next sweep tick, which is bounded by the sweep's cron interval). +// +// A pool that merely dropped (or stayed the same) since the last charge is +// D1e (no refund) — this only ever ADDS to what is billed, never subtracts. +// The top-up's own Idempotency-Keys are derived from the TARGET cumulative +// over-count, so a crash-and-retry of the SAME top-up (pool unchanged since) +// reuses the SAME Stripe objects; if the pool grows AGAIN before this one +// resolves, the next pass computes a NEW target and a NEW (legitimately +// different) key — never a collision with the one still in flight. +func (s *Service) topUpGraceOverage(ctx context.Context, cand OverageGraceCandidate, existing AccountOverageSnapshot, at, periodStart, periodEnd time.Time) (*OverageChargeSummary, error) { + pooled, err := s.store.PooledModuleCount(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("pooled module count lookup failed", err) + } + overCount := pooled - usage.IncludedModules + if overCount <= existing.OverCount { + if overCount <= 0 { + if err := s.store.ClearAccountOverage(ctx, cand.ID); err != nil { + return nil, billing.Internal("clear account overage timer failed", err) + } + } + return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil + } + + deltaOverCount := overCount - existing.OverCount + deltaFullMicros := usage.ModuleOverageFeeMicros * int64(deltaOverCount) + deltaMicros := usage.ProratedOverageMicros(deltaFullMicros, at, periodStart, periodEnd) + deltaCents, err := centsFromMicros(deltaMicros) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) + } + summary := &OverageChargeSummary{PeriodStart: periodStart, OverCount: overCount} + if deltaCents == 0 { + summary.Status = OverageSkippedZeroCents + return summary, nil + } + summary.ChargedCents = deltaCents + + hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("usable PM check failed", err) + } + if !hasPM { + summary.Status = OverageSkippedNoPM + return summary, nil // retained; re-attempted next sweep through the same idem key + } + custID, err := s.store.AccountStripeCustomer(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) + } + + desc := fmt.Sprintf("MirrorStack module overage top-up (account pool, %d over, +%d) — account %s", overCount, deltaOverCount, cand.ID) + item, err := s.stripe.CreateInvoiceItem(ctx, custID, deltaCents, chargeCurrency, desc, accountOverageTopUpItemIdemKey(cand.ID, periodStart, overCount)) + if err != nil { + return nil, billing.StripeError("overage top-up invoice item failed", err) + } + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, accountOverageTopUpInvoiceIdemKey(cand.ID, periodStart, overCount)) + if err != nil { + return nil, billing.StripeError("overage top-up invoice failed", err) + } + + if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ + AccountID: cand.ID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + // The incremental coverage starts at THIS sweep's instant (the + // conservative proration basis above), never retroactively. + PeriodStart: usage.ProrationCoverageStart(at, periodStart), + PeriodEnd: periodEnd, + }); err != nil { + return nil, billing.Internal("invoice mirror upsert failed", err) + } + + if err := s.store.TopUpAccountOverageSnapshot(ctx, AccountOverageSnapshot{ AccountID: cand.ID, PeriodStart: periodStart, PeriodEnd: periodEnd, OverCount: overCount, - ChargedMicros: proratedMicros, + ChargedMicros: existing.ChargedMicros + deltaMicros, // cumulative total actually billed for the period Source: "grace", InvoiceItemID: item.ID, }); err != nil { - return nil, billing.Internal("account overage snapshot insert failed", err) + return nil, billing.Internal("account overage snapshot top-up failed", err) } - summary.Status = OverageCharged + summary.Status = OverageToppedUp summary.StripeInvoiceID = inv.ID return summary, nil } @@ -266,3 +465,17 @@ func accountOverageItemIdemKey(accountID uuid.UUID, periodStart time.Time) strin func accountOverageInvoiceIdemKey(accountID uuid.UUID, periodStart time.Time) string { return "acct-overage-inv-" + accountID.String() + "-" + strconv.FormatInt(periodStart.UTC().Unix(), 10) } + +// accountOverageTopUpItemIdemKey / accountOverageTopUpInvoiceIdemKey build the +// deterministic Stripe Idempotency-Keys for an INCREMENTAL top-up charge +// (finding #3), keyed additionally by the TARGET cumulative over-count so a +// retry of the SAME top-up (pool unchanged since) reuses the SAME Stripe +// objects, while a further pool change before it resolves computes a NEW +// (legitimately distinct) key rather than colliding with the one in flight. +func accountOverageTopUpItemIdemKey(accountID uuid.UUID, periodStart time.Time, targetOverCount int) string { + return accountOverageItemIdemKey(accountID, periodStart) + "-topup-" + strconv.Itoa(targetOverCount) +} + +func accountOverageTopUpInvoiceIdemKey(accountID uuid.UUID, periodStart time.Time, targetOverCount int) string { + return accountOverageInvoiceIdemKey(accountID, periodStart) + "-topup-" + strconv.Itoa(targetOverCount) +} diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 3bcbd56..6a3297e 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -8,6 +8,8 @@ package cycle_test import ( "context" + "errors" + "strings" "testing" "time" @@ -332,3 +334,201 @@ func TestAccountOverageMicros_IsPooledNotPerApp(t *testing.T) { // whole point of the reversal. require.EqualValues(t, 9_000_000, usage.AccountOverageMicros(4+4)) } + +// --- PR #47 review fixes: regression tests ---------------------------------- + +// Finding #1 [CRITICAL] — cross-leg double charge. Before the fix, +// ChargeAccountOverage called Stripe BEFORE writing account_overage_snapshots; +// a crash between "Stripe succeeded" and "the row committed" left NO row for +// the boundary to see, so it independently charged the FULL pooled overage +// again under a disjoint Idempotency-Key namespace — a real double charge. +// The fix claims the period (a 'pending' row) BEFORE calling Stripe, so the +// claim survives any crash after that point. This test simulates the crash by +// injecting a failure at the LAST step (flipping 'pending' → 'charged') — +// AFTER Stripe already succeeded — and proves the boundary that runs next +// still sees the claim and does NOT charge the overage a second time. +func TestChargeAccountOverage_CrashAfterStripeSuccess_BoundaryDoesNotDoubleCharge(t *testing.T) { + at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // graceEnd May 31 < Jun 1 → full overage this period + store := newFakeStore() + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + acct := armedOverageAccount(store, 2, 4, since, jun1) // pool 8 → 3 over → $9.00 pooled overage + sc := newFakeStripe() + svc := overageSvc(store, sc, at) + cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + + // Simulate the crash: Stripe already confirmed the charge (item + invoice + // calls below prove it), but the LAST write — flipping the claim row from + // 'pending' to 'charged' — fails, exactly like a Lambda dying right there. + store.errMarkOverageSnap = errors.New("boom: process died before the claim flipped to charged") + _, err := svc.ChargeAccountOverage(context.Background(), cand, at) + require.Error(t, err, "the crash surfaces as an error to the caller (the Lambda dies / retries)") + + // Stripe's charge already went through before the crash. + require.Len(t, sc.invoiceCalls, 1, "the grace leg's Stripe call already succeeded before the simulated crash") + require.EqualValues(t, 900, sc.itemCalls[0].amountCfg) + + // CRITICAL: the claim row survives the crash (it was written BEFORE Stripe, + // not after) — this is the durable evidence the OTHER leg must respect. + snap, ok := store.overageSnaps[acctSnapKey{acct, jun1}] + require.True(t, ok, "the pending claim row must survive the crash") + require.Equal(t, "grace", snap.Source) + + // The BOUNDARY now closes [Jun 1, Jul 1). WITHOUT the fix (no row would + // exist at this point), it would independently charge the FULL $9.00 + // pooled overage AGAIN under its own ii- Idempotency-Key — a real + // double charge totaling $18.00 for a $9.00 debt. WITH the fix, it must see + // the claim and charge ZERO overage. + store.errMarkOverageSnap = nil // the injected fault was specific to the grace leg's crash + resp, err := svc.RunBillingCycle(context.Background(), acct, jun1, jul1, 0) + require.NoError(t, err) + require.EqualValues(t, 0, resp.AccountOverageMicros, + "the boundary must NOT independently charge the pooled overage the grace leg already claimed, crash or not") + require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros, "the flat per-app base is unaffected") + require.EqualValues(t, 4_000, resp.ChargedCents, "base only ($40.00) — NOT $49.00, which would be the double charge") + + // Exactly TWO invoices total: the grace leg's original $9.00 overage charge + // + the boundary's base-only invoice. Never a second overage charge. + require.Len(t, sc.invoiceCalls, 2) +} + +// Finding #2 [HIGH] — boundary retry livelock. Before the fix, a reclaim of a +// 'pending' billing_run (after InsertAccountOverageSnapshot succeeded but +// MarkBillingRun crashed) recomputed advanceOverage FRESH from +// snapshot-presence, collapsing it from a real amount to $0 — a DIFFERENT +// combined total reusing the SAME deterministic Stripe Idempotency-Key, which +// a real Stripe would reject (a mismatched-parameter idempotency-key reuse), +// permanently stuck. The fix freezes the overage amount into the +// account_overage_snapshots row at the FIRST attempt and REUSES it verbatim on +// every reclaim of the same run. +func TestRunBillingCycle_ReclaimAfterMarkBillingRunFailure_KeepsStableOverageAmount(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 0 + store.hasPM = true + store.stripeCustomer = "cus_reclaim_overage" + seedApp(store, chargeAccount, 4, false) + seedApp(store, chargeAccount, 4, false) // pool 8 → 3 over → $9.00 pooled overage + sc := newFakeStripe() + svc := chargeSvc(store, sc) + + // Attempt #1: the combined charge (base $40.00 + overage $9.00 = $49.00 = + // 4_900 cents) succeeds at Stripe and the overage snapshot is claimed + + // marked 'charged' — but MarkBillingRun then fails/crashes, so the run row + // stays 'pending' (non-terminal). + store.errMarkRun = errors.New("boom: process died before the run's terminal write landed") + _, err := svc.RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Len(t, sc.invoiceCalls, 1) + require.EqualValues(t, 4_900, sc.itemCalls[0].amountCfg) + + snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] + require.True(t, ok) + require.Equal(t, "advance", snap.Source) + require.Equal(t, cycle.OverageSnapshotCharged, snap.Status) + require.EqualValues(t, 9_000_000, snap.ChargedMicros) + + // Attempt #2: RECLAIM — InsertBillingRun reuses the SAME run id (the row is + // still 'pending'). WITHOUT the fix, advanceOverage recomputes to $0 (the + // snapshot "looks already billed" from the boundary's own prior write), + // giving a DIFFERENT combined total ($40.00 = 4_000 cents) reusing the SAME + // Idempotency-Key — a real Stripe rejects this and the run is stuck + // forever. WITH the fix, the overage amount is FROZEN and reused, so + // attempt #2 computes the IDENTICAL $49.00 = 4_900 cents. + store.errMarkRun = nil + resp, err := svc.RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.True(t, resp.FirstRun, "the pending run is reclaimed for a fresh attempt") + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 9_000_000, resp.AccountOverageMicros, + "the overage amount must stay stable across the reclaim retry, never recompute to 0") + require.EqualValues(t, 4_900, resp.ChargedCents, "the SAME combined total as attempt #1 — never 4_000 (base-only)") + require.Len(t, sc.invoiceCalls, 2, "the reclaim re-calls Stripe with the SAME idem key (safe/idempotent for the real client)") + require.EqualValues(t, 4_900, sc.itemCalls[1].amountCfg, "attempt #2's item amount matches attempt #1's exactly") + require.Equal(t, sc.itemCalls[0].idemKey, sc.itemCalls[1].idemKey, "the SAME deterministic ii- key is reused across the reclaim") + require.Len(t, store.insertedRuns, 1, "reclaim reuses the same run row, never a second one") +} + +// Finding #3 [MEDIUM, judgment call] — pooled-overage growth after the +// period's grace charge went permanently unbilled (recomputeAccountOverage +// only arms/clears the timer; it never re-evaluated an already-charged +// period). The fix charges an INCREMENTAL top-up, conservatively prorated from +// the sweep's own instant to the period end (never retroactively). +func TestChargeAccountOverage_PoolGrowthMidPeriodChargesIncrementalTopUp(t *testing.T) { + // First charge: pool 8 (2 apps × 4) → 3 over → $9.00 (900 cents), the exact + // fixture TestChargeAccountOverage_ChargesFullWhenGraceEndedBeforePeriodStart + // uses. Then a THIRD app (4 modules) installs mid-period, growing the pool + // to 12 → 7 over. A later sweep pass (Jun 20 — 11 days left of the 30-day + // [Jun 1, Jul 1) period) must charge the INCREMENTAL 4-module delta + // prorated for the remaining 11 days: 4 × $3.00 × 11/30 = $4.40 (440 + // cents) — never $0 (the pre-fix behavior, permanently unbilled) and never + // the full $12.00 (never retroactive for time before the sweep noticed). + at1 := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) + since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) + store := newFakeStore() + jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + acct := armedOverageAccount(store, 2, 4, since, jun1) + sc := newFakeStripe() + cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + + first, err := overageSvc(store, sc, at1).ChargeAccountOverage(context.Background(), cand, at1) + require.NoError(t, err) + require.Equal(t, cycle.OverageCharged, first.Status) + require.EqualValues(t, 900, first.ChargedCents) + + // Pool grows mid-period: a third app (4 modules) installs, 8 → 12. + seedAppCreated(store, acct, 4, false, jun1.AddDate(0, 0, -1)) + + at2 := time.Date(2026, 6, 20, 9, 0, 0, 0, time.UTC) + second, err := overageSvc(store, sc, at2).ChargeAccountOverage(context.Background(), cand, at2) + require.NoError(t, err) + require.Equal(t, cycle.OverageToppedUp, second.Status) + require.Equal(t, 7, second.OverCount, "pool 12 − included 5 = 7 over, the NEW cumulative over-count") + require.EqualValues(t, 440, second.ChargedCents, "4 incremental modules × $3.00 × 11/30 remaining days = $4.40 — never $0, never $12.00") + + require.Len(t, sc.invoiceCalls, 2, "one invoice for the original charge, one for the top-up") + require.NotEqual(t, sc.itemCalls[0].idemKey, sc.itemCalls[1].idemKey, "the top-up uses its OWN Idempotency-Key, distinct from the original charge") + + snap := store.overageSnaps[acctSnapKey{acct, jun1}] + require.Equal(t, 7, snap.OverCount) + require.EqualValues(t, 13_400_000, snap.ChargedMicros, + "cumulative: the original $9.00 + the $4.40 top-up = $13.40 total billed for the period — the display reads this exact figure") + require.Equal(t, cycle.OverageSnapshotCharged, snap.Status) + + // A third pass with NO further pool growth is a clean no-op (no third + // charge, D1e — never re-bill what is already covered). + third, err := overageSvc(store, sc, at2.Add(time.Hour)).ChargeAccountOverage(context.Background(), cand, at2.Add(time.Hour)) + require.NoError(t, err) + require.Equal(t, cycle.OverageSkippedAlreadyCharged, third.Status) + require.Len(t, sc.invoiceCalls, 2, "no third invoice when the pool hasn't grown further") +} + +// Finding #4 [MEDIUM] — the boundary's account_overage_snapshots row +// (source='advance') stored the literal Idempotency-Key STRING ("ii-") +// as InvoiceItemID instead of the genuine Stripe invoice item id, because +// s.charge() discarded CreateInvoiceItem's return value. The fix threads the +// real item back from s.charge() and stores IT. +func TestRunBillingCycle_AdvanceOverageSnapshotStoresGenuineStripeItemID(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 0 + store.hasPM = true + store.stripeCustomer = "cus_item_id" + seedApp(store, chargeAccount, 4, false) + seedApp(store, chargeAccount, 4, false) // pool 8 → 3 over → $9.00 pooled overage + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, 9_000_000, resp.AccountOverageMicros) + require.Len(t, sc.itemCalls, 1) + + snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] + require.True(t, ok) + + idemKey := sc.itemCalls[0].idemKey + require.True(t, strings.HasPrefix(idemKey, "ii-"), "sanity: the item was created under the ii- idempotency key") + require.NotEqual(t, idemKey, snap.InvoiceItemID, + "the stored id must be the GENUINE Stripe invoice item id, never the ii- idempotency-key string") + require.True(t, strings.HasPrefix(snap.InvoiceItemID, "ii_test_"), + "must be the REAL Stripe invoice item id the fake client generated for this call") +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 0adea43..cda3164 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -120,6 +120,14 @@ type fakeStore struct { errOverageGrace error // AccountsInOverageGrace errOverageSnap error // AccountOverageSnapshot errInsertOverageSnap error // InsertAccountOverageSnapshot + errMarkOverageSnap error // MarkAccountOverageSnapshotCharged + errTopUpOverageSnap error // TopUpAccountOverageSnapshot + + // overageClaimLoses, when > 0, makes the NEXT N InsertAccountOverageSnapshot + // calls report "lost the race" (inserted=false) WITHOUT actually writing + // anything — simulating a concurrent claim that beat this call, so a test + // can drive the claim-insert-loses-the-race path deterministically. + overageClaimLoses int } // acctSnapKey mirrors the account_overage_snapshots PRIMARY KEY @@ -564,16 +572,51 @@ func (f *fakeStore) AccountOverageSnapshot(_ context.Context, accountID uuid.UUI return snap, ok, nil } -func (f *fakeStore) InsertAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) error { +func (f *fakeStore) InsertAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) (bool, error) { if f.errInsertOverageSnap != nil { - return f.errInsertOverageSnap + return false, f.errInsertOverageSnap + } + if f.overageClaimLoses > 0 { + f.overageClaimLoses-- + return false, nil // simulate a concurrent claim winning the race } // ON CONFLICT (account_id, period_start) DO NOTHING: an existing row wins. k := acctSnapKey{snap.AccountID, snap.PeriodStart} if _, exists := f.overageSnaps[k]; exists { - return nil + return false, nil } f.overageSnaps[k] = snap + return true, nil +} + +func (f *fakeStore) MarkAccountOverageSnapshotCharged(_ context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error { + if f.errMarkOverageSnap != nil { + return f.errMarkOverageSnap + } + k := acctSnapKey{accountID, periodStart} + snap, ok := f.overageSnaps[k] + if !ok { + return nil // defensive: mirrors the DB's unconditional UPDATE affecting 0 rows + } + snap.Status = cycle.OverageSnapshotCharged + snap.InvoiceItemID = invoiceItemID + f.overageSnaps[k] = snap + return nil +} + +func (f *fakeStore) TopUpAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) error { + if f.errTopUpOverageSnap != nil { + return f.errTopUpOverageSnap + } + k := acctSnapKey{snap.AccountID, snap.PeriodStart} + existing, ok := f.overageSnaps[k] + if !ok || existing.Status != cycle.OverageSnapshotCharged { + return nil // mirrors the DB's WHERE status='charged' guard: a non-match is a no-op + } + existing.OverCount = snap.OverCount + existing.ChargedMicros = snap.ChargedMicros + existing.InvoiceItemID = snap.InvoiceItemID + f.overageSnaps[k] = existing return nil } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index c8efdbd..549cd52 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -247,18 +247,34 @@ type Store interface { // (grace anchor) and activated_at (period anchor). AccountsInOverageGrace(ctx context.Context, cutoff time.Time) ([]OverageGraceCandidate, error) - // AccountOverageSnapshot reads the frozen pooled overage a charge leg billed - // for ONE (account, period) — the double-charge guard both the grace sweep - // and the boundary consult (found=true → this period's pooled overage was - // already billed, skip it). found=false → never charged. + // AccountOverageSnapshot reads the frozen pooled overage a charge leg + // claimed/billed for ONE (account, period) — the double-charge guard both + // the grace sweep and the boundary consult (found=true → this period's + // pooled overage is CLAIMED — status 'pending' or 'charged' — the OTHER leg + // must never independently charge it; the claiming leg resumes/reuses it). + // found=false → never claimed. AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (snap AccountOverageSnapshot, found bool, err error) - // InsertAccountOverageSnapshot freezes what a charge leg billed the account - // for one period's pooled overage (migration 030) with ON CONFLICT - // (account_id, period_start) DO NOTHING — an existing row (a prior grace - // charge, or a reclaimed boundary attempt's own row) wins, so a re-run never - // rewrites what was already recorded as billed. - InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error + // InsertAccountOverageSnapshot CLAIMS one period's pooled overage charge for + // a leg — callers write status="pending" BEFORE calling Stripe (the + // crash-safe marker; see migration 030's status column doc) — with + // ON CONFLICT (account_id, period_start) DO NOTHING. inserted=false means + // the row already existed (a prior claim by this leg or the other one) and + // the caller MUST re-read AccountOverageSnapshot and defer to the winner + // rather than proceed to charge Stripe under its own claim. + InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) (inserted bool, err error) + + // MarkAccountOverageSnapshotCharged flips a claimed row to status="charged" + // once Stripe actually created the invoice item/invoice, recording the + // GENUINE Stripe invoice item id (never an idempotency-key string). + MarkAccountOverageSnapshotCharged(ctx context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error + + // TopUpAccountOverageSnapshot records an incremental charge against an + // already-'charged' period whose pool grew further before the period closed + // (the mid-period sweep's top-up leg, a judgment call — see + // cycle/overage.go's topUpGraceOverage doc). snap.OverCount/ChargedMicros + // are the NEW cumulative totals for the period. + TopUpAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error } // OverageGraceCandidate is one account the mid-period grace sweep evaluates: its @@ -273,11 +289,14 @@ type OverageGraceCandidate struct { // AccountOverageSnapshot is the in-memory form of a // ms_billing.account_overage_snapshots row (migration 030): what one charge leg -// billed one account for one period's POOLED module overage. PeriodStart is the -// display + double-charge lookup key; ChargedMicros is the exact overage the -// invoice billed (prorated for a 'grace' row, full for an 'advance' row); -// OverCount is the pooled over-count it tiered on; Source is 'grace' or -// 'advance'; InvoiceItemID is the Stripe item id (empty for a 0-cent charge). +// claimed/billed one account for one period's POOLED module overage. +// PeriodStart is the display + double-charge lookup key; ChargedMicros is the +// exact overage the invoice billed (prorated for a 'grace' row, full for an +// 'advance' row, or the cumulative total after a top-up); OverCount is the +// pooled over-count it tiered on; Source is 'grace' or 'advance'; Status is +// 'pending' (claimed, Stripe not yet confirmed — the crash-safe marker) or +// 'charged' (Stripe confirmed); InvoiceItemID is the genuine Stripe item id +// (empty while Status=="pending", or for a 0-cent charge). type AccountOverageSnapshot struct { AccountID uuid.UUID PeriodStart time.Time @@ -285,9 +304,16 @@ type AccountOverageSnapshot struct { OverCount int ChargedMicros int64 Source string + Status string InvoiceItemID string } +// Account overage snapshot status values (migration 030's status column). +const ( + OverageSnapshotPending = "pending" + OverageSnapshotCharged = "charged" +) + // AppModuleCount pairs one live roster app with its module_count snapshot — // one advance-base input row. The boundary leg needs the app id (not just the // count) to write the per-app-period base snapshot it bills (migration 028). @@ -1050,22 +1076,46 @@ func (s *pgxStore) AccountOverageSnapshot(ctx context.Context, accountID uuid.UU OverCount: int(row.OverCount), ChargedMicros: row.ChargedMicros, Source: row.Source, + Status: row.Status, }, true, nil } -func (s *pgxStore) InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error { - // 0 rows = ON CONFLICT DO NOTHING kept an existing row (a prior grace charge, - // or a reclaimed boundary attempt's write) — success either way. - _, err := s.q.InsertAccountOverageSnapshot(ctx, db.InsertAccountOverageSnapshotParams{ +func (s *pgxStore) InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) (bool, error) { + // rows=0 = ON CONFLICT DO NOTHING kept an existing row (a prior claim by + // this leg or the other one) — the caller must re-read and defer to it, + // never proceed to charge Stripe under its own (lost) claim. + rows, err := s.q.InsertAccountOverageSnapshot(ctx, db.InsertAccountOverageSnapshotParams{ AccountID: snap.AccountID.String(), PeriodStart: snap.PeriodStart, PeriodEnd: snap.PeriodEnd, OverCount: int32(snap.OverCount), //nolint:gosec // over_count = pooled sum − IncludedModules; the pool is Σ validated module_counts (each ≤ maxModuleCount), far below int32 max ChargedMicros: snap.ChargedMicros, Source: snap.Source, + Status: snap.Status, + InvoiceItemID: pgtype.Text{String: snap.InvoiceItemID, Valid: snap.InvoiceItemID != ""}, + }) + if err != nil { + return false, err + } + return rows > 0, nil +} + +func (s *pgxStore) MarkAccountOverageSnapshotCharged(ctx context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error { + return s.q.MarkAccountOverageSnapshotCharged(ctx, db.MarkAccountOverageSnapshotChargedParams{ + AccountID: accountID.String(), + PeriodStart: periodStart, + InvoiceItemID: pgtype.Text{String: invoiceItemID, Valid: invoiceItemID != ""}, + }) +} + +func (s *pgxStore) TopUpAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error { + return s.q.TopUpAccountOverageSnapshot(ctx, db.TopUpAccountOverageSnapshotParams{ + AccountID: snap.AccountID.String(), + PeriodStart: snap.PeriodStart, + OverCount: int32(snap.OverCount), //nolint:gosec // over_count = pooled sum − IncludedModules; the pool is Σ validated module_counts (each ≤ maxModuleCount), far below int32 max + ChargedMicros: snap.ChargedMicros, InvoiceItemID: pgtype.Text{String: snap.InvoiceItemID, Valid: snap.InvoiceItemID != ""}, }) - return err } // parseUUIDs parses a slice of UUID-as-string account ids (the form the sqlc diff --git a/internal/account/db/models.go b/internal/account/db/models.go index 73b077b..6053948 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -342,6 +342,7 @@ type MsBillingAccountOverageSnapshot struct { OverCount int32 `json:"over_count"` ChargedMicros int64 `json:"charged_micros"` Source string `json:"source"` + Status string `json:"status"` InvoiceItemID pgtype.Text `json:"invoice_item_id"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/account/db/overage.sql.go b/internal/account/db/overage.sql.go index 4edd8f5..cae90b8 100644 --- a/internal/account/db/overage.sql.go +++ b/internal/account/db/overage.sql.go @@ -75,8 +75,8 @@ func (q *Queries) ClearAccountOverage(ctx context.Context, id string) (int64, er const insertAccountOverageSnapshot = `-- name: InsertAccountOverageSnapshot :execrows INSERT INTO ms_billing.account_overage_snapshots - (account_id, period_start, period_end, over_count, charged_micros, source, invoice_item_id) -VALUES ($1, $2, $3, $4, $5, $6, $7) + (account_id, period_start, period_end, over_count, charged_micros, source, status, invoice_item_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (account_id, period_start) DO NOTHING ` @@ -87,16 +87,19 @@ type InsertAccountOverageSnapshotParams struct { OverCount int32 `json:"over_count"` ChargedMicros int64 `json:"charged_micros"` Source string `json:"source"` + Status string `json:"status"` InvoiceItemID pgtype.Text `json:"invoice_item_id"` } -// InsertAccountOverageSnapshot records what a charge leg billed the account for -// one period's pooled overage (migration 030). ON CONFLICT (account_id, -// period_start) DO NOTHING: an existing row — a prior grace charge, or a prior -// reclaimed boundary attempt's own row — wins, so a re-run never rewrites what -// was already recorded as billed. :execrows so the caller can observe the no-op, -// though both outcomes are success (the Stripe idempotency key on the same -// (account, period) already deduped the money). +// InsertAccountOverageSnapshot claims ONE (account, period)'s pooled overage +// charge for a leg — status='pending' is written BEFORE the leg calls Stripe +// (see migration 030's status column comment); the leg later calls +// MarkAccountOverageSnapshotCharged once Stripe actually succeeds. ON CONFLICT +// (account_id, period_start) DO NOTHING: an existing row — a prior grace claim, +// or a prior reclaimed boundary attempt's own row — wins, so a re-run never +// rewrites what was already claimed. :execrows so the caller can tell whether +// ITS insert won the race (rows=1) or lost to a concurrent claim (rows=0) and +// must re-read + defer to the winner instead of proceeding to charge Stripe. func (q *Queries) InsertAccountOverageSnapshot(ctx context.Context, arg InsertAccountOverageSnapshotParams) (int64, error) { result, err := q.db.Exec(ctx, insertAccountOverageSnapshot, arg.AccountID, @@ -105,6 +108,7 @@ func (q *Queries) InsertAccountOverageSnapshot(ctx context.Context, arg InsertAc arg.OverCount, arg.ChargedMicros, arg.Source, + arg.Status, arg.InvoiceItemID, ) if err != nil { @@ -113,8 +117,38 @@ func (q *Queries) InsertAccountOverageSnapshot(ctx context.Context, arg InsertAc return result.RowsAffected(), nil } +const markAccountOverageSnapshotCharged = `-- name: MarkAccountOverageSnapshotCharged :exec +UPDATE ms_billing.account_overage_snapshots +SET status = 'charged', + invoice_item_id = $3 +WHERE account_id = $1 + AND period_start = $2 +` + +type MarkAccountOverageSnapshotChargedParams struct { + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + InvoiceItemID pgtype.Text `json:"invoice_item_id"` +} + +// MarkAccountOverageSnapshotCharged flips a claimed row to 'charged' once +// Stripe actually created the invoice item/invoice, recording the GENUINE +// Stripe invoice item id (finding #4 — never the idempotency-key string). +// Unconditional on status (not just WHERE status='pending'): a retry that +// re-enters this leg after already flipping the row to 'charged' (e.g. the +// Stripe call succeeded, the write here committed, but a LATER step in the +// caller failed) must still be able to re-affirm the row harmlessly — the +// caller only ever calls this with the Stripe values it just confirmed, so +// overwriting an already-'charged' row with the same (idempotent) values is +// safe by construction (deterministic per-(account,period) Idempotency-Keys +// guarantee Stripe returns the SAME object on every re-call). +func (q *Queries) MarkAccountOverageSnapshotCharged(ctx context.Context, arg MarkAccountOverageSnapshotChargedParams) error { + _, err := q.db.Exec(ctx, markAccountOverageSnapshotCharged, arg.AccountID, arg.PeriodStart, arg.InvoiceItemID) + return err +} + const selectAccountOverageSnapshot = `-- name: SelectAccountOverageSnapshot :one -SELECT over_count, charged_micros, source +SELECT over_count, charged_micros, source, status FROM ms_billing.account_overage_snapshots WHERE account_id = $1 AND period_start = $2 @@ -129,19 +163,26 @@ type SelectAccountOverageSnapshotRow struct { OverCount int32 `json:"over_count"` ChargedMicros int64 `json:"charged_micros"` Source string `json:"source"` + Status string `json:"status"` } // SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg -// billed for ONE (account, period) — the double-charge guard for the charge -// side (a row means "this period's pooled overage was already billed, skip it") -// AND the authoritative display value for GetAccountBill's pooled-overage line. -// Exact period_start match (both the grace sweep and the boundary leg key on the -// anchored window start); no row → never charged → the caller skips (charge -// side) or falls back to the live pooled estimate (display side). +// claimed/billed for ONE (account, period) — the double-charge guard for the +// charge side (a row means "this period's pooled overage is claimed — pending +// or charged — skip/resume it, never independently charge it") AND the +// authoritative display value for GetAccountBill's pooled-overage line. Exact +// period_start match (both the grace sweep and the boundary leg key on the +// anchored window start); no row → never claimed → the caller charges it +// (charge side) or falls back to the live pooled estimate (display side). func (q *Queries) SelectAccountOverageSnapshot(ctx context.Context, arg SelectAccountOverageSnapshotParams) (SelectAccountOverageSnapshotRow, error) { row := q.db.QueryRow(ctx, selectAccountOverageSnapshot, arg.AccountID, arg.PeriodStart) var i SelectAccountOverageSnapshotRow - err := row.Scan(&i.OverCount, &i.ChargedMicros, &i.Source) + err := row.Scan( + &i.OverCount, + &i.ChargedMicros, + &i.Source, + &i.Status, + ) return i, err } @@ -199,3 +240,39 @@ func (q *Queries) SumLiveModuleCount(ctx context.Context, accountID string) (int err := row.Scan(&pooled_count) return pooled_count, err } + +const topUpAccountOverageSnapshot = `-- name: TopUpAccountOverageSnapshot :exec +UPDATE ms_billing.account_overage_snapshots +SET over_count = $3, + charged_micros = $4, + invoice_item_id = $5 +WHERE account_id = $1 + AND period_start = $2 + AND status = 'charged' +` + +type TopUpAccountOverageSnapshotParams struct { + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + OverCount int32 `json:"over_count"` + ChargedMicros int64 `json:"charged_micros"` + InvoiceItemID pgtype.Text `json:"invoice_item_id"` +} + +// TopUpAccountOverageSnapshot records an INCREMENTAL charge against an already- +// 'charged' period whose pool grew further before the period closed (finding +// #3 — a judgment call, see cycle/overage.go's topUpGraceOverage doc). Only +// fires from a 'charged' row (a 'pending' row is a resume-in-progress, never a +// top-up target); over_count/charged_micros are overwritten with the NEW +// cumulative totals so the ledger + the display always show what was actually +// billed in total for the period. +func (q *Queries) TopUpAccountOverageSnapshot(ctx context.Context, arg TopUpAccountOverageSnapshotParams) error { + _, err := q.db.Exec(ctx, topUpAccountOverageSnapshot, + arg.AccountID, + arg.PeriodStart, + arg.OverCount, + arg.ChargedMicros, + arg.InvoiceItemID, + ) + return err +} diff --git a/internal/account/db/queries/overage.sql b/internal/account/db/queries/overage.sql index bd63bf1..0a321e5 100644 --- a/internal/account/db/queries/overage.sql +++ b/internal/account/db/queries/overage.sql @@ -57,27 +57,64 @@ WHERE overage_since IS NOT NULL AND activated_at IS NOT NULL; -- SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg --- billed for ONE (account, period) — the double-charge guard for the charge --- side (a row means "this period's pooled overage was already billed, skip it") --- AND the authoritative display value for GetAccountBill's pooled-overage line. --- Exact period_start match (both the grace sweep and the boundary leg key on the --- anchored window start); no row → never charged → the caller skips (charge --- side) or falls back to the live pooled estimate (display side). +-- claimed/billed for ONE (account, period) — the double-charge guard for the +-- charge side (a row means "this period's pooled overage is claimed — pending +-- or charged — skip/resume it, never independently charge it") AND the +-- authoritative display value for GetAccountBill's pooled-overage line. Exact +-- period_start match (both the grace sweep and the boundary leg key on the +-- anchored window start); no row → never claimed → the caller charges it +-- (charge side) or falls back to the live pooled estimate (display side). -- name: SelectAccountOverageSnapshot :one -SELECT over_count, charged_micros, source +SELECT over_count, charged_micros, source, status FROM ms_billing.account_overage_snapshots WHERE account_id = $1 AND period_start = $2; --- InsertAccountOverageSnapshot records what a charge leg billed the account for --- one period's pooled overage (migration 030). ON CONFLICT (account_id, --- period_start) DO NOTHING: an existing row — a prior grace charge, or a prior --- reclaimed boundary attempt's own row — wins, so a re-run never rewrites what --- was already recorded as billed. :execrows so the caller can observe the no-op, --- though both outcomes are success (the Stripe idempotency key on the same --- (account, period) already deduped the money). +-- InsertAccountOverageSnapshot claims ONE (account, period)'s pooled overage +-- charge for a leg — status='pending' is written BEFORE the leg calls Stripe +-- (see migration 030's status column comment); the leg later calls +-- MarkAccountOverageSnapshotCharged once Stripe actually succeeds. ON CONFLICT +-- (account_id, period_start) DO NOTHING: an existing row — a prior grace claim, +-- or a prior reclaimed boundary attempt's own row — wins, so a re-run never +-- rewrites what was already claimed. :execrows so the caller can tell whether +-- ITS insert won the race (rows=1) or lost to a concurrent claim (rows=0) and +-- must re-read + defer to the winner instead of proceeding to charge Stripe. -- name: InsertAccountOverageSnapshot :execrows INSERT INTO ms_billing.account_overage_snapshots - (account_id, period_start, period_end, over_count, charged_micros, source, invoice_item_id) -VALUES ($1, $2, $3, $4, $5, $6, $7) + (account_id, period_start, period_end, over_count, charged_micros, source, status, invoice_item_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (account_id, period_start) DO NOTHING; + +-- MarkAccountOverageSnapshotCharged flips a claimed row to 'charged' once +-- Stripe actually created the invoice item/invoice, recording the GENUINE +-- Stripe invoice item id (finding #4 — never the idempotency-key string). +-- Unconditional on status (not just WHERE status='pending'): a retry that +-- re-enters this leg after already flipping the row to 'charged' (e.g. the +-- Stripe call succeeded, the write here committed, but a LATER step in the +-- caller failed) must still be able to re-affirm the row harmlessly — the +-- caller only ever calls this with the Stripe values it just confirmed, so +-- overwriting an already-'charged' row with the same (idempotent) values is +-- safe by construction (deterministic per-(account,period) Idempotency-Keys +-- guarantee Stripe returns the SAME object on every re-call). +-- name: MarkAccountOverageSnapshotCharged :exec +UPDATE ms_billing.account_overage_snapshots +SET status = 'charged', + invoice_item_id = $3 +WHERE account_id = $1 + AND period_start = $2; + +-- TopUpAccountOverageSnapshot records an INCREMENTAL charge against an already- +-- 'charged' period whose pool grew further before the period closed (finding +-- #3 — a judgment call, see cycle/overage.go's topUpGraceOverage doc). Only +-- fires from a 'charged' row (a 'pending' row is a resume-in-progress, never a +-- top-up target); over_count/charged_micros are overwritten with the NEW +-- cumulative totals so the ledger + the display always show what was actually +-- billed in total for the period. +-- name: TopUpAccountOverageSnapshot :exec +UPDATE ms_billing.account_overage_snapshots +SET over_count = $3, + charged_micros = $4, + invoice_item_id = $5 +WHERE account_id = $1 + AND period_start = $2 + AND status = 'charged'; diff --git a/migrations/billing/030_account_wide_overage.up.sql b/migrations/billing/030_account_wide_overage.up.sql index 44c3554..266173d 100644 --- a/migrations/billing/030_account_wide_overage.up.sql +++ b/migrations/billing/030_account_wide_overage.up.sql @@ -75,8 +75,20 @@ CREATE TABLE IF NOT EXISTS ms_billing.account_overage_snapshots ( -- grace-end) or the boundary advance leg ('advance', full pooled overage). source TEXT NOT NULL CHECK (source IN ('grace', 'advance')), - -- The Stripe invoice item id of the overage charge (empty/NULL for a - -- 0-cent rounded charge that recorded nothing) — audit trail only. + -- CRASH-SAFE claim marker (PR #47 review fix — the cross-leg double-charge + -- finding). 'pending' is written BEFORE the leg calls Stripe (the row is the + -- durable "I am about to charge this period's overage" claim); 'charged' is + -- written only AFTER Stripe actually created the invoice item/invoice. The + -- OTHER leg (grace vs boundary) treats ANY row for the period — pending or + -- charged — as claimed and must never independently charge it, closing the + -- crash window where the ORIGINAL code only wrote this row AFTER Stripe + -- succeeded (a crash between the two let the other leg see "no row" and + -- double-charge under a disjoint Idempotency-Key namespace). + status TEXT NOT NULL CHECK (status IN ('pending', 'charged')), + + -- The Stripe invoice item id of the overage charge (empty/NULL while + -- status='pending', or for a 0-cent rounded charge that recorded nothing) + -- — audit trail only. invoice_item_id TEXT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), From 8ebde0db0c1c8a9cb1384214f5103471456d4921 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 07:15:47 +0800 Subject: [PATCH 07/30] fix: creation-grace retroactive-billing + lock-hold review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three confirmed, adversarially-verified findings on the creation-grace charge (PR #46, money-charging code; invariant D1d: no retroactive catch-up): 1. HIGH — ChargeCreationProration priced the ENTIRE historical creation-period window off module_count read FRESH at sweep time, days after creation. A customer installing/uninstalling modules via SyncAppModules during the mandatory grace window could retroactively move the tier applied to days that never had that module count. Fixed: migration 030 adds apps.created_module_count, frozen once at RegisterApp (InsertAppMirror stamps it from the SAME value as module_count) and never touched by any other writer. ChargeCreationProration now prices off created_module_count; module_count remains the live value the boundary advance leg (and the display estimate for future periods) uses. 2. HIGH — removing the "skip if the creation period already closed" gate reintroduced the retroactive catch-up billing D1d forbids: an app created while its account is unactivated sits correctly unbilled every sweep, but the moment the account activates months later, the next sweep charged it for a period that closed long ago. Fixed: migration 031 adds apps.proration_skipped_at, a one-shot PERMANENT marker. ChargeCreationProration now compares the account's activation anchor against the app's anchored creation-period end (not "now" — grace + ordinary sweep cadence can legitimately push a healthy, already-activated account's charge a few days past its own period end, and that must still charge normally); when the account only activated at/after the period closed, the charge is permanently skipped and the app never resurfaces on a later sweep (AppsPendingProration now also excludes proration_skipped_at). 3. MEDIUM — ChargeProrationLocked held a SELECT ... FOR UPDATE row lock across two live Stripe HTTP calls (up to ~160s), blocking any concurrent SyncAppModules/AppDeleted write to the same row. Its deferred tx.Rollback(ctx) also reused the request context, which can silently fail against an already-cancelled/expired ctx. Fixed: split into three phases — lock+read+release (a short tx), the Stripe calls OUTSIDE any lock, then persist (a second short tx). A concurrent delete that races in during the Stripe call no longer blocks, and — since the charge has already succeeded in Stripe by then — is recorded regardless (D1e already forbids refunds; the money moved). The deferred rollback now uses a short-lived detached context (context.WithoutCancel + a fresh timeout) so cleanup reaches Postgres even when the caller's context has died. Tests: unit regression tests for all three (frozen-count pricing both directions, permanent-skip vs. still-charges-when-activated-in-time, verified to fail without the fix by reverting each in isolation); DB integration tests for the SQL-layer freeze/skip semantics and the lock-not-held-across-Stripe-call + dead-context-rollback behavior (verified to fail/hang against the pre-fix code). Full migration up/down/up round-trip validated against a real Postgres. make test / go vet / go build / gofmt / integration suite (-race) all clean. Co-Authored-By: Claude Opus 4.8 --- .../cycle/migration030_integration_test.go | 74 +++++++ .../cycle/migration031_integration_test.go | 78 ++++++++ internal/account/cycle/proration.go | 104 ++++++++-- internal/account/cycle/proration_test.go | 182 +++++++++++++++++- .../cycle/rollback_integration_test.go | 54 ++++++ internal/account/cycle/service_test.go | 21 +- internal/account/cycle/store.go | 180 +++++++++++++---- .../store_prorationlock_integration_test.go | 142 ++++++++++++++ internal/account/db/apps.sql.go | 76 ++++++-- internal/account/db/models.go | 4 + internal/account/db/queries/apps.sql | 59 ++++-- .../usage/account_bill_integration_test.go | 4 +- .../030_apps_created_module_count.down.sql | 6 + .../030_apps_created_module_count.up.sql | 40 ++++ .../031_apps_proration_skipped.down.sql | 12 ++ .../billing/031_apps_proration_skipped.up.sql | 46 +++++ 16 files changed, 977 insertions(+), 105 deletions(-) create mode 100644 internal/account/cycle/migration030_integration_test.go create mode 100644 internal/account/cycle/migration031_integration_test.go create mode 100644 internal/account/cycle/rollback_integration_test.go create mode 100644 internal/account/cycle/store_prorationlock_integration_test.go create mode 100644 migrations/billing/030_apps_created_module_count.down.sql create mode 100644 migrations/billing/030_apps_created_module_count.up.sql create mode 100644 migrations/billing/031_apps_proration_skipped.down.sql create mode 100644 migrations/billing/031_apps_proration_skipped.up.sql diff --git a/internal/account/cycle/migration030_integration_test.go b/internal/account/cycle/migration030_integration_test.go new file mode 100644 index 0000000..803ac27 --- /dev/null +++ b/internal/account/cycle/migration030_integration_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 030 (apps.created_module_count) — verifies at the SQL layer (the +// unit fakes only model this in Go) that the frozen creation-time module count +// survives a live module_count write untouched: InsertAppMirror stamps BOTH +// columns from the same value, and SetAppModuleCount (SyncAppModules' writer) +// touches ONLY module_count. + +func TestCreatedModuleCount_Integration_FrozenAcrossSetAppModuleCount(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + 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) + require.True(t, found) + require.Equal(t, 2, app.ModuleCount) + require.Equal(t, 2, app.CreatedModuleCount, "created_module_count starts equal to the registered count") + + // SyncAppModules' writer (module install/uninstall) — must move ONLY the + // live column. + require.NoError(t, store.SetAppModuleCount(ctx, appID, 9)) + + app, found, err = store.AppMirror(ctx, appID) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, 9, app.ModuleCount, "the live count moved") + require.Equal(t, 2, app.CreatedModuleCount, "the frozen count must NOT move — no query writes it after insert") + + // A second live-count write (another install/uninstall cycle) still never + // touches the frozen column. + require.NoError(t, store.SetAppModuleCount(ctx, appID, 0)) + app, _, err = store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 0, app.ModuleCount) + require.Equal(t, 2, app.CreatedModuleCount) +} + +func TestCreatedModuleCount_Integration_RetryKeepsFirstRegistrationsFrozenCount(t *testing.T) { + // InsertAppMirror's ON CONFLICT (app_id) DO NOTHING means a RegisterApp + // retry with a DIFFERENT module_count must not move either column — the + // FIRST registration's frozen count is the stable proration anchor. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + 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 + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 3, app.ModuleCount) + require.Equal(t, 3, app.CreatedModuleCount) +} diff --git a/internal/account/cycle/migration031_integration_test.go b/internal/account/cycle/migration031_integration_test.go new file mode 100644 index 0000000..d957940 --- /dev/null +++ b/internal/account/cycle/migration031_integration_test.go @@ -0,0 +1,78 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 031 (apps.proration_skipped_at) — verifies at the SQL layer that +// the permanent-skip marker (D1d, no retroactive catch-up) is a one-shot, +// first-write-wins guard, and that AppsPendingProration's work-list filter +// excludes a permanently-skipped app forever (the same partial-index +// predicate the migration re-defines). + +func TestProrationSkipped_Integration_OneShotAndExcludedFromPending(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + 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"))) + + // Past grace, unarmed, not yet skipped → pending. + ids, err := store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{skipped}, ids) + + // Mark permanently skipped (what ChargeCreationProration does once it + // determines the account activated after this app's period had closed). + require.NoError(t, store.SetAppProrationSkipped(ctx, skipped)) + + // Excluded from the pending work list from now on — a later sweep must + // never resurface it (it would otherwise sit pending forever, since + // proration_invoice_id stays NULL for a skipped charge). + ids, err = store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Empty(t, ids, "a permanently-skipped app is excluded from every future sweep") + + // One-shot: a second SetAppProrationSkipped call is a harmless no-op — it + // does not need to be idempotent-with-error, just idempotent-with-effect. + require.NoError(t, store.SetAppProrationSkipped(ctx, skipped)) + + app, _, err := store.AppMirror(ctx, skipped) + require.NoError(t, err) + require.True(t, app.ProrationSkipped) + require.Empty(t, app.ProrationInvoiceID, "skipped, never charged") +} + +func TestProrationSkipped_Integration_RefusesToSkipAnAlreadyChargedApp(t *testing.T) { + // Defensive belt-and-suspenders: SetAppProrationSkipped's WHERE clause also + // requires proration_invoice_id IS NULL, so it can never clobber a genuine + // charge that raced in first. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + 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.SetAppProrationInvoice(ctx, appID, "in_already_charged")) + + require.NoError(t, store.SetAppProrationSkipped(ctx, appID)) // no-op, not an error + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.False(t, app.ProrationSkipped, "an already-charged app must never be marked skipped") + require.Equal(t, "in_already_charged", app.ProrationInvoiceID) +} diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 95abd20..b18b00c 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -17,7 +17,22 @@ package cycle // // An app soft-deleted within grace is thus NEVER charged (the sweep excludes // deleted rows), and the charge is race-safe against a concurrent delete via a -// FOR UPDATE row lock (see ChargeProrationLocked). +// brief FOR UPDATE row lock that is released BEFORE the Stripe call (see +// ChargeProrationLocked in store.go). +// +// D1d — no retroactive catch-up: an app whose account never activated (or had +// no usable PM) sits pending on every sweep. If the account only becomes +// chargeable AFTER the app's anchored creation period has already closed, +// charging it then would be exactly the retroactive catch-up D1d forbids — +// ChargeCreationProration detects this (activatedAt at/after the period's end) +// and PERMANENTLY skips the charge (proration_skipped_at, migration 031) +// rather than charging it or leaving it pending forever. +// +// module_count is a LIVE snapshot SyncAppModules can move at any time, +// including during grace. The creation-proration charge must never price its +// historical window off whatever module_count happens to read at sweep time — +// it prices off created_module_count (migration 030), frozen once at +// RegisterApp and never touched again. import ( "context" @@ -59,6 +74,12 @@ const ( ProrationStatusNoCharge ProrationStatus = "no_charge" // ProrationStatusNotFound: no roster row for the app id (never registered). ProrationStatusNotFound ProrationStatus = "not_found" + // ProrationStatusPeriodClosed: the account only activated at/after the + // app's anchored creation period had already closed — charging it now + // would be a retroactive catch-up (D1d). PERMANENTLY skipped: the + // proration_skipped_at marker is armed so the app never resurfaces on a + // later sweep. + ProrationStatusPeriodClosed ProrationStatus = "skipped_period_closed" ) // ProrationResult reports what ChargeCreationProration did. ProrationInvoiceID is @@ -111,21 +132,30 @@ type ProrationCharge struct { // // The amount is the SAME as the pre-grace RegisterApp charge: // -// ProratedBaseMicros(AppBaseFeeMicros(BaseFeeMicros, module_count), +// ProratedBaseMicros(AppBaseFeeMicros(BaseFeeMicros, created_module_count), // created_at, the anchored period CONTAINING created_at) // // anchored to the TRUE created_at (NOT now), so the app pays only for the whole // UTC days it existed in its creation period, creation day inclusive — grace -// only delayed WHEN this fires. It is deliberately NOT gated on whether the -// creation period has since ended: that period is billed by NO other leg (the -// boundary advance leg only ever bills an app's SUBSEQUENT periods, never the one -// containing its creation), so charging it whenever the guard is unarmed is -// always correct and can never double-bill. +// only delayed WHEN this fires. created_module_count (migration 030) is the +// module count FROZEN at RegisterApp time: SyncAppModules can move the LIVE +// module_count at any point during (or after) grace, but the historical +// creation-period days billed here must price at the tier that actually +// applied on those days, never whatever count happens to read at sweep time. +// +// It IS gated on whether the account only became chargeable after the +// creation period had already closed (D1d): that would be a retroactive +// catch-up charge for time the account was never eligible to be billed for, +// and is PERMANENTLY skipped rather than charged (see the period-closed check +// below). Short of that, the creation period is billed by NO other leg (the +// boundary advance leg only ever bills an app's SUBSEQUENT periods, never the +// one containing its creation), so charging it whenever the guard is unarmed +// and the period-closed check passes is correct and can never double-bill. // // Cheap gates that don't need the row lock (unregistered / already-charged / -// deleted / unactivated / no-PM) short-circuit first; the actual charge + arm -// runs under the lock (ChargeProrationLocked), which re-verifies the deleted + -// guard state authoritatively. +// deleted / unactivated / period-closed / no-PM) short-circuit first; the +// actual charge + arm runs under the lock (ChargeProrationLocked), which +// re-verifies the deleted + guard state authoritatively. func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) (*ProrationResult, error) { if appID == uuid.Nil { return nil, billing.InvalidInput("app_id required") @@ -142,15 +172,19 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if app.ProrationInvoiceID != "" { return &ProrationResult{AppID: appID, Status: ProrationStatusAlreadyCharged, ProrationInvoiceID: app.ProrationInvoiceID}, nil } + // Permanently skipped on a prior sweep (D1d retroactive-catch-up guard, + // migration 031) — never re-evaluated. + if app.ProrationSkipped { + return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil + } // Deleted within grace (or after) → never charged (D1e). The locked section // re-checks this authoritatively; this is the cheap early-out. if app.Deleted { return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil } - // Activation + PM gates (D1d), same posture as the boundary spine: an - // unactivated account (never bound a card) is never charged, and an activated - // one with no usable default PM is skipped and re-attempted next sweep. + // Activation gate (D1d), same posture as the boundary spine: an + // unactivated account (never bound a card) is never charged. activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) if err != nil { return nil, billing.Internal("account activation lookup failed", err) @@ -158,6 +192,37 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if !activated { return &ProrationResult{AppID: appID, Status: ProrationStatusUnactivated}, nil } + + // D1d — no retroactive catch-up: derive the anchored period CONTAINING the + // app's created_at from the account's (now-known) activation anchor. If the + // account only activated AT OR AFTER that period's end, the account was + // unactivated — and therefore never chargeable — for the app's ENTIRE + // creation period; charging it now, however late the sweep runs, would be + // exactly the retroactive catch-up D1d forbids. Permanently mark it skipped + // (never re-evaluated again) rather than charge it, and rather than leaving + // it pending forever (proration_invoice_id would stay NULL, so without this + // marker AppsPendingProration would resurface it on every future sweep). + // + // This check deliberately compares against activatedAt, NOT "now": grace + + // ordinary sweep cadence can itself push the charge attempt a few days past + // this SAME periodEnd for a perfectly healthy, already-activated account + // (an app created near its period boundary) — that is expected, intended + // delayed billing (still the ONLY leg that ever bills this period), not a + // retroactive catch-up, and must still charge normally. + _, periodEnd := billingperiod.AnchoredPeriodWindow(app.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) + if !activatedAt.Before(periodEnd) { + if err := s.store.SetAppProrationSkipped(ctx, appID); err != nil { + return nil, billing.Internal("mark proration permanently skipped failed", err) + } + return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil + } + + // PM gate (D1d), same posture as the boundary spine: activated but no + // usable default PM is skipped and re-attempted next sweep (unlike the + // period-closed case above, "no PM right now" is not itself evidence the + // account was ever ineligible for this specific period, so it stays a + // transient, retried skip rather than a permanent one — see the judgment + // call noted in the PR description for the limits of this). hasPM, err := s.store.HasUsableDefaultPM(ctx, app.AccountID) if err != nil { return nil, billing.Internal("usable PM check failed", err) @@ -177,17 +242,20 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) } - // The charge callback runs INSIDE the locked transaction (ChargeProrationLocked): - // the not-deleted re-check, the Stripe charge, and the guard-arm are one atomic - // unit so a racing delete and this charge are mutually exclusive (no refund path). + // The charge callback runs AFTER the row lock is released (ChargeProrationLocked, + // store.go): the not-deleted re-check happens under a brief lock, then the + // Stripe charge runs unlocked, then the guard-arm persists the result. var cents int64 outcome, invID, err := s.store.ChargeProrationLocked(ctx, appID, func(locked AppMirror) (*ProrationCharge, error) { // Window = the anchored period CONTAINING the app's created_at (ADR 0005 // anchor from the activation day). Derived from created_at, NEVER from now, // so the amount is deterministic regardless of when the sweep fires. periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(locked.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) + // Priced off created_module_count — the count FROZEN at RegisterApp time + // — never the live module_count, which SyncAppModules may have moved + // during (or since) grace (finding 1: no retroactive tier drift). prorated := usage.ProratedBaseMicros( - usage.AppBaseFeeMicros(usage.BaseFeeMicros, locked.ModuleCount), + usage.AppBaseFeeMicros(usage.BaseFeeMicros, locked.CreatedModuleCount), locked.CreatedAt, periodStart, periodEnd, ) c, err := centsFromMicros(prorated) @@ -230,7 +298,7 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) AppID: locked.AppID, PeriodStart: periodStart, PeriodEnd: periodEnd, - ModuleCount: locked.ModuleCount, + ModuleCount: locked.CreatedModuleCount, BaseMicros: prorated, }, }, nil diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index ebd228c..e72b773 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -184,10 +184,17 @@ func TestChargeCreationProration_CreatedOnBoundaryChargesFullNewPeriodBase(t *te func TestChargeCreationProration_DelayedPastPeriodEndStillCharges(t *testing.T) { // Delayed billing (grace point 5): the charge is anchored to the TRUE - // created_at, NOT now, so even when grace pushes the charge past the creation - // period's end, the creation-period proration STILL fires — that period is - // billed by NO other leg (the boundary advance leg only ever bills an app's - // SUBSEQUENT periods), so charging it is correct and never double-bills. + // created_at, NOT now, so even when grace + ordinary sweep cadence pushes the + // charge attempt past the creation period's end, the creation-period + // proration STILL fires — that period is billed by NO other leg (the + // boundary advance leg only ever bills an app's SUBSEQUENT periods), so + // charging it is correct and never double-bills. This is NOT the D1d + // retroactive-catch-up case (see TestChargeCreationProration_ + // SkipsPermanentlyWhenActivatedAfterPeriodClosed below): the account here was + // ALREADY activated (May 4, registeredAccount) well before the app's period + // even opened — the period-closed check in ChargeCreationProration compares + // against activatedAt, not "now", so a healthy already-activated account is + // never penalized for a sweep that simply runs late. // App created May 20 → period [May 4, Jun 4), long closed by the sweep in // July: 15 of 31 days of $20 = round_half_up(9_677_419.8) → 968 cents. store := newFakeStore() @@ -314,3 +321,170 @@ func TestSweep_ChargesOnlyPastGraceLiveUnchargedApps(t *testing.T) { require.Empty(t, store.apps[young].ProrationInvoiceID) require.Empty(t, store.apps[gone].ProrationInvoiceID) } + +// --- FINDING 1: the creation-proration charge must price off the module count +// FROZEN at RegisterApp time, never whatever module_count SyncAppModules moves +// it to during (or after) the grace window ---------------------------------- + +func TestChargeCreationProration_PricesFrozenCountNotLiveCountAfterMidGraceInstall(t *testing.T) { + // Reproduces the exact failure scenario: an app registers with 0 modules, + // the customer installs 7 MORE modules (via SyncAppModules) DURING the + // mandatory grace window — before the sweep ever charges — and the sweep + // fires after grace elapses. Pre-fix, the charge priced off the module_count + // read FRESH at sweep time (7, live) → 20e6 + 2×3e6 = 26e6 base, 3 of 30 days + // → 2_600_000 micros → 260 cents: a HIGHER tier retroactively applied to + // days that never had 7 modules installed. Fixed: the charge prices off + // created_module_count (frozen at registration, 0) → 20e6 base, 3 of 30 days + // → 2_000_000 micros → 200 cents — identical to + // TestChargeCreationProration_AmountMatchesLegacyProration's un-synced case. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Mid-grace install: the live count jumps to 7 BEFORE the sweep ever runs. + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ + AppID: appID, ModuleCount: intPtr(7), + }) + require.NoError(t, err) + require.Equal(t, 7, store.apps[appID].ModuleCount, "the live count DID move") + require.Equal(t, 0, store.apps[appID].CreatedModuleCount, "the frozen count must NOT move") + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 200, resp.ProrationCents, + "must price off the FROZEN count (0 modules → $20 base), not the live count (7 → $26 base)") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) + + // The migration-028 snapshot must also record the FROZEN count/amount — the + // display must never show a tier that never applied to those days either. + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, 0, snap.snap.ModuleCount) + require.EqualValues(t, 2_000_000, snap.snap.BaseMicros) + + // The LIVE count survives untouched for the boundary advance leg's future + // periods — only the historical creation-period charge is frozen. + require.Equal(t, 7, store.apps[appID].ModuleCount) +} + +func TestChargeCreationProration_PricesFrozenCountNotLiveCountAfterMidGraceUninstall(t *testing.T) { + // The reverse direction: registers with 7 modules (overage tier), the + // customer UNINSTALLS all the way down to 0 during grace. Pre-fix this would + // retroactively price the creation days at the LOWER tier (200 cents, 0 + // modules) though they were never at 0 modules on those days. Fixed: prices + // off the frozen created_module_count (7) → 1300 cents, matching + // TestChargeCreationProration_IncludesModuleOverage's un-synced case exactly. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ + AppID: appID, ModuleCount: intPtr(0), + }) + require.NoError(t, err) + require.Equal(t, 0, store.apps[appID].ModuleCount) + require.Equal(t, 7, store.apps[appID].CreatedModuleCount, "the frozen count must NOT move") + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 1_300, resp.ProrationCents, + "must price off the FROZEN count (7 modules → overage tier), not the live count (0 → base only)") +} + +// --- FINDING 2: no retroactive catch-up (D1d) when an account only activates +// after the app's anchored creation period already closed ------------------- + +func TestChargeCreationProration_SkipsPermanentlyWhenActivatedAfterPeriodClosed(t *testing.T) { + // Reproduces the exact failure scenario: an app is created while its account + // is unactivated. Every sweep correctly leaves it unbilled (skipped_ + // unactivated). MONTHS later the owner finally binds a card — with anchor + // day 1 (activated Apr 1), the app's anchored creation period is + // [Jan 1, Feb 1), long closed by Apr 1. Pre-fix, the very next charge + // attempt would retroactively bill that period in FULL (2000 cents on 0 + // modules, since Jan 1 == the period start): exactly the retroactive + // catch-up D1d forbids. Fixed: the charge is PERMANENTLY skipped — no + // Stripe call, ever, and the app never resurfaces on a later sweep (it + // would otherwise sit pending forever, since proration_invoice_id never + // gets set for a skipped charge). + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) // unactivated at creation + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + created := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + registerMirror(t, svc, user, appID, created, 0) + + // Past grace, still unactivated → correctly pending, no charge (D1d's + // existing unactivated gate — unchanged by this fix). + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, first.Status) + require.Empty(t, sc.itemCalls) + + // MONTHS later: the owner binds a card (anchor day 1) and a PM. + store.activation[acct] = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + store.hasPM = true + + second, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, second.Status) + require.Empty(t, sc.itemCalls, "no Stripe call — a retroactive catch-up charge must never fire") + require.Empty(t, store.apps[appID].ProrationInvoiceID) + require.True(t, store.apps[appID].ProrationSkipped, "permanently marked so it is never re-evaluated") + + // A cheap re-evaluation (e.g. a retried RPC) short-circuits on the marker + // without even re-reading account activation — still no Stripe call. + third, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, third.Status) + require.Empty(t, sc.itemCalls) + + // A LATER sweep must never resurface it — it would otherwise sit pending + // forever (proration_invoice_id stays NULL for a permanently-skipped app). + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "a permanently-skipped app never resurfaces on a later sweep") + require.Empty(t, sc.itemCalls) +} + +func TestChargeCreationProration_ActivatedBeforePeriodClosesStillCharges(t *testing.T) { + // Guard against an over-broad fix: an account that activates BEFORE the + // app's anchored creation period closes must charge normally — D1d only + // blocks a retroactive catch-up when the account was unactivated for the + // app's ENTIRE creation period. The anchor day is DERIVED from activatedAt + // itself (billingperiod.AnchorDay), so activating the SAME calendar day the + // app was created (the common "sign up, create an app, add a card" onboarding + // flow) anchors the period at that same day-of-month — putting its END a + // full month out, safely after activation. + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 1, 10, 6, 0, 0, 0, time.UTC), 0) + + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, first.Status) + + // Activates a few hours later the SAME day (anchor day 10) → period + // [Jan 10, Feb 10) — still wide open — and binds a PM. + store.activation[acct] = time.Date(2026, 1, 10, 9, 0, 0, 0, time.UTC) + store.hasPM = true + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2_000, resp.ProrationCents, "created on/after the period start → full base") + require.Len(t, sc.itemCalls, 1) + require.False(t, store.apps[appID].ProrationSkipped) +} diff --git a/internal/account/cycle/rollback_integration_test.go b/internal/account/cycle/rollback_integration_test.go new file mode 100644 index 0000000..3b89e74 --- /dev/null +++ b/internal/account/cycle/rollback_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package cycle + +// White-box (package cycle, not cycle_test) so this can exercise the +// unexported deferredRollback directly (finding 3, store.go): the deferred +// Rollback in ChargeProrationLocked's phases must reach Postgres even when the +// context passed to the surrounding call is already cancelled/expired (e.g. +// the enclosing Lambda invocation timed out while awaiting a stalled Stripe +// call) — reusing that dead context verbatim in the Rollback call can fail +// silently, leaving the transaction (and its row lock) open until Postgres's +// own dead-connection detection eventually reclaims it. + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +func TestDeferredRollback_Integration_SurvivesAlreadyCancelledContext(t *testing.T) { + pool := testutil.NewTestDB(t) + + // Sanity check: confirms the bug this test guards against is real — pgx + // refuses to run a network round-trip (including ROLLBACK) against an + // already-cancelled context, so a NAIVE `defer func() { tx.Rollback(ctx) }()` + // that reuses a dead ctx verbatim is a silent no-op. + ctx, cancel := context.WithCancel(context.Background()) + tx, err := pool.Begin(ctx) + require.NoError(t, err) + cancel() + naiveErr := tx.Rollback(ctx) + require.Error(t, naiveErr, "sanity: rolling back with an already-cancelled context fails") + // Clean up the still-open transaction/connection from the sanity check with + // a fresh context so it doesn't leak into the real assertion below. + _ = tx.Rollback(context.Background()) + + // The fix: deferredRollback is handed the SAME dead ctx but must still reach + // Postgres via its own short-lived detached context. + tx2, err := pool.Begin(context.Background()) + require.NoError(t, err) + deferredRollback(ctx, tx2) // ctx here is STILL the cancelled context from above + + // Prove the rollback actually completed and the connection is healthy again + // (a leaked/open transaction would eventually starve the pool; this bounds + // how long we're willing to wait for the pool to hand back a working conn). + require.Eventually(t, func() bool { + var one int + return pool.QueryRow(context.Background(), "SELECT 1").Scan(&one) == nil && one == 1 + }, 3*time.Second, 50*time.Millisecond, "deferredRollback must release the transaction/connection despite the dead ctx") +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 39b84fe..d102725 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -102,6 +102,7 @@ type fakeStore struct { errAppInsert error // InsertAppMirror errAppMirror error // AppMirror errSetProration error // SetAppProrationInvoice + errSetSkipped error // SetAppProrationSkipped errSetCount error // SetAppModuleCount errMarkDeleted error // MarkAppDeleted errLiveCounts error // LiveAppsCreatedBefore @@ -396,7 +397,9 @@ func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUI return nil // ON CONFLICT (app_id) DO NOTHING — the FIRST registration wins } f.apps[appID] = cycle.AppMirror{ - AppID: appID, AccountID: accountID, ModuleCount: moduleCount, CreatedAt: createdAt, + AppID: appID, AccountID: accountID, ModuleCount: moduleCount, + CreatedModuleCount: moduleCount, // frozen at insert, mirroring InsertAppMirror's $3/$3 write + CreatedAt: createdAt, } return nil } @@ -420,6 +423,17 @@ func (f *fakeStore) SetAppProrationInvoice(_ context.Context, appID uuid.UUID, s return nil } +func (f *fakeStore) SetAppProrationSkipped(_ context.Context, appID uuid.UUID) error { + if f.errSetSkipped != nil { + return f.errSetSkipped + } + if app, ok := f.apps[appID]; ok && !app.ProrationSkipped && app.ProrationInvoiceID == "" { + app.ProrationSkipped = true // first-write-wins, like the WHERE … IS NULL guard + f.apps[appID] = app + } + return nil +} + func (f *fakeStore) SetAppModuleCount(_ context.Context, appID uuid.UUID, moduleCount int) error { if f.errSetCount != nil { return f.errSetCount @@ -448,10 +462,11 @@ func (f *fakeStore) AppsPendingProration(_ context.Context, createdBefore time.T return nil, f.errPendingProration } // Mirrors the SQL: created_at <= cutoff AND proration_invoice_id IS NULL AND - // deleted_at IS NULL. Sorted by created_at for a deterministic sweep order. + // deleted_at IS NULL AND proration_skipped_at IS NULL. Sorted by created_at + // for a deterministic sweep order. var out []uuid.UUID for id, app := range f.apps { - if app.ProrationInvoiceID == "" && !app.Deleted && !app.CreatedAt.After(createdBefore) { + if app.ProrationInvoiceID == "" && !app.Deleted && !app.ProrationSkipped && !app.CreatedAt.After(createdBefore) { out = append(out, id) } } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 30fd37f..95be5b6 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -185,23 +185,32 @@ type Store interface { // AppsPendingProration returns the app ids past the creation grace window // (created_at <= createdBefore = now − GraceDays) that are still LIVE - // (deleted_at IS NULL) and NOT yet charged (proration_invoice_id IS NULL) — - // the creation-proration sweep's work list. An app deleted within grace, or - // already charged, is excluded. + // (deleted_at IS NULL), NOT yet charged (proration_invoice_id IS NULL), and + // NOT permanently skipped (proration_skipped_at IS NULL, migration 031) — + // the creation-proration sweep's work list. An app deleted within grace, + // already charged, or already determined to be a would-be retroactive + // catch-up (D1d) is excluded. AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) - // ChargeProrationLocked runs the creation-proration charge for ONE app inside - // a single transaction that SELECT ... FOR UPDATE-locks the roster row. Under - // the lock it re-verifies the row is still chargeable (deleted_at IS NULL AND - // proration_invoice_id IS NULL); only then does it invoke charge — which - // performs the Stripe calls and returns the payload to persist — and, STILL - // under the same lock, mirrors the invoice, freezes the base snapshot, and - // arms the one-shot guard, committing atomically. Holding the lock across the - // Stripe call is deliberate: it makes a concurrent soft-delete (MarkAppDeleted) - // and this charge mutually exclusive (no refund path, D1e) — whichever commits - // first wins and the loser sees the other's write and no-ops. charge returning - // (nil, nil) means "nothing to charge" (0 cents) → nothing is persisted. The - // returned invoice id is the armed (or pre-armed) guard's. + // ChargeProrationLocked runs the creation-proration charge for ONE app. It + // briefly SELECT ... FOR UPDATE-locks the roster row to re-verify the row is + // still chargeable (deleted_at IS NULL AND proration_invoice_id IS NULL) and + // read its frozen state, then RELEASES the lock before invoking charge — + // which performs the (potentially slow) Stripe network calls OUTSIDE any + // lock or transaction — and finally persists the mirrored invoice, the base + // snapshot, and the one-shot guard in a second short transaction. The lock is + // deliberately NOT held across the Stripe call (a prior version did; it could + // block a concurrent SyncAppModules/MarkAppDeleted write for the Stripe SDK's + // full ~80s-per-call timeout): a soft-delete that commits while the charge + // callback is in flight does NOT unwind an already-succeeded Stripe charge + // (D1e already forbids refunds — the money moved), so the persist step + // writes the invoice/snapshot/guard unconditionally on success. A second, + // genuinely concurrent charge attempt for the SAME app converges on the SAME + // Stripe objects (the deterministic per-app Idempotency-Keys) and the guard's + // first-write-wins UPDATE, so this stays race-safe without a lock spanning + // both phases. charge returning (nil, nil) means "nothing to charge" (0 + // cents) → nothing is persisted. The returned invoice id is the armed (or + // pre-armed) guard's. ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(locked AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) // SetAppProrationInvoice arms the ONE-SHOT creation-proration guard: it @@ -210,6 +219,15 @@ type Store interface { // the write is a no-op and the original invoice id survives. SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, stripeInvoiceID string) error + // SetAppProrationSkipped arms the PERMANENT creation-proration skip marker + // (migration 031, D1d): the account only activated at/after this app's + // anchored creation period had already closed, so the app is EXCLUDED from + // every future sweep rather than left pending forever (proration_invoice_id + // stays NULL, so without this marker AppsPendingProration would resurface it + // on every sweep indefinitely). First-write-wins and a no-op if the app was + // somehow already charged in the meantime — never an error. + SetAppProrationSkipped(ctx context.Context, appID uuid.UUID) error + // SetAppModuleCount snapshots a new installed-module count. A deleted // app's count is frozen (the UPDATE's WHERE deleted_at IS NULL no-ops — // D1e: no future base, so no tier to move). @@ -270,12 +288,23 @@ type AppBaseSnapshot struct { // AppMirror is the in-memory form of a ms_billing.apps roster row (migration // 027). ProrationInvoiceID is "" while the one-shot creation-proration guard // is unarmed; DeletedAt is meaningful only when Deleted is true. +// CreatedModuleCount (migration 030) is the module count FROZEN at +// RegisterApp time — immutable, never touched by SyncAppModules — and is what +// ChargeCreationProration prices the historical creation-period window from; +// ModuleCount is the LIVE count SyncAppModules keeps current and is what the +// boundary advance leg (and the display read for all FUTURE periods) uses. +// ProrationSkipped (migration 031) is true once the app's creation-proration +// charge has been PERMANENTLY skipped as a would-be retroactive catch-up +// (D1d): the account only activated at/after the app's anchored creation +// period had already closed. type AppMirror struct { AppID uuid.UUID AccountID uuid.UUID ModuleCount int + CreatedModuleCount int CreatedAt time.Time ProrationInvoiceID string + ProrationSkipped bool Deleted bool DeletedAt time.Time } @@ -862,8 +891,10 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b AppID: app, AccountID: acct, ModuleCount: int(row.ModuleCount), + CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, ProrationInvoiceID: row.ProrationInvoiceID.String, // "" when NULL (guard unarmed) + ProrationSkipped: row.ProrationSkippedAt.Valid, Deleted: row.DeletedAt.Valid, DeletedAt: row.DeletedAt.Time, }, true, nil @@ -877,56 +908,89 @@ func (s *pgxStore) AppsPendingProration(ctx context.Context, createdBefore time. return parseUUIDs(rows) } -func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) { +// deferredRollback rolls back tx using a short-lived DETACHED context rather +// than reusing ctx verbatim. ctx may already be cancelled or past its deadline +// by the time this runs (e.g. the surrounding Lambda invocation timed out +// while a Stripe call the caller was awaiting stalled) — Rollback on a dead +// context can fail silently, leaving the row lock / transaction open until +// Postgres's own dead-connection detection eventually reclaims it. Stripping +// cancellation (context.WithoutCancel) while keeping request-scoped values, +// then applying a fresh short timeout, lets cleanup reach Postgres either way. +func deferredRollback(ctx context.Context, tx pgx.Tx) { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = tx.Rollback(rctx) // no-op after a successful Commit +} + +// lockAndReadChargeableApp briefly SELECT ... FOR UPDATE-locks the roster row, +// re-verifies it is still chargeable (deleted_at IS NULL AND +// proration_invoice_id IS NULL), and releases the lock (the transaction +// commits either way — there is nothing left to write once the terminal +// checks pass, so a plain commit is equivalent to and cheaper than a rollback +// here). proceed=false means the caller must return (outcome, invID, nil) +// immediately without invoking charge; proceed=true carries the locked +// snapshot (including the frozen created_module_count) charge prices from. +func (s *pgxStore) lockAndReadChargeableApp(ctx context.Context, appID uuid.UUID) (locked AppMirror, outcome ProrationOutcome, invID string, proceed bool, err error) { tx, err := s.pool.Begin(ctx) if err != nil { - return 0, "", err + return AppMirror{}, 0, "", false, err } - defer func() { _ = tx.Rollback(ctx) }() // no-op after a successful Commit + defer deferredRollback(ctx, tx) qtx := s.q.WithTx(tx) - - // Lock the row and re-verify the terminal state UNDER the lock: a soft-delete - // that committed before us is now visible; one racing us blocks here until we - // commit (charge wins), then no-ops on its own WHERE deleted_at IS NULL. row, err := qtx.SelectAppMirrorForUpdate(ctx, appID.String()) if errors.Is(err, pgx.ErrNoRows) { - return ProrationLockedNotFound, "", nil + return AppMirror{}, ProrationLockedNotFound, "", false, nil } if err != nil { - return 0, "", err + return AppMirror{}, 0, "", false, err } if row.DeletedAt.Valid { - return ProrationLockedDeleted, "", nil + return AppMirror{}, ProrationLockedDeleted, "", false, nil } if row.ProrationInvoiceID.Valid { - return ProrationLockedAlreadyCharged, row.ProrationInvoiceID.String, nil + return AppMirror{}, ProrationLockedAlreadyCharged, row.ProrationInvoiceID.String, false, nil } app, err := uuid.Parse(row.AppID) if err != nil { - return 0, "", err + return AppMirror{}, 0, "", false, err } acct, err := uuid.Parse(row.AccountID) if err != nil { - return 0, "", err + return AppMirror{}, 0, "", false, err } - locked := AppMirror{ - AppID: app, - AccountID: acct, - ModuleCount: int(row.ModuleCount), - CreatedAt: row.CreatedAt, + locked = AppMirror{ + AppID: app, + AccountID: acct, + ModuleCount: int(row.ModuleCount), + CreatedModuleCount: int(row.CreatedModuleCount), + CreatedAt: row.CreatedAt, } - // Stripe charge happens INSIDE the tx (the callback), so the lock spans it — - // the not-deleted check, the charge, and the guard-arm are one atomic unit. - pc, err := charge(locked) - if err != nil { - return 0, "", err // rollback; guard unarmed → the next sweep retries (idem keys) + if err := tx.Commit(ctx); err != nil { + return AppMirror{}, 0, "", false, err } - if pc == nil { - return ProrationLockedNoCharge, "", nil // 0 cents — nothing to invoice + return locked, 0, "", true, nil +} + +// persistProrationCharge mirrors a SUCCESSFULLY-created Stripe charge (the +// invoice, the migration-028 base snapshot, and the one-shot guard) inside one +// short transaction. Called AFTER the Stripe network call has already +// completed — the money has already moved — so this always persists on a +// non-nil pc: a concurrent soft-delete that raced in during the (now-released) +// window between the lock and this write does NOT unwind an already-succeeded +// charge (D1e forbids refunds), and a genuinely concurrent second charge +// attempt for the same app converges on identical values (the deterministic +// per-app Stripe Idempotency-Keys guarantee the SAME invoice id, and every +// write here is itself idempotent / first-write-wins). +func (s *pgxStore) persistProrationCharge(ctx context.Context, appID uuid.UUID, pc *ProrationCharge) (ProrationOutcome, string, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, "", err } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) due, err := centsNumeric(pc.Invoice.AmountDueCents) if err != nil { @@ -957,8 +1021,9 @@ func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, c }); err != nil { return 0, "", err } - // Arm the one-shot guard. WHERE proration_invoice_id IS NULL is belt-and- - // suspenders — the FOR UPDATE lock already guarantees no concurrent arm. + // Arm the one-shot guard. First-write-wins (WHERE proration_invoice_id IS + // NULL): a concurrent second attempt for the same app affects 0 rows here + // and keeps the winner's (identical, by construction) invoice id. if _, err := qtx.SetAppProrationInvoice(ctx, db.SetAppProrationInvoiceParams{ AppID: appID.String(), ProrationInvoiceID: pgtype.Text{String: pc.InvoiceID, Valid: true}, @@ -972,6 +1037,30 @@ func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, c return ProrationLockedCharged, pc.InvoiceID, nil } +func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) { + // Phase 1: lock just long enough to read + verify chargeable state, then + // release — never held across the Stripe call below. + locked, outcome, invID, proceed, err := s.lockAndReadChargeableApp(ctx, appID) + if err != nil { + return 0, "", err + } + if !proceed { + return outcome, invID, nil + } + + // Phase 2: the Stripe network calls, OUTSIDE any lock or transaction. + pc, err := charge(locked) + if err != nil { + return 0, "", err // guard unarmed → the next sweep retries (idem keys) + } + if pc == nil { + return ProrationLockedNoCharge, "", nil // 0 cents — nothing to invoice + } + + // Phase 3: persist the successful charge. + return s.persistProrationCharge(ctx, appID, pc) +} + func (s *pgxStore) SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, stripeInvoiceID string) error { // 0 rows = the guard was already armed (first-charge-wins) — not an error: // the deterministic Stripe idem keys guarantee the concurrent charger @@ -983,6 +1072,13 @@ func (s *pgxStore) SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, return err } +func (s *pgxStore) SetAppProrationSkipped(ctx context.Context, appID uuid.UUID) error { + // 0 rows = already marked, or already charged in the meantime — neither is + // an error: the marker is a one-shot, first-write-wins terminal state. + _, err := s.q.SetAppProrationSkipped(ctx, appID.String()) + return err +} + func (s *pgxStore) SetAppModuleCount(ctx context.Context, appID uuid.UUID, moduleCount int) error { // 0 rows = the app is deleted (count frozen, D1e); existence was already // checked by the service via AppMirror, so this is a legitimate no-op. diff --git a/internal/account/cycle/store_prorationlock_integration_test.go b/internal/account/cycle/store_prorationlock_integration_test.go new file mode 100644 index 0000000..8c4a252 --- /dev/null +++ b/internal/account/cycle/store_prorationlock_integration_test.go @@ -0,0 +1,142 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// ChargeProrationLocked (finding 3, store.go) — verifies against a REAL +// Postgres that the row lock is released BEFORE the (simulated) Stripe network +// call, not held across it: a concurrent SyncAppModules/MarkAppDeleted write +// to the SAME row must complete promptly while the charge callback is still +// in flight, never blocking behind it. + +func mkProrationCharge(acct, appID uuid.UUID, invID string, periodEnd time.Time) *cycle.ProrationCharge { + return &cycle.ProrationCharge{ + InvoiceID: invID, + Cents: 200, + Invoice: cycle.InvoiceMirror{ + AccountID: acct, StripeInvoiceID: invID, Status: "open", + AmountDueCents: 200, Currency: "usd", + PeriodStart: periodEnd.AddDate(0, 0, -3), PeriodEnd: periodEnd, + }, + Snapshot: cycle.AppBaseSnapshot{ + AppID: appID, PeriodStart: periodEnd.AddDate(0, -1, 0), PeriodEnd: periodEnd, + ModuleCount: 0, BaseMicros: 2_000_000, + }, + } +} + +func TestChargeProrationLocked_Integration_LockNotHeldAcrossStripeCall(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + + insideCallback := make(chan struct{}) + release := make(chan struct{}) + chargeDone := make(chan error, 1) + + go func() { + _, _, err := store.ChargeProrationLocked(ctx, appID, func(locked cycle.AppMirror) (*cycle.ProrationCharge, error) { + close(insideCallback) // signal: the phase-1 lock has already been read + released + <-release // simulate a slow Stripe HTTP call + return mkProrationCharge(acct, appID, "in_slow", mustTime(t, "2026-07-04T00:00:00Z")), nil + }) + chargeDone <- err + }() + + select { + case <-insideCallback: + case <-time.After(5 * time.Second): + t.Fatal("charge callback never started") + } + + // A concurrent write to the SAME row must complete promptly — it must NOT + // be blocked behind the "Stripe call" the callback is simulating. Under the + // pre-fix code (the lock held across the callback via one long-lived FOR + // UPDATE transaction) this write would hang until `release` is closed. + writeDone := make(chan error, 1) + go func() { writeDone <- store.SetAppModuleCount(ctx, appID, 42) }() + + select { + case err := <-writeDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("SetAppModuleCount blocked behind the in-flight charge callback — the row lock is still held across it") + } + + close(release) + require.NoError(t, <-chargeDone) + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 42, app.ModuleCount, "the concurrent write landed") + require.Equal(t, "in_slow", app.ProrationInvoiceID, "the charge still committed after release") +} + +func TestChargeProrationLocked_Integration_ConcurrentDeleteDoesNotBlockOnLock(t *testing.T) { + // Same shape as above, using MarkAppDeleted (the specific writer finding 3 + // calls out — SyncAppModules' soft-delete path) as the concurrent write. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + + insideCallback := make(chan struct{}) + release := make(chan struct{}) + chargeDone := make(chan error, 1) + + go func() { + _, _, err := store.ChargeProrationLocked(ctx, appID, func(locked cycle.AppMirror) (*cycle.ProrationCharge, error) { + close(insideCallback) + <-release + return mkProrationCharge(acct, appID, "in_slow_del", mustTime(t, "2026-07-04T00:00:00Z")), nil + }) + chargeDone <- err + }() + + select { + case <-insideCallback: + case <-time.After(5 * time.Second): + t.Fatal("charge callback never started") + } + + deleteDone := make(chan error, 1) + go func() { deleteDone <- store.MarkAppDeleted(ctx, appID) }() + + select { + case err := <-deleteDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("MarkAppDeleted blocked behind the in-flight charge callback — the row lock is still held across it") + } + + close(release) + require.NoError(t, <-chargeDone) + + // Judgment call (documented in store.go / proration.go and the PR + // description): once the Stripe charge has already succeeded, a delete that + // raced in during the (now-released) window does NOT unwind it — D1e + // already forbids refunds, and the money has already moved. The invoice / + // snapshot / guard are persisted regardless of the app's deleted_at. + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.True(t, app.Deleted, "the delete committed") + require.Equal(t, "in_slow_del", app.ProrationInvoiceID, "the already-succeeded Stripe charge is still recorded, not silently dropped") +} diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 052be1c..c846c4a 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -18,6 +18,7 @@ FROM ms_billing.apps WHERE created_at <= $1 AND proration_invoice_id IS NULL AND deleted_at IS NULL + AND proration_skipped_at IS NULL ORDER BY created_at ` @@ -25,9 +26,11 @@ ORDER BY created_at // have survived the grace window ($1 = now() − GraceDays) and were never charged // their creation-period base. proration_invoice_id IS NULL is the one-shot guard // (an already-charged app drops out); deleted_at IS NULL excludes apps -// soft-deleted within grace (they are NEVER charged). Ordered by created_at so -// the oldest pending app charges first. Backed by the partial index -// apps_pending_proration_idx (migration 029). +// soft-deleted within grace (they are NEVER charged); proration_skipped_at IS +// NULL excludes apps permanently skipped as a would-be retroactive catch-up +// (migration 031, D1d). Ordered by created_at so the oldest pending app +// charges first. Backed by the partial index apps_pending_proration_idx +// (migrations 029/031). func (q *Queries) AppsPendingProration(ctx context.Context, createdAt time.Time) ([]string, error) { rows, err := q.db.Query(ctx, appsPendingProration, createdAt) if err != nil { @@ -85,8 +88,8 @@ 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_at) -VALUES ($1, $2, $3, $4) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) +VALUES ($1, $2, $3, $3, $4) ON CONFLICT (app_id) DO NOTHING ` @@ -103,10 +106,14 @@ type InsertAppMirrorParams struct { // the installed-module-count snapshot, and the one-shot proration guard. // InsertAppMirror registers an app row idempotently: ON CONFLICT (app_id) DO // NOTHING so a RegisterApp retry (or a concurrent double-fire) never rewrites -// created_at / module_count / the proration guard of the original insert — -// the FIRST registration's values are the stable proration anchor. :execrows -// so the caller can tell a fresh insert (1) from a retry no-op (0), though -// both are success. +// created_at / module_count / created_module_count / the proration guard of +// the original insert — the FIRST registration's values are the stable +// proration anchor. created_module_count is stamped from the SAME $3 value as +// module_count and NEVER written again by any other query (migration 030) — +// it is the frozen count ChargeCreationProration prices its historical window +// 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. func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams) (int64, error) { result, err := q.db.Exec(ctx, insertAppMirror, arg.AppID, @@ -270,7 +277,8 @@ func (q *Queries) SelectAppBaseSnapshot(ctx context.Context, arg SelectAppBaseSn } const selectAppMirror = `-- name: SelectAppMirror :one -SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 ` @@ -279,8 +287,10 @@ type SelectAppMirrorRow struct { AppID string `json:"app_id"` AccountID string `json:"account_id"` ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` } @@ -294,15 +304,18 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM &i.AppID, &i.AccountID, &i.ModuleCount, + &i.CreatedModuleCount, &i.CreatedAt, &i.ProrationInvoiceID, + &i.ProrationSkippedAt, &i.DeletedAt, ) return i, err } const selectAppMirrorForUpdate = `-- name: SelectAppMirrorForUpdate :one -SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 FOR UPDATE @@ -312,19 +325,21 @@ type SelectAppMirrorForUpdateRow struct { AppID string `json:"app_id"` AccountID string `json:"account_id"` ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` } // SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — -// the creation-proration charge's race-safety primitive. In ONE transaction the -// charge locks the row here, re-verifies deleted_at IS NULL and -// proration_invoice_id IS NULL under the lock, performs the Stripe charge, and -// arms the guard. A concurrent SyncAppModules soft-delete (MarkAppDeleted) is -// thereby mutually exclusive: whichever of {delete, charge} commits first wins -// and the loser blocks on the lock, then sees the winner's write and no-ops — so -// a within-grace delete can never coincide with a charge (no refund path, D1e). +// the creation-proration charge's race-safety primitive. The charge locks the +// row here just long enough to re-verify deleted_at IS NULL and +// proration_invoice_id IS NULL and read the frozen created_module_count, +// releasing the lock immediately after (ChargeProrationLocked runs the actual +// Stripe network call OUTSIDE this lock — see store.go); a concurrent +// SyncAppModules soft-delete (MarkAppDeleted) only ever contends for the brief +// read, never for the duration of a Stripe HTTP call. func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (SelectAppMirrorForUpdateRow, error) { row := q.db.QueryRow(ctx, selectAppMirrorForUpdate, appID) var i SelectAppMirrorForUpdateRow @@ -332,8 +347,10 @@ func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (S &i.AppID, &i.AccountID, &i.ModuleCount, + &i.CreatedModuleCount, &i.CreatedAt, &i.ProrationInvoiceID, + &i.ProrationSkippedAt, &i.DeletedAt, ) return i, err @@ -389,6 +406,29 @@ func (q *Queries) SetAppProrationInvoice(ctx context.Context, arg SetAppProratio return result.RowsAffected(), nil } +const setAppProrationSkipped = `-- name: SetAppProrationSkipped :execrows +UPDATE ms_billing.apps +SET proration_skipped_at = now() +WHERE app_id = $1 + AND proration_skipped_at IS NULL + AND proration_invoice_id IS NULL +` + +// SetAppProrationSkipped arms the PERMANENT skip marker (migration 031, D1d): +// the account only activated at/after this app's anchored creation period had +// already closed, so charging it now would be a retroactive catch-up. The +// WHERE clause makes it idempotent (a re-evaluation, or a concurrent one, +// affects 0 rows once set) and defensively refuses to mark an app that was +// somehow already charged in the meantime. :execrows so the caller can +// observe (and tolerate) the already-set / already-charged cases. +func (q *Queries) SetAppProrationSkipped(ctx context.Context, appID string) (int64, error) { + result, err := q.db.Exec(ctx, setAppProrationSkipped, appID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const upsertProrationBaseSnapshot = `-- name: UpsertProrationBaseSnapshot :exec INSERT INTO ms_billing.app_base_snapshots (app_id, period_start, period_end, module_count, base_micros, source) diff --git a/internal/account/db/models.go b/internal/account/db/models.go index d9e8e21..83ae068 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -352,6 +352,10 @@ type MsBillingApp struct { ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` UpdatedAt time.Time `json:"updated_at"` + // Module count frozen at RegisterApp time (immutable — SyncAppModules never writes this column). ChargeCreationProration prices the creation-period window off THIS value, never the live module_count. + CreatedModuleCount int32 `json:"created_module_count"` + // Set once (never unset) when ChargeCreationProration determines the account only activated at/after this app's anchored creation period had already closed — a would-be retroactive catch-up charge (D1d). The app is permanently excluded from the proration sweep from then on. + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` } type MsBillingAppBaseSnapshot struct { diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index a8cd433..d70997d 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -5,33 +5,39 @@ -- InsertAppMirror registers an app row idempotently: ON CONFLICT (app_id) DO -- NOTHING so a RegisterApp retry (or a concurrent double-fire) never rewrites --- created_at / module_count / the proration guard of the original insert — --- the FIRST registration's values are the stable proration anchor. :execrows --- so the caller can tell a fresh insert (1) from a retry no-op (0), though --- both are success. +-- created_at / module_count / created_module_count / the proration guard of +-- the original insert — the FIRST registration's values are the stable +-- proration anchor. created_module_count is stamped from the SAME $3 value as +-- module_count and NEVER written again by any other query (migration 030) — +-- it is the frozen count ChargeCreationProration prices its historical window +-- 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: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_at) -VALUES ($1, $2, $3, $4) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) +VALUES ($1, $2, $3, $3, $4) 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_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, deleted_at FROM ms_billing.apps WHERE app_id = $1; -- SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — --- the creation-proration charge's race-safety primitive. In ONE transaction the --- charge locks the row here, re-verifies deleted_at IS NULL and --- proration_invoice_id IS NULL under the lock, performs the Stripe charge, and --- arms the guard. A concurrent SyncAppModules soft-delete (MarkAppDeleted) is --- thereby mutually exclusive: whichever of {delete, charge} commits first wins --- and the loser blocks on the lock, then sees the winner's write and no-ops — so --- a within-grace delete can never coincide with a charge (no refund path, D1e). +-- the creation-proration charge's race-safety primitive. The charge locks the +-- row here just long enough to re-verify deleted_at IS NULL and +-- proration_invoice_id IS NULL and read the frozen created_module_count, +-- releasing the lock immediately after (ChargeProrationLocked runs the actual +-- Stripe network call OUTSIDE this lock — see store.go); a concurrent +-- 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_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 FOR UPDATE; @@ -40,15 +46,18 @@ FOR UPDATE; -- have survived the grace window ($1 = now() − GraceDays) and were never charged -- their creation-period base. proration_invoice_id IS NULL is the one-shot guard -- (an already-charged app drops out); deleted_at IS NULL excludes apps --- soft-deleted within grace (they are NEVER charged). Ordered by created_at so --- the oldest pending app charges first. Backed by the partial index --- apps_pending_proration_idx (migration 029). +-- soft-deleted within grace (they are NEVER charged); proration_skipped_at IS +-- NULL excludes apps permanently skipped as a would-be retroactive catch-up +-- (migration 031, D1d). Ordered by created_at so the oldest pending app +-- charges first. Backed by the partial index apps_pending_proration_idx +-- (migrations 029/031). -- name: AppsPendingProration :many SELECT app_id FROM ms_billing.apps WHERE created_at <= $1 AND proration_invoice_id IS NULL AND deleted_at IS NULL + AND proration_skipped_at IS NULL ORDER BY created_at; -- SetAppProrationInvoice arms the ONE-SHOT proration guard: it records the @@ -62,6 +71,20 @@ SET proration_invoice_id = $2 WHERE app_id = $1 AND proration_invoice_id IS NULL; +-- SetAppProrationSkipped arms the PERMANENT skip marker (migration 031, D1d): +-- the account only activated at/after this app's anchored creation period had +-- already closed, so charging it now would be a retroactive catch-up. The +-- WHERE clause makes it idempotent (a re-evaluation, or a concurrent one, +-- affects 0 rows once set) and defensively refuses to mark an app that was +-- somehow already charged in the meantime. :execrows so the caller can +-- observe (and tolerate) the already-set / already-charged cases. +-- name: SetAppProrationSkipped :execrows +UPDATE ms_billing.apps +SET proration_skipped_at = now() +WHERE app_id = $1 + AND proration_skipped_at IS NULL + AND proration_invoice_id IS NULL; + -- SetAppModuleCount snapshots a new installed-module count (SyncAppModules). -- WHERE deleted_at IS NULL makes a count update on a deleted app a no-op -- (D1e — a deleted app accrues no future base, so its count is frozen). diff --git a/internal/account/usage/account_bill_integration_test.go b/internal/account/usage/account_bill_integration_test.go index ff58f6a..200904f 100644 --- a/internal/account/usage/account_bill_integration_test.go +++ b/internal/account/usage/account_bill_integration_test.go @@ -31,8 +31,8 @@ func seedMirrorApp(t *testing.T, pool *pgxpool.Pool, acct, app uuid.UUID, create del = deletedAt } _, err := pool.Exec(context.Background(), - `INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_at, deleted_at) - VALUES ($1, $2, 0, $3, $4)`, + `INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, deleted_at) + VALUES ($1, $2, 0, 0, $3, $4)`, app.String(), acct.String(), createdAt, del) require.NoError(t, err) } diff --git a/migrations/billing/030_apps_created_module_count.down.sql b/migrations/billing/030_apps_created_module_count.down.sql new file mode 100644 index 0000000..316524c --- /dev/null +++ b/migrations/billing/030_apps_created_module_count.down.sql @@ -0,0 +1,6 @@ +-- Down for 030 — drop the frozen creation-time module-count column. Reverting +-- returns ChargeCreationProration to pricing off the live module_count (the +-- pre-030, retroactive-tier-drift posture). + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS created_module_count; diff --git a/migrations/billing/030_apps_created_module_count.up.sql b/migrations/billing/030_apps_created_module_count.up.sql new file mode 100644 index 0000000..7ae2ddd --- /dev/null +++ b/migrations/billing/030_apps_created_module_count.up.sql @@ -0,0 +1,40 @@ +-- Migration 030 — freeze the module count the creation-proration charge prices +-- (review finding, creation-grace PR #46). +-- +-- ms_billing.apps.module_count is a LIVE snapshot: SyncAppModules overwrites it +-- on every install/uninstall. Under creation grace (migration 029) the +-- creation-proration charge no longer fires synchronously at RegisterApp — it +-- is delayed until the app survives GraceDays and the sweep gets to it, which +-- can be days after creation. A customer is free to install/uninstall modules +-- via SyncAppModules during that window, so by the time the sweep reads +-- module_count it may no longer be the count that actually applied on the +-- historical creation-period days being priced — the charge would retroactively +-- apply a tier that never existed on those days. +-- +-- created_module_count freezes the count AT REGISTRATION (RegisterApp's INSERT +-- — the same instant that stamps created_at) and is NEVER touched again: it is +-- absent from every SyncAppModules write path. The creation-proration charge +-- (ChargeCreationProration) prices its historical window off THIS column; +-- module_count remains exactly what it was — the LIVE count the boundary +-- advance leg (and the display read for all FUTURE periods) uses. +-- +-- Backfill: for rows that predate this migration, the live module_count is the +-- best available approximation of what applied at creation (no per-day history +-- exists to reconstruct it precisely) — acceptable because every pre-migration +-- row not yet proration-charged, if any, is still within its grace/pending +-- window and its module_count has had comparatively little time to drift. + +ALTER TABLE ms_billing.apps + ADD COLUMN created_module_count INT NULL CHECK (created_module_count >= 0); + +UPDATE ms_billing.apps + SET created_module_count = module_count + WHERE created_module_count IS NULL; + +ALTER TABLE ms_billing.apps + ALTER COLUMN created_module_count SET NOT NULL; + +COMMENT ON COLUMN ms_billing.apps.created_module_count IS + 'Module count frozen at RegisterApp time (immutable — SyncAppModules never ' + 'writes this column). ChargeCreationProration prices the creation-period ' + 'window off THIS value, never the live module_count.'; diff --git a/migrations/billing/031_apps_proration_skipped.down.sql b/migrations/billing/031_apps_proration_skipped.down.sql new file mode 100644 index 0000000..3b59e3d --- /dev/null +++ b/migrations/billing/031_apps_proration_skipped.down.sql @@ -0,0 +1,12 @@ +-- Down for 031 — drop the permanent-skip marker and restore the migration-029 +-- partial index predicate (deleted_at IS NULL AND proration_invoice_id IS +-- NULL only). + +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL AND proration_invoice_id IS NULL; + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS proration_skipped_at; diff --git a/migrations/billing/031_apps_proration_skipped.up.sql b/migrations/billing/031_apps_proration_skipped.up.sql new file mode 100644 index 0000000..d2165e2 --- /dev/null +++ b/migrations/billing/031_apps_proration_skipped.up.sql @@ -0,0 +1,46 @@ +-- Migration 031 — permanent-skip marker for the creation-proration charge +-- (review finding, creation-grace PR #46, D1d). +-- +-- D1d: v1 never retroactively catches up an app's creation-period base once +-- the window it covers has already closed with the account never having been +-- chargeable during it (never activated). Pre-grace, RegisterApp charged +-- synchronously at creation, so this could only happen if activation itself +-- landed after the creation period ended — and the code skipped the charge +-- (0 cents, guard left unarmed) in exactly that case. +-- +-- Creation grace (migration 029) removed that gate outright when it moved the +-- charge into the async sweep, reasoning (wrongly, in general) that the +-- creation period is billed by no other leg so charging it whenever the guard +-- is unarmed "can never double-bill". That reasoning misses D1d: an app whose +-- account sat unactivated across the app's ENTIRE creation period and only +-- activates months later would, on the very next sweep, be retroactively +-- billed for a period it was never eligible to be charged for. +-- +-- proration_skipped_at records that this decision was made and is PERMANENT: +-- once an app's creation-proration charge is determined to be a would-be +-- retroactive catch-up (the account only activated at/after the app's +-- anchored creation period had already closed), the app is marked here and +-- dropped from the sweep's pending work list for good — never charged, and +-- never re-evaluated on every future sweep (which would otherwise happen +-- forever, since proration_invoice_id would stay NULL). +-- +-- The apps_pending_proration_idx partial index (migration 029) is redefined to +-- add the same predicate so the sweep's work-list query keeps hitting the +-- index. + +ALTER TABLE ms_billing.apps + ADD COLUMN proration_skipped_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.apps.proration_skipped_at IS + 'Set once (never unset) when ChargeCreationProration determines the ' + 'account only activated at/after this app''s anchored creation period had ' + 'already closed — a would-be retroactive catch-up charge (D1d). The app ' + 'is permanently excluded from the proration sweep from then on.'; + +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL + AND proration_invoice_id IS NULL + AND proration_skipped_at IS NULL; From 64eebfaee3237844e1b7254eba8bd5a2fa2e12a2 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 09:24:53 +0800 Subject: [PATCH 08/30] feat: per-module-instance overage timers + Leg 1 grace charge (migration 033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the account-wide single-timer pooled overage model (migration 032, superseded) with one independently-anchored grace timer per module INSTALL EVENT, each priced from its own install date. DESIGN.md "Base fee — v2: creation grace + per-module overage timers". Migration 033 (born clean; fully replaces 032's account-wide schema since it never shipped to main): - new table ms_billing.app_module_overage_timers (surrogate id, account_id, app_id, installed_at [FIFO key + proration anchor], grace_expires_at, removed_at, grace_charged_at, grace_resolved, grace_invoice_id, grace_invoice_item_id) + 3 partial indexes (live-FIFO, sweep work-list, LIFO removal); - up drops accounts.overage_since + account_overage_snapshots; down recreates them verbatim from 032 so up/down/up round-trips cleanly. Instance synthesis (RegisterApp / SyncAppModules, no api-platform change — the RPC still carries an integer module_count): - RegisterApp(count=K) inserts K timers anchored installed_at = created_at; - SyncAppModules N→M inserts M−N at now() (grow) or LIFO soft-removes N−M newest (shrink); app delete soft-removes all live timers. Reconcile against the LIVE timer count so both RPCs stay idempotent + self-healing on retry. Live FIFO + Leg 1 (cycle/overage.go, replaces the account-wide sweep entirely): - included-vs-over computed fresh at every grace-check via LiveModuleTimerRankBefore (order installed_at,id; first IncludedModules are included); - exploiting monotonicity, an "included" verdict is PERMANENT (grace_resolved) — never charged, never re-checked; - an "over" timer past its own grace is charged $3 prorated from installed_at's UTC day to its INSTALL period's end (install-anchored — the correction vs. the prior grace-elapse-anchored attempt; also keeps Leg 1 within the install period so scenario 6's boundary precharge can't double-bill), via a per-timer Stripe invoice (deterministic keys mod-overage-ii/inv-), storing the REAL Stripe item id (not the idem-key string). Auto-collect disclosure flag (migration 034) resolved post-charge at this site too. charge.go boundary leg drops the pooled-overage term (base-only advance now); GetAccountBill's account-overage line shows the live steady-state estimate. cmd/billing-cycle drives SweepModuleOverage. Tests: scenario 4 exact numbers (two modules a day apart → $2.40 on June 13 and $2.30 on June 14, install-anchored proration), FIFO monotonicity / permanent inclusion, over→included flip-before-grace not charged, removed-in-grace never charged, no-PM skip+retry, unactivated never swept, LIFO shrink; plus a migration-033 integration test exercising the real timer SQL end-to-end. Full build / vet / test / gofmt green; sqlc regenerated. Co-Authored-By: Claude Opus 4.8 --- cmd/billing-cycle/main.go | 66 +- internal/account/cycle/apps.go | 111 ++- internal/account/cycle/apps_test.go | 72 +- internal/account/cycle/charge.go | 192 ++--- .../cycle/migration033_integration_test.go | 96 +++ internal/account/cycle/overage.go | 586 +++++--------- internal/account/cycle/overage_test.go | 717 +++++++----------- internal/account/cycle/service_test.go | 243 +++--- internal/account/cycle/store.go | 283 +++---- internal/account/cycle/types.go | 20 +- internal/account/db/models.go | 28 +- internal/account/db/module_timers.sql.go | 248 ++++++ internal/account/db/overage.sql.go | 265 +------ internal/account/db/queries/module_timers.sql | 108 +++ internal/account/db/queries/overage.sql | 128 +--- internal/account/usage/accountbill.go | 40 +- internal/account/usage/service_test.go | 35 +- internal/account/usage/store.go | 42 +- internal/account/usage/types.go | 14 +- .../033_app_module_overage_timers.down.sql | 27 + .../033_app_module_overage_timers.up.sql | 109 +++ 21 files changed, 1573 insertions(+), 1857 deletions(-) create mode 100644 internal/account/cycle/migration033_integration_test.go create mode 100644 internal/account/db/module_timers.sql.go create mode 100644 internal/account/db/queries/module_timers.sql create mode 100644 migrations/billing/033_app_module_overage_timers.down.sql create mode 100644 migrations/billing/033_app_module_overage_timers.up.sql diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index a4fc5a4..5e9bdb5 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -155,11 +155,11 @@ type cycleResult struct { 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) + // Mid-period per-module overage grace sweep (migration 033, Leg 1). + OverageCandidates int // install timers past their grace window this sweep evaluated + OverageCharged int // "over" installs whose overage was invoiced mid-period + OverageSkipped int // evaluated but not charged (resolved-included / no PM / 0 cents) + OverageFailed int // per-timer overage-charge errors (counted, never abort) } // runCycle closes every card-bound account's just-ended ANCHORED period as of @@ -256,49 +256,37 @@ 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. + // Mid-period PER-MODULE overage grace sweep (migration 033, Leg 1): + // independent of the boundary close above. Every per-module install timer + // whose OWN 3-day grace has elapsed is evaluated LIVE by FIFO — the "over" + // installs are charged $3 prorated from their install date (a deliberate + // mid-period charge), the "included" ones resolved permanently. Idempotent per + // timer via the grace_resolved guard + the deterministic per-timer 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). +// runOverageSweep runs Leg 1's per-module overage grace sweep as of `at`. A +// single timer's error is logged + counted inside the sweep but never aborts the +// batch (the next sweep retries it through the same deterministic Stripe keys). func runOverageSweep(ctx context.Context, svc *cycle.Service, at time.Time, res *cycleResult) { - cands, err := svc.AccountsInOverageGrace(ctx, at) + sweep, err := svc.SweepModuleOverage(ctx, at) if err != nil { - slog.ErrorContext(ctx, "list overage-grace accounts failed", "error", err) + slog.ErrorContext(ctx, "module overage sweep failed", "as_of", at, "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) - } + res.OverageCandidates = sweep.Pending + res.OverageCharged = sweep.Charged + // "Skipped" folds the resolved-as-included verdicts and the transient no-PM / + // zero-cent skips — everything evaluated but not charged this sweep. + res.OverageSkipped = sweep.Included + sweep.Skipped + res.OverageFailed = sweep.Failed + res.Failed += sweep.Failed + slog.InfoContext(ctx, "module overage sweep complete", + "as_of", at, "pending", sweep.Pending, "charged", sweep.Charged, + "included", sweep.Included, "skipped", sweep.Skipped, "failed", sweep.Failed) } // tally classifies one account's charge summary for the run totals + a diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 61e5fbe..3b89e43 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -99,14 +99,14 @@ type SyncAppModulesResponse struct { // visibility). ProrationCents is never set here (no charge happens in this RPC). // api-platform fires this fire-and-forget with retry; every step is idempotent. // -// 3. AFTER the mirror insert, recompute the account's pooled-overage grace -// timer (recomputeAccountOverage): a creation whose module_count pushes the -// account's live pool over IncludedModules arms accounts.overage_since -// (migration 032). This is independent of the deferred creation-proration -// base charge (which the grace sweep fires later, and which bills the FLAT -// base only — module overage is account-wide pooled, migration 032). -// Idempotent (Start/Clear are first-crossing-wins / no-op), so a retry -// re-runs it harmlessly. +// Finally, AFTER the mirror insert, it synthesizes K per-module install timers +// (migration 033) anchored at created_at — one row per co-created module, each on +// its own independent overage grace timer. Reconcile-to-target (insert only the +// deficit vs. the app's live timer count) keeps it idempotent across +// fire-and-forget retries and self-heals a crashed first attempt. The timers are +// charged (if "over") only later, by the Leg 1 sweep (overage.go), and only after +// each survives its OWN grace — independent of the deferred creation-proration +// base charge (proration.go). func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*RegisterAppResponse, error) { if req.OwnerUserID == uuid.Nil && req.OwnerOrgID == uuid.Nil { return nil, billing.InvalidInput("owner_user_id or owner_org_id required") @@ -160,19 +160,36 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg ProrationInvoiceID: app.ProrationInvoiceID, // "" until the sweep charges } - // Recompute the account's pooled overage timer (migration 032) after the - // insert: the new app's module_count may push the account's live pool over - // IncludedModules (arming accounts.overage_since). RegisterApp charges - // NOTHING here (creation grace) — the FLAT-base creation proration is the - // grace sweep's job (proration.go), and the module overage rides the same - // grace mechanism. 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 { + // Synthesize the app's per-module install timers (migration 033), all anchored + // at the app's created_at (the read-back mirror's stable first-registration + // value) — the RPC carries only an integer module_count, so K identical timer + // rows stand in for the K co-created module instances. Insert-only reconcile + // makes a fire-and-forget retry a no-op once the first registration already + // synthesized them. + if err := s.ensureModuleTimers(ctx, app.AccountID, app.AppID, app.CreatedAt, app.ModuleCount); err != nil { return nil, err } return resp, nil } +// ensureModuleTimers brings an app's live install-timer set UP to targetCount by +// inserting the deficit, all anchored at installedAt (RegisterApp: created_at). +// Insert-only (never removes): reconcile-to-target so a fire-and-forget retry +// that re-runs after the first registration already synthesized the timers +// inserts 0, and a crashed first attempt self-heals on the retry. +func (s *Service) ensureModuleTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt time.Time, targetCount int) error { + live, err := s.store.LiveModuleTimerCountForApp(ctx, appID) + if err != nil { + return billing.Internal("live module timer count lookup failed", err) + } + if deficit := targetCount - live; deficit > 0 { + if err := s.store.InsertModuleOverageTimers(ctx, accountID, appID, installedAt, moduleGraceExpiry(installedAt), deficit); err != nil { + return billing.Internal("insert module overage timers failed", err) + } + } + return nil +} + // SyncAppModules updates an app's roster row: a new installed-module-count // snapshot and/or the soft-delete flag (D1b/D1e). Semantics: // @@ -183,14 +200,15 @@ 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). // -// 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 032). Dropping back under the pool clears the -// timer (no refund of anything already charged, D1e). +// After any module_count / delete write it synthesizes the app's per-module +// install timers (migration 033): a grow (M>N) inserts M−N new timers anchored +// at now (each a genuine new install on its OWN overage grace timer); a shrink +// (M live: + if err := s.store.InsertModuleOverageTimers(ctx, accountID, appID, now, moduleGraceExpiry(now), targetCount-live); err != nil { + return billing.Internal("insert module overage timers failed", err) + } + case targetCount < live: + if err := s.store.SoftRemoveNewestModuleTimers(ctx, appID, live-targetCount, now); err != nil { + return billing.Internal("soft-remove module overage timers failed", err) + } + } + return nil +} diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index a08c751..ea46411 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -75,25 +75,28 @@ func TestRegisterApp_MirrorsRowWithoutCharging(t *testing.T) { require.Equal(t, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), app.CreatedAt) require.Empty(t, app.ProrationInvoiceID) - // 3 modules ≤ the pooled 5 → the overage grace timer stays disarmed. - require.NotContains(t, store.overageSince, acct) + // 3 modules → 3 per-module install timers synthesized at created_at, all live, + // none charged (each on its own independent grace timer). + require.Equal(t, 3, liveTimerCount(store, appID)) + require.Empty(t, sc.itemCalls) } -func TestRegisterApp_ArmsOverageTimerWhenPoolCrossesButNeverCharges(t *testing.T) { - // Creation grace + account-wide pool (migration 032): a create whose - // module_count pushes the account pool over IncludedModules ARMS the pooled - // overage grace timer (accounts.overage_since) but still charges NOTHING at - // registration — both the flat base AND the pooled overage are the grace - // sweep's job once the app survives. +func TestRegisterApp_SynthesizesPerModuleTimersButNeverCharges(t *testing.T) { + // Creation grace + per-module timers (migration 033): a create with K modules + // synthesizes K install timers anchored at created_at (each with its own + // 3-day grace) but charges NOTHING at registration — the flat base is the + // grace sweep's job and each module's overage is Leg 1's, once it survives. store := newFakeStore() - user, acct := registeredAccount(store) + user, _ := registeredAccount(store) sc := newFakeStripe() + appID := uuid.New() + created := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ OwnerUserID: user, - AppID: uuid.New(), + AppID: appID, ModuleCount: 7, - CreatedAt: time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), + CreatedAt: created, }) require.NoError(t, err) @@ -103,8 +106,14 @@ func TestRegisterApp_ArmsOverageTimerWhenPoolCrossesButNeverCharges(t *testing.T require.Empty(t, sc.itemCalls) require.Empty(t, sc.invoiceCalls) - // But the 7-module create pushed the account pool over 5, arming the timer. - require.Contains(t, store.overageSince, acct, "crossing the pooled 5 arms overage_since") + // 7 timers synthesized, all anchored at created_at with grace = created + 3d. + require.Equal(t, 7, liveTimerCount(store, appID)) + for _, tm := range store.timers { + require.Equal(t, created, tm.installedAt) + require.Equal(t, created.AddDate(0, 0, 3), tm.graceExpiresAt) + require.False(t, tm.removed) + require.False(t, tm.graceResolved) + } } func TestRegisterApp_DefaultsCreatedAtToNow(t *testing.T) { @@ -341,11 +350,11 @@ func seedAppCreated(store *fakeStore, accountID uuid.UUID, moduleCount int, dele return id } -func TestRunBillingCycle_InvoicesUsagePlusAdvanceBasePlusPooledOverage(t *testing.T) { - // Migration 032: 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-032 per-app tier - // for this single-account case, but split base-vs-overage differently. +func TestRunBillingCycle_InvoicesUsagePlusAdvanceBase(t *testing.T) { + // Under the per-module-instance overage model (migration 033) the boundary + // bills ONLY usage arrears + the FLAT advance base — module overage is NO + // longer charged here (it rides per-module grace timers, Leg 1). arrears 1e6 + // (usage) + 2 × flat $20 (40e6) = 41e6 → 4100 cents on ONE invoice. store := newFakeStore() store.chargedTotal = 1_000_000 store.hasPM = true @@ -358,14 +367,13 @@ func TestRunBillingCycle_InvoicesUsagePlusAdvanceBasePlusPooledOverage(t *testin require.NoError(t, err) require.Equal(t, cycle.RunStatusInvoiced, resp.Status) require.EqualValues(t, 1_000_000, resp.ArrearsMicros) - 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 + pooled overage pool into ONE line on ONE invoice") - require.EqualValues(t, 4_400, sc.itemCalls[0].amountCfg) + require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) // 2 × flat $20 + require.EqualValues(t, 4_100, resp.ChargedCents) + require.Len(t, sc.itemCalls, 1, "usage + base pool into ONE line on ONE invoice") + require.EqualValues(t, 4_100, sc.itemCalls[0].amountCfg) // 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). + // the NEW window [Jul 1, Aug 1) — the FLAT base (overage is not billed here). fs, ok := store.baseSnapshots[snapKey{flat, periodEnd}] require.True(t, ok) require.Equal(t, "advance", fs.source) @@ -373,16 +381,8 @@ func TestRunBillingCycle_InvoicesUsagePlusAdvanceBasePlusPooledOverage(t *testin 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, usage.BaseFeeMicros, ts.snap.BaseMicros, "per-app base is flat now") + require.EqualValues(t, usage.BaseFeeMicros, ts.snap.BaseMicros, "per-app base is flat") 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) { @@ -482,13 +482,13 @@ func TestRunBillingCycle_ExcludesAppCreatedInsideNewPeriod(t *testing.T) { // 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. 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. + // apps contribute the FLAT $20 base (2 × 20e6). Module overage is NOT billed + // at the boundary anymore (it rides per-module grace timers, Leg 1). resp, err = svc.RunBillingCycle(context.Background(), chargeAccount, periodEnd, periodEnd.AddDate(0, 1, 0), 0) require.NoError(t, err) 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") + require.EqualValues(t, 4_000, resp.ChargedCents, "2 × flat $20, no boundary overage") } func TestRunBillingCycle_ReclaimedRunNoDoubleBase(t *testing.T) { diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 2a9f020..53494c5 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -31,24 +31,20 @@ import ( // LIVE ms_billing.apps rows (deleted_at IS NULL — a deleted app stops // accruing base, D1e, though its usage arrears above still bill) that // EXISTED BEFORE the new period opened (created_at < the closed window's -// period_end) of the FLAT BaseFeeMicros (module overage is account-wide -// pooled now, migration 032 — no longer a per-app tier here). PLUS a -// SEPARATE account-level POOLED overage term for the CLOSING period ($3 × -// max(0, Σ live-app module_count − included)), charged ONCE only when the -// mid-period grace sweep did NOT already bill it (no -// account_overage_snapshots row for the period — the double-charge guard); -// when the boundary does charge it, it writes its own 'advance' snapshot. -// An app created INSIDE the new period is excluded — RegisterApp's -// creation-proration leg already charged its new-period base (full or -// prorated); it joins the advance leg at the NEXT boundary. module_count is -// snapshotted AT CHARGE TIME, and each billed app-period is frozen into -// ms_billing.app_base_snapshots (migration 028) so the display always shows -// what was invoiced. The allowance nets USAGE only, never the base/overage -// (it offsets ModuleUsage+Infra in the display math too). An account with -// NO mirror rows (pre-backfill) gets base 0 — exactly the pre-027 -// arrears-only invoice — until the api-platform backfill populates the -// roster. -// 4. arrears + base + pooled overage == 0 (ALL zero) → MarkBillingRun('invoiced') with NO +// period_end) of the FLAT BaseFeeMicros. Module overage is NO longer billed +// here — it rides per-module-instance grace timers (migration 033, Leg 1 in +// cycle/overage.go); the boundary per-module overage precharge for ongoing +// modules (scenario 6) is a Stage B follow-up. An app created INSIDE the new +// period is excluded — RegisterApp's creation-proration leg already charged +// its new-period base (full or prorated); it joins the advance leg at the +// NEXT boundary. module_count is snapshotted AT CHARGE TIME, and each billed +// app-period is frozen into ms_billing.app_base_snapshots (migration 028) so +// the display always shows what was invoiced. The allowance nets USAGE only, +// never the base (it offsets ModuleUsage+Infra in the display math too). An +// account with NO mirror rows (pre-backfill) gets base 0 — exactly the +// pre-027 arrears-only invoice — until the api-platform backfill populates +// the roster. +// 4. arrears + base == 0 (both zero) → MarkBillingRun('invoiced') with NO // Stripe call. We NEVER auto-create a Stripe Customer with nothing to // charge (design §4 Axis 4). // 5. no usable default PM → MarkBillingRun('skipped_no_pm'). The usage is @@ -146,70 +142,22 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("live app roster read failed", err) } - // Each live app contributes ONLY its FLAT base (migration 032 — module - // overage is account-wide pooled, no longer a per-app tier). The pooled - // module count for the ACCOUNT overage term below is Σ these apps' counts - // (the same roster the base bills — apps created inside the new period are - // excluded, so the closing period's pool is exact). + // Each live app contributes ONLY its FLAT base. Module overage is NO longer + // billed here — it rides per-module-instance grace timers (migration 033, + // Leg 1 in cycle/overage.go); the boundary per-module overage precharge for + // ongoing modules (scenario 6) is a Stage B follow-up. var advanceBase int64 - var pooledModuleCount int - for _, a := range apps { + for range apps { advanceBase += usage.BaseFeeMicros - pooledModuleCount += a.ModuleCount - } - - // SEPARATE account-level POOLED overage term for the closing period, charged - // ONCE per account. If this period's pooled overage already has an - // account_overage_snapshots row, its SOURCE decides how the boundary treats - // it — the cross-leg double-charge guard (PR #47 review): - // - // - source='grace': the mid-period sweep already CLAIMED it (pending or - // charged — a crash between Stripe succeeding and the row flipping to - // 'charged' must NOT let the boundary independently charge it too, so - // ANY row, not just a settled one, excludes it here); - // - source='advance': THIS boundary run's OWN prior attempt (a crash - // between claiming the period and MarkBillingRun succeeding reclaims the - // SAME run id) — reuse the FROZEN over_count/charged_micros so the retry - // recomputes the IDENTICAL combined total and the deterministic - // ii-/inv- Idempotency-Keys never see a different amount - // (finding #2 — the boundary retry livelock). - // - // No row yet (grace never expired within the period, or overage started too - // late for the sweep to run): compute the FULL pooled overage for the - // account; it is CLAIMED (a 'pending' row written BEFORE Stripe is called) - // further down, right before the charge — so every over-the-pool period is - // billed exactly once, crash-safe. - existingOverage, overageFound, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) - if err != nil { - return nil, billing.Internal("account overage snapshot lookup failed", err) - } - var ( - advanceOverage int64 - overCount int - overageAlreadyClaimed bool // true → no fresh claim-insert needed before charging - ) - switch { - case overageFound && existingOverage.Source == "grace": - overageAlreadyClaimed = true - case overageFound && existingOverage.Source == "advance": - advanceOverage = existingOverage.ChargedMicros - overCount = existingOverage.OverCount - overageAlreadyClaimed = true - default: - overCount = pooledModuleCount - usage.IncludedModules - if overCount > 0 { - advanceOverage = usage.AccountOverageMicros(pooledModuleCount) - } } - summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase, AccountOverageMicros: advanceOverage} + summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase} - // Zero-skip: only when arrears, base AND pooled overage are ALL zero - // (empty/zero period with no live apps) is there nothing to invoice — mark - // invoiced with NO Stripe call, never auto-create a Customer with nothing to - // charge. A zero total can never breach a limit/ceiling, so this - // short-circuits ahead of the risk gate. - if arrears == 0 && advanceBase == 0 && advanceOverage == 0 { + // Zero-skip: only when arrears AND base are both zero (empty/zero period with + // no live apps) is there nothing to invoice — mark invoiced with NO Stripe + // call, never auto-create a Customer with nothing to charge. A zero total can + // never breach a limit/ceiling, so this short-circuits ahead of the risk gate. + if arrears == 0 && advanceBase == 0 { if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero arrears) failed", err) } @@ -322,53 +270,18 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri _, newPeriodEnd = billingperiod.AnchoredPeriodWindow(periodEnd, anchorDay) } - // CLAIM the boundary's pooled-overage line BEFORE calling Stripe (the - // crash-safe marker — cycle/overage.go's header). Skipped when there is no - // overage to charge, or it is already claimed (source='grace' owns it, or - // this run's own reclaimed retry is reusing its own prior 'advance' claim). - if advanceOverage > 0 && !overageAlreadyClaimed { - inserted, err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ - AccountID: accountID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - OverCount: overCount, - ChargedMicros: advanceOverage, - Source: "advance", - Status: OverageSnapshotPending, - }) - if err != nil { - return nil, billing.Internal("account overage snapshot claim failed", err) - } - if !inserted { - // Lost the race to a concurrent claim (almost certainly the mid-period - // grace sweep) — re-read and defer to the winner: never charge an - // overage amount someone else already claimed. - winning, _, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) - if err != nil { - return nil, billing.Internal("account overage snapshot lookup failed", err) - } - advanceOverage, overCount = 0, 0 - if winning.Source == "advance" { - advanceOverage, overCount = winning.ChargedMicros, winning.OverCount - } - overageAlreadyClaimed = true - } - } - summary.AccountOverageMicros = advanceOverage - - // One invoice, one pooled line: closed period's netted usage arrears + the - // new period's advance base + the closing period's account-wide pooled - // overage, converted micros → whole cents ONCE at the Stripe boundary (a - // single deterministic rounding point for the total). - cents, err := centsFromMicros(arrears + advanceBase + advanceOverage) + // One invoice: closed period's netted usage arrears + the new period's advance + // base, converted micros → whole cents ONCE at the Stripe boundary (a single + // deterministic rounding point for the total). + cents, err := centsFromMicros(arrears + advanceBase) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } if cents == 0 { - // The claim-race adjustment above dropped the combined total to zero (a - // narrow concurrent-invocation edge case) — never call Stripe for $0. + // A sub-half-cent arrears total (an advance base, when present, is always ≥ + // $20 and can never round to 0) — never call Stripe for $0. if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { - return nil, billing.Internal("mark billing run (zero after overage claim race) failed", err) + return nil, billing.Internal("mark billing run (zero cents) failed", err) } summary.Status = RunStatusInvoiced return summary, nil @@ -377,7 +290,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // Charge. A failure after the PM gate marks the run 'failed' (auditable) and // returns the error. - inv, item, err := s.charge(ctx, runID, custID, cents, advanceBase > 0 || advanceOverage > 0) + inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -424,17 +337,6 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.Internal("invoice mirror upsert failed", err) } - // Flip the overage claim to 'charged' now that Stripe confirmed it, using - // the GENUINE Stripe invoice item id `item.ID` — never the ii- - // idempotency-key string (finding #4). Covers both a fresh claim (won - // above) and a reused prior 'advance' claim (a reclaimed retry) — both - // need the row settled once this combined charge succeeds. - if advanceOverage > 0 { - if err := s.store.MarkAccountOverageSnapshotCharged(ctx, accountID, periodStart, item.ID); err != nil { - return nil, billing.Internal("account overage snapshot mark-charged failed", err) - } - } - // Freeze what this boundary actually billed per app for the NEW window // (migration 028, source='advance'): the display's authoritative base for // the period, so a later SyncAppModules can never drift the shown base @@ -448,7 +350,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri PeriodStart: periodEnd, // the new period opens where the closed one ends PeriodEnd: newPeriodEnd, ModuleCount: a.ModuleCount, - BaseMicros: usage.BaseFeeMicros, // FLAT per-app base (overage is pooled, migration 032) + BaseMicros: usage.BaseFeeMicros, // FLAT per-app base (module overage rides per-module timers, migration 033) }); err != nil { return nil, billing.Internal("advance base snapshot insert failed", err) } @@ -463,27 +365,21 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return summary, nil } -// charge creates the Stripe invoice item + draft invoice for the boundary -// total (usage arrears + advance base + pooled overage), with the two -// deterministic Idempotency-Keys (ii-, inv-) so a re-run reuses the -// same Stripe objects. withBase only widens the line DESCRIPTION when the -// total includes an advance base fee — a pure-usage invoice keeps the -// historical line text. Returns the created invoice projection (id/status/ -// amounts) for the mirror upsert AND the created invoice item (the caller -// needs its GENUINE id to freeze into account_overage_snapshots when the -// combined line includes pooled overage — finding #4 — rather than the -// idempotency-key string the item was created with). -func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, billingstripe.InvoiceItem, error) { +// charge creates the Stripe invoice item + draft invoice for the boundary total +// (usage arrears + advance base), with the two deterministic Idempotency-Keys +// (ii-, inv-) so a re-run reuses the same Stripe objects. withBase only +// widens the line DESCRIPTION when the total includes an advance base fee — a +// pure-usage invoice keeps the historical line text. Returns the created invoice +// projection (id/status/amounts) for the mirror upsert. +func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { desc := fmt.Sprintf("MirrorStack usage — run %s", runID) if withBase { desc = fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) } - item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)) - if err != nil { - return billingstripe.Invoice{}, billingstripe.InvoiceItem{}, err + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { + return billingstripe.Invoice{}, err } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) - return inv, item, err + return s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) } // AccountsWithUsageEvents returns the accounts with raw usage_events in the diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go new file mode 100644 index 0000000..61a8a56 --- /dev/null +++ b/internal/account/cycle/migration033_integration_test.go @@ -0,0 +1,96 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 033 (ms_billing.app_module_overage_timers) — exercises the per-module +// install-timer SQL against a real Postgres: the generate_series bulk insert, the +// live-count reconcile input, the FIFO rank row-comparison, the past-grace sweep +// work-list join (activated_at gate), the terminal-mark first-write-wins guards, +// and LIFO / all soft-removal. +func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + // Activate so the sweep's activated_at IS NOT NULL gate passes. + _, err := pool.Exec(ctx, `UPDATE ms_billing.accounts SET activated_at = $2 WHERE id = $1`, + acct.String(), mustTime(t, "2026-05-04T00:00:00Z")) + require.NoError(t, err) + + app := uuid.New() + 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") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, early, early.AddDate(0, 0, 3), 5)) + over := mustTime(t, "2026-06-10T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, over, over.AddDate(0, 0, 3), 1)) + + n, err := store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 6, n) + + // Work list as of June 20 (all graces elapsed): all 6 candidates, activated. + cands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-06-20T00:00:00Z")) + require.NoError(t, err) + require.Len(t, cands, 6) + + // LIVE FIFO rank: the 5 early installs rank 0-4 (included); the June-10 install + // ranks 5 (over) — verifying the (installed_at, id) row-comparison in SQL. + var overCand cycle.ModuleOverageCandidate + included := 0 + for _, c := range cands { + rank, err := store.LiveModuleTimerRankBefore(ctx, c.AccountID, c.ID, c.InstalledAt) + require.NoError(t, err) + if rank < usage.IncludedModules { + included++ + } else { + overCand = c + } + } + require.Equal(t, usage.IncludedModules, included) + require.True(t, over.Equal(overCand.InstalledAt), "the June-10 install is the over one") + + // Terminal marks drop rows out of the work list. + require.NoError(t, store.MarkModuleTimerCharged(ctx, overCand.ID, mustTime(t, "2026-06-20T00:00:00Z"), "in_real_stripe", "ii_real_stripe")) + for _, c := range cands { + if c.ID != overCand.ID { + require.NoError(t, store.MarkModuleTimerIncluded(ctx, c.ID)) + } + } + cands2, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-06-20T00:00:00Z")) + require.NoError(t, err) + require.Empty(t, cands2, "resolved timers drop out of the work list") + + // Resolved rows stay LIVE (removed_at NULL) — resolution ≠ removal. + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 6, n) + + // LIFO soft-removal: add 3 more, remove the newest 2 → 6 + 3 − 2 = 7 live. + late := mustTime(t, "2026-07-01T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, late, late.AddDate(0, 0, 3), 3)) + require.NoError(t, store.SoftRemoveNewestModuleTimers(ctx, app, 2, late)) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 7, n) + + // SoftRemoveAll clears every remaining live timer. + require.NoError(t, store.SoftRemoveAllModuleTimersForApp(ctx, app, late)) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n) +} diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 9997ad0..d761254 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -1,481 +1,277 @@ package cycle -// Account-wide POOLED module overage (migration 032, owner spec 2026-07-05, -// confirmed reversal of the per-app overage tier). Overage moved from PER-APP -// to a single ACCOUNT-WIDE POOL of IncludedModules: overage = $3 × max(0, -// Σ live-app module_count − IncludedModules), charged ONCE per account per -// period. This file owns: +// Per-module-instance overage — Leg 1: the per-module grace charge (migration +// 033, DESIGN.md "Base fee — v2: creation grace + per-module overage timers"). +// This REPLACES the account-wide single-timer pooled overage sweep entirely. // -// - recomputeAccountOverage: after any module_count write (RegisterApp / -// SyncAppModules) it re-derives the pool and arms/clears the account's ONE -// grace timer (accounts.overage_since); -// - the mid-period GRACE SWEEP (ChargeAccountOverage + AccountsInOverageGrace): -// when the pool has been over for the full grace window, it charges the -// pooled overage prorated from grace-end to the period end — a DELIBERATE -// mid-period charge (the D1b "no mid-period charges" rule is now stale for -// the OVERAGE leg specifically; base-fee mid-period behavior is unchanged). +// The model: overage is no longer ONE account-level grace timer tiering on +// SUM(module_count); it is ONE independently-anchored 3-day grace timer per +// module INSTALL EVENT (ms_billing.app_module_overage_timers), each priced from +// its OWN install date. RegisterApp / SyncAppModules synthesize the timer rows +// (the RPC layer carries only an integer module_count); this file owns the +// mid-period sweep that charges them: // -// The boundary advance leg's pooled-overage term lives in charge.go alongside -// the base leg. Both legs guard against double-charging the same period through -// the account_overage_snapshots ledger (keyed (account_id, period_start)) plus -// the deterministic per-(account, period) Stripe Idempotency-Keys below — the -// same money-safety pattern app_base_snapshots gives the per-app base. +// - SweepModuleOverage lists the live, unresolved timers whose grace has +// elapsed (ModuleOverageTimersPastGrace) and runs ChargeModuleOverage on each. +// - ChargeModuleOverage determines, LIVE and fresh at every grace-check (never +// cached), whether the install is "included" or "over": across the account's +// currently-live timers ordered (installed_at ASC, id ASC), the first +// IncludedModules (5) are included, the rest are over. +// * included → mark grace_resolved, never charge. Monotonicity (a new +// install always gets the latest installed_at, so an existing row's rank +// can only improve over→included, never included→over) makes this a +// PERMANENT verdict — the row is never re-evaluated. +// * over → charge ModuleOverageFeeMicros ($3) prorated from the install's +// UTC day to the install period's end (install-anchored — the correction +// vs. the prior account-wide attempt, which anchored to grace-elapse), +// via a per-timer Stripe invoice with deterministic idem keys derived +// from the timer id, then stamp grace_charged_at / grace_resolved and the +// GENUINE Stripe ids. // -// CRASH-SAFETY (PR #47 review — the cross-leg double-charge finding): the -// ledger row is written as a claim BEFORE either leg calls Stripe -// (status='pending'; see migration 032), not only after Stripe succeeds. A -// crash between "Stripe succeeded" and "the row committed" used to leave NO -// row for the OTHER leg to see, so it would independently charge the SAME -// period's overage under a completely disjoint Idempotency-Key namespace — a -// real double charge. Now the claim is visible to both legs the instant it is -// written, before any money moves, so a crash anywhere in the sequence leaves -// evidence the other leg (and the same leg's own retry) can see and must -// respect: ANY row for the period — pending or charged — means "claimed, -// never independently charge it". +// The boundary per-module overage precharge for ongoing modules (scenario 6) and +// the combined creation-invoice overage line (scenario 3) are Stage B follow-ups. import ( "context" "fmt" - "strconv" + "log/slog" "time" "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/collection" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) -// overageGraceWindow is the ONE grace timer per account: when the pooled module -// count first crosses IncludedModules the timer starts (accounts.overage_since), -// and only after this window elapses does the mid-period sweep charge the -// overage. If the pool drops back to ≤ IncludedModules before it elapses the -// timer is cleared and nothing is charged. Owner spec 2026-07-05: 3 days. -const overageGraceWindow = 3 * 24 * time.Hour +// moduleOverageGraceWindow is the per-install grace window: a module's own timer +// starts at its install instant and its overage is only charged once this window +// has elapsed (owner spec 2026-07-05: 3 days, the SAME GraceDays as the creation +// grace). A module removed before its own grace elapses is never charged. +const moduleOverageGraceWindow = usage.GraceDays * 24 * time.Hour + +// moduleGraceExpiry is the single home of the "grace_expires_at = installed_at + +// window" rule, used by RegisterApp / SyncAppModules when they synthesize timer +// rows and (implicitly, via the stored column) by the sweep's work-list. +func moduleGraceExpiry(installedAt time.Time) time.Time { + return installedAt.Add(moduleOverageGraceWindow) +} -// OverageChargeStatus is the terminal classification of a ChargeAccountOverage -// attempt, for the cron sweep's tally + logging. -type OverageChargeStatus string +// ModuleOverageStatus is the terminal classification of one ChargeModuleOverage +// attempt, for the sweep's tally + per-timer log line. +type ModuleOverageStatus string const ( - // OverageCharged: the pooled overage was invoiced for the period. - OverageCharged OverageChargeStatus = "charged" - // OverageSkippedAlreadyCharged: this period's pooled overage already has an - // account_overage_snapshots row (a prior sweep or the boundary billed it). - OverageSkippedAlreadyCharged OverageChargeStatus = "already_charged" - // OverageSkippedUnderPool: the pool dropped back to ≤ IncludedModules by the - // time the sweep ran — nothing to charge; the timer is cleared. - OverageSkippedUnderPool OverageChargeStatus = "under_pool" - // OverageSkippedGraceHolding: the grace window has not elapsed yet (defensive - // — the work-list query already excludes these). - OverageSkippedGraceHolding OverageChargeStatus = "grace_holding" - // OverageSkippedZeroCents: the prorated overage rounded to 0 cents (grace-end - // lands at/after the period end, or a sub-cent remainder) — nothing to - // invoice this period; a later period picks it up. - OverageSkippedZeroCents OverageChargeStatus = "zero_cents" - // OverageSkippedNoPM: no usable default payment method — retained, re-attempted - // on the next sweep (the SAME per-(account, period) Stripe idem key stays - // stable), never a failure. - OverageSkippedNoPM OverageChargeStatus = "skipped_no_pm" - // OverageToppedUp: an ALREADY-charged period's pool grew further while the - // period was still open, and the sweep charged the incremental delta - // (finding #3, a judgment call — see topUpGraceOverage's doc). - OverageToppedUp OverageChargeStatus = "topped_up" + // ModuleOverageCharged: the install was "over" and its overage was invoiced. + ModuleOverageCharged ModuleOverageStatus = "charged" + // ModuleOverageIncluded: the live FIFO put the install within the included + // IncludedModules — resolved permanently, never charged. + ModuleOverageIncluded ModuleOverageStatus = "included" + // ModuleOverageSkippedNoPM: "over" but no usable default PM — left unresolved, + // re-attempted on the next sweep through the SAME per-timer idem keys. + ModuleOverageSkippedNoPM ModuleOverageStatus = "skipped_no_pm" + // ModuleOverageSkippedZeroCents: "over" but the prorated overage rounded to 0 + // cents (unreachable for a real ≥1-day over module at $3) — resolved with no + // charge so it never re-sweeps forever. + ModuleOverageSkippedZeroCents ModuleOverageStatus = "zero_cents" ) -// OverageChargeSummary reports what one ChargeAccountOverage call did. -type OverageChargeSummary struct { - Status OverageChargeStatus - PeriodStart time.Time - OverCount int +// ModuleOverageResult reports what one ChargeModuleOverage call did. +type ModuleOverageResult struct { + TimerID uuid.UUID + Status ModuleOverageStatus ChargedCents int64 - // StripeInvoiceID is set only when Status == OverageCharged. + // StripeInvoiceID is set only when Status == ModuleOverageCharged. StripeInvoiceID string } -// AccountsInOverageGrace returns the accounts whose grace timer has EXPIRED as -// of `at` (overage_since <= at − overageGraceWindow) and are chargeable — the -// mid-period sweep's work list. A thin pass-through to the store. -func (s *Service) AccountsInOverageGrace(ctx context.Context, at time.Time) ([]OverageGraceCandidate, error) { - cands, err := s.store.AccountsInOverageGrace(ctx, at.Add(-overageGraceWindow)) - if err != nil { - return nil, billing.Internal("list overage-grace accounts failed", err) - } - return cands, nil -} - -// recomputeAccountOverage re-derives the account's pooled module count after a -// module_count write and arms/clears its ONE grace timer accordingly: pool > -// IncludedModules and not yet armed → stamp overage_since (StartAccountOverage -// is first-crossing-wins); pool ≤ IncludedModules → clear it (ClearAccountOverage -// is idempotent). No refund on the clear (D1e) — it only stops FUTURE accrual; -// overage already charged this period stays billed via its snapshot row. Called -// by RegisterApp (after the initial insert) and SyncAppModules (after any count -// / delete write) so the timer always reflects the live pool. -func (s *Service) recomputeAccountOverage(ctx context.Context, accountID uuid.UUID) error { - pooled, err := s.store.PooledModuleCount(ctx, accountID) - if err != nil { - return billing.Internal("pooled module count lookup failed", err) - } - if pooled > usage.IncludedModules { - if err := s.store.StartAccountOverage(ctx, accountID, s.nowFn().UTC()); err != nil { - return billing.Internal("start account overage timer failed", err) - } - return nil - } - if err := s.store.ClearAccountOverage(ctx, accountID); err != nil { - return billing.Internal("clear account overage timer failed", err) - } - return nil -} - -// ChargeAccountOverage is the mid-period grace charge for ONE account whose -// pooled overage has survived the grace window. It: -// -// 1. resolves the account's CURRENT anchored period (from activated_at) and the -// grace-end instant (overage_since + overageGraceWindow); -// 2. reads this period's account_overage_snapshots row, if any (the -// double-charge guard): -// - source='advance' (the boundary claimed it, pending or charged) → -// skip, never independently charge it; -// - source='grace', status='charged' → already settled; the only further -// work is a possible TOP-UP if the pool grew further within this SAME -// still-open period (topUpGraceOverage, finding #3); -// - source='grace', status='pending' → THIS leg's own attempt died -// between claiming the period and Stripe confirming (or before Stripe -// was ever called) — resume it, reusing the FROZEN over_count / -// charged_micros so the retry recomputes the IDENTICAL amount and the -// deterministic Idempotency-Key stays valid; -// 3. otherwise (no row): reads the CURRENT pool — ≤ IncludedModules clears the -// timer and skips (no refund of anything already charged, D1e); prices the -// pooled overage PRORATED from grace-end to the period end -// (ProratedOverageMicros) → whole cents; a 0-cent result skips (grace ends -// at/after the period end); -// 4. CLAIMS the period (a 'pending' account_overage_snapshots row) BEFORE -// calling Stripe — the crash-safe marker described in this file's header — -// then charges via the SAME Stripe plumbing as the other legs with the -// deterministic per-(account, period) Idempotency-Keys, mirrors the -// invoice, and flips the row to 'charged' with the genuine Stripe item id. -// -// Gated on a usable default PM exactly like the spine (the candidate is already -// activated). A failure after the claim leaves the row 'pending'; the next -// sweep resumes through the SAME idem key (Stripe dedupes) — retry-safe, never -// a double charge. -func (s *Service) ChargeAccountOverage(ctx context.Context, cand OverageGraceCandidate, at time.Time) (*OverageChargeSummary, error) { +// ChargeModuleOverage evaluates + (if "over") charges ONE per-module install +// timer whose grace has elapsed. Gated on a usable default PM exactly like the +// proration leg (the candidate account is already activated — the work-list +// query filters activated_at IS NOT NULL). Idempotent + race-safe WITHOUT a lock: +// the deterministic per-timer Stripe Idempotency-Keys dedupe the charge across +// retries, and the grace_resolved first-write-wins guard records the terminal +// verdict — a crash between Stripe succeeding and the mark committing resumes on +// the next sweep through the SAME keys (Stripe returns the same objects) and then +// marks. Unlike the superseded account-wide model there is exactly ONE charge leg +// and ONE key namespace per timer, so no pending-claim ledger is needed. +func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCandidate, at time.Time) (*ModuleOverageResult, error) { if cand.ID == uuid.Nil { - return nil, billing.InvalidInput("account_id required") + return nil, billing.InvalidInput("timer id required") } if s.stripe == nil { - return nil, billing.Internal("ChargeAccountOverage requires a Stripe client", nil) - } - - graceEnd := cand.OverageSince.Add(overageGraceWindow) - if at.UTC().Before(graceEnd) { - // Defensive: the work-list query already excludes still-in-grace accounts. - return &OverageChargeSummary{Status: OverageSkippedGraceHolding}, nil - } - - anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) - periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(at.UTC(), anchorDay) - - existing, found, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart) - if err != nil { - return nil, billing.Internal("account overage snapshot lookup failed", err) - } - if found { - if existing.Source == "advance" { - // The boundary claimed (pending or charged) this period's overage — - // the mid-period sweep must NEVER independently charge it, crash - // window or not (the cross-leg double-charge guard, PR #47 review). - return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil - } - if existing.Status == OverageSnapshotCharged { - return s.topUpGraceOverage(ctx, cand, existing, at, periodStart, periodEnd) - } - // existing.Status == pending, existing.Source == "grace": resume OUR - // own crashed attempt, reusing the frozen amount. - return s.completeGraceCharge(ctx, cand, existing.OverCount, existing.ChargedMicros, graceEnd, periodStart, periodEnd, true /* alreadyClaimed */) + return nil, billing.Internal("ChargeModuleOverage requires a Stripe client", nil) } + res := &ModuleOverageResult{TimerID: cand.ID} - pooled, err := s.store.PooledModuleCount(ctx, cand.ID) + // LIVE FIFO determination, computed fresh (never cached): this install's rank + // among the account's currently-live timers ordered (installed_at, id). + rank, err := s.store.LiveModuleTimerRankBefore(ctx, cand.AccountID, cand.ID, cand.InstalledAt) if err != nil { - return nil, billing.Internal("pooled module count lookup failed", err) - } - overCount := pooled - usage.IncludedModules - if overCount <= 0 { - // Dropped back under the pool since the timer was read — clear the stale - // timer and charge nothing (no refund of anything already billed, D1e). - if err := s.store.ClearAccountOverage(ctx, cand.ID); err != nil { - return nil, billing.Internal("clear account overage timer failed", err) + return nil, billing.Internal("module timer FIFO rank lookup failed", err) + } + if rank < usage.IncludedModules { + // Included → PERMANENT verdict (monotonicity: an existing row's rank only + // ever improves over→included, never the reverse). Mark resolved, never + // charge, never re-check. + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer included failed", err) } - return &OverageChargeSummary{Status: OverageSkippedUnderPool, PeriodStart: periodStart}, nil + res.Status = ModuleOverageIncluded + return res, nil } - // Prorate the pooled overage from grace-end to the period end. A 0-cent - // result (grace ends at/after this period's end) means nothing to bill this - // period — a later period picks it up; leave the timer armed, no snapshot. - proratedMicros := usage.ProratedOverageMicros(usage.AccountOverageMicros(pooled), graceEnd, periodStart, periodEnd) + // "Over": price $3 prorated from the install's UTC day to the end of the + // anchored period CONTAINING the install (ADR 0005 anchor from activation) — + // install-anchored, NOT grace-elapse-anchored and NOT now-anchored. Anchoring + // on the install's own period (rather than now's) keeps this leg's coverage + // strictly within the install period, so a grace that straddles a period + // boundary never charges the NEXT period here — that period is the boundary + // precharge's job (scenario 6, Stage B), which would otherwise double-bill it. + anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) + periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(cand.InstalledAt.UTC(), anchorDay) + proratedMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) cents, err := centsFromMicros(proratedMicros) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } if cents == 0 { - return &OverageChargeSummary{Status: OverageSkippedZeroCents, PeriodStart: periodStart, OverCount: overCount}, nil - } - - return s.completeGraceCharge(ctx, cand, overCount, proratedMicros, graceEnd, periodStart, periodEnd, false /* alreadyClaimed */) -} - -// completeGraceCharge runs the PM/customer gate + the actual Stripe charge for -// the mid-period grace leg, given an already-resolved (overCount, -// chargedMicros) — either freshly computed (alreadyClaimed=false: this call -// still needs to CLAIM the period before Stripe) or reused from an existing -// 'pending' row (alreadyClaimed=true: THIS leg already claimed it on a prior -// attempt; resume straight to the PM/Stripe steps with the SAME frozen amount -// so the deterministic Idempotency-Key never sees a different total). -func (s *Service) completeGraceCharge(ctx context.Context, cand OverageGraceCandidate, overCount int, chargedMicros int64, graceEnd, periodStart, periodEnd time.Time, alreadyClaimed bool) (*OverageChargeSummary, error) { - summary := &OverageChargeSummary{PeriodStart: periodStart, OverCount: overCount} - cents, err := centsFromMicros(chargedMicros) - if err != nil { - return nil, billing.Internal("micros to cents conversion failed", err) + // Rounds to 0 cents — resolve with no charge so the sweep never revisits it + // forever (grace_resolved would otherwise stay false). + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer resolved (zero cents) failed", err) + } + res.Status = ModuleOverageSkippedZeroCents + return res, nil } - summary.ChargedCents = cents + res.ChargedCents = cents - hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.ID) + // PM gate (same posture as the proration leg): no usable PM → skip WITHOUT + // resolving, re-attempted next sweep (the per-timer idem keys stay stable). + hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.AccountID) if err != nil { return nil, billing.Internal("usable PM check failed", err) } if !hasPM { - summary.Status = OverageSkippedNoPM - return summary, nil // retained; re-attempted next sweep through the same idem key + res.Status = ModuleOverageSkippedNoPM + return res, nil } - custID, err := s.store.AccountStripeCustomer(ctx, cand.ID) + custID, err := s.store.AccountStripeCustomer(ctx, cand.AccountID) if err != nil { return nil, billing.Internal("stripe customer lookup failed", err) } if custID == "" { + // A usable PM implies a Customer (same anomaly posture as the spine). return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) } - if !alreadyClaimed { - // CLAIM the period BEFORE calling Stripe (the crash-safe marker — see - // this file's header). inserted=false means we lost a race to a - // concurrent claim (another sweep invocation, or the boundary) — defer - // to the winner instead of charging under our own stale claim. - inserted, err := s.store.InsertAccountOverageSnapshot(ctx, AccountOverageSnapshot{ - AccountID: cand.ID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - OverCount: overCount, - ChargedMicros: chargedMicros, - Source: "grace", - Status: OverageSnapshotPending, - }) - if err != nil { - return nil, billing.Internal("account overage snapshot claim failed", err) - } - if !inserted { - return s.resumeClaimedOverage(ctx, cand, graceEnd, periodStart, periodEnd) - } + // Charge via a per-timer invoice with deterministic idem keys derived from the + // timer id (the stable charge identity — each install charges at most once, + // the grace_resolved guard), so a crash-retry reuses the SAME Stripe objects. + desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) + item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)) + if err != nil { + return nil, billing.StripeError("module overage invoice item failed", err) } - - desc := fmt.Sprintf("MirrorStack module overage (account pool, %d over) — account %s", overCount, cand.ID) - item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, accountOverageItemIdemKey(cand.ID, periodStart)) + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, moduleOverageInvoiceIdemKey(cand.ID)) if err != nil { - return nil, billing.StripeError("overage invoice item failed", err) + return nil, billing.StripeError("module overage invoice failed", err) } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, accountOverageInvoiceIdemKey(cand.ID, periodStart)) + + // Resolve the large-charge disclosure threshold AT CHARGE TIME, immediately + // after Stripe confirms (scenario 5 / migration 034) — the SAME resolution + // point every off-session charge site uses. + acct, err := s.store.AccountCollection(ctx, cand.AccountID) if err != nil { - return nil, billing.StripeError("overage invoice failed", err) + return nil, billing.Internal("account collection lookup failed", err) } if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: cand.ID, + AccountID: cand.AccountID, StripeInvoiceID: inv.ID, Status: inv.Status, AmountDueCents: inv.AmountDue, AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, - // The mirrored window is the PARTIAL coverage [grace-end day, period end), - // the SAME instant ProratedOverageMicros priced, so the shown window and - // the charged amount agree by construction. - PeriodStart: usage.ProrationCoverageStart(graceEnd, periodStart), - PeriodEnd: periodEnd, + // Partial coverage window [install day (UTC midnight), period end) — the + // SAME instant ProratedBaseMicros priced, so the mirrored window and the + // charged amount agree by construction. + PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), + PeriodEnd: periodEnd, + IsLargeAutoCollect: collection.IsLargeAutoCollect(proratedMicros, acct.AutoCollectThresholdMicros), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } - // Flip the claim to 'charged' now that Stripe confirmed it, recording the - // GENUINE Stripe invoice item id (never an idempotency-key string). - if err := s.store.MarkAccountOverageSnapshotCharged(ctx, cand.ID, periodStart, item.ID); err != nil { - return nil, billing.Internal("account overage snapshot mark-charged failed", err) + // Stamp the terminal "over and charged" verdict with the GENUINE Stripe ids + // (item.ID — never the idempotency-key string). + if err := s.store.MarkModuleTimerCharged(ctx, cand.ID, at.UTC(), inv.ID, item.ID); err != nil { + return nil, billing.Internal("mark module timer charged failed", err) } - summary.Status = OverageCharged - summary.StripeInvoiceID = inv.ID - return summary, nil + res.Status = ModuleOverageCharged + res.StripeInvoiceID = inv.ID + return res, nil } -// resumeClaimedOverage handles the (rare, only-possible-under-true-concurrency) -// case where completeGraceCharge's own claim-insert LOST a race: by the time it -// tried to claim the period, some OTHER caller (a concurrent sweep invocation, -// or the boundary) had already inserted a row first. Re-reads the winning row -// and defers to it — an 'advance' winner means the boundary owns this period -// (skip); a 'grace' winner that is already 'charged' means another sweep -// invocation finished first (skip); a 'grace' winner still 'pending' means -// another invocation is mid-flight — resume IT (same frozen amount). -func (s *Service) resumeClaimedOverage(ctx context.Context, cand OverageGraceCandidate, graceEnd, periodStart, periodEnd time.Time) (*OverageChargeSummary, error) { - existing, found, err := s.store.AccountOverageSnapshot(ctx, cand.ID, periodStart) - if err != nil { - return nil, billing.Internal("account overage snapshot lookup failed", err) - } - if !found { - // The conflict that sent us here proves a row exists; a missing read - // immediately after is a driver/consistency anomaly. - return nil, billing.Internal("account overage snapshot claim lost but no row found on re-read", nil) - } - if existing.Source == "advance" || existing.Status == OverageSnapshotCharged { - return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil - } - return s.completeGraceCharge(ctx, cand, existing.OverCount, existing.ChargedMicros, graceEnd, periodStart, periodEnd, true) +// SweepModuleOverageResult tallies one SweepModuleOverage batch for the +// cmd/billing-cycle log line + exit code. +type SweepModuleOverageResult struct { + Pending int // live unresolved timers past grace (the work-list size) + Charged int // over-modules invoiced this sweep + Included int // resolved-as-included (no charge) + Skipped int // no-PM / 0-cent (left for next sweep, or resolved zero) + Failed int // per-timer errors — retried next sweep, never abort the batch } -// topUpGraceOverage is finding #3's fix — A JUDGMENT CALL (PR #47 review): no -// product decision was available on the exact top-up policy, so this -// implements the safest technically-correct interpretation, flagged here and -// in the PR description. recomputeAccountOverage only ever arms/clears the -// grace timer; it never re-evaluates an ALREADY-CHARGED period's snapshot, so -// pooled-module growth after the period's first grace charge went permanently -// unbilled for that period (and the display, which is snapshot-first, never -// reflected it either). Because the timer stays armed until the pool drops -// back under the pool (recomputeAccountOverage), the sweep keeps re-invoking -// ChargeAccountOverage for this account on every pass — so THIS function now -// re-checks a 'charged' period's current pool against what was billed and, if -// it grew, charges the INCREMENTAL delta, conservatively prorated from THIS -// sweep's instant (`at`) to the period end — never retroactively for time -// before this sweep noticed the growth (the safest, never-overcharge -// interpretation; it may under-bill by the gap between the actual install and -// the next sweep tick, which is bounded by the sweep's cron interval). -// -// A pool that merely dropped (or stayed the same) since the last charge is -// D1e (no refund) — this only ever ADDS to what is billed, never subtracts. -// The top-up's own Idempotency-Keys are derived from the TARGET cumulative -// over-count, so a crash-and-retry of the SAME top-up (pool unchanged since) -// reuses the SAME Stripe objects; if the pool grows AGAIN before this one -// resolves, the next pass computes a NEW target and a NEW (legitimately -// different) key — never a collision with the one still in flight. -func (s *Service) topUpGraceOverage(ctx context.Context, cand OverageGraceCandidate, existing AccountOverageSnapshot, at, periodStart, periodEnd time.Time) (*OverageChargeSummary, error) { - pooled, err := s.store.PooledModuleCount(ctx, cand.ID) - if err != nil { - return nil, billing.Internal("pooled module count lookup failed", err) - } - overCount := pooled - usage.IncludedModules - if overCount <= existing.OverCount { - if overCount <= 0 { - if err := s.store.ClearAccountOverage(ctx, cand.ID); err != nil { - return nil, billing.Internal("clear account overage timer failed", err) - } - } - return &OverageChargeSummary{Status: OverageSkippedAlreadyCharged, PeriodStart: periodStart, OverCount: existing.OverCount}, nil - } - - deltaOverCount := overCount - existing.OverCount - deltaFullMicros := usage.ModuleOverageFeeMicros * int64(deltaOverCount) - deltaMicros := usage.ProratedOverageMicros(deltaFullMicros, at, periodStart, periodEnd) - deltaCents, err := centsFromMicros(deltaMicros) - if err != nil { - return nil, billing.Internal("micros to cents conversion failed", err) - } - summary := &OverageChargeSummary{PeriodStart: periodStart, OverCount: overCount} - if deltaCents == 0 { - summary.Status = OverageSkippedZeroCents - return summary, nil - } - summary.ChargedCents = deltaCents - - hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.ID) - if err != nil { - return nil, billing.Internal("usable PM check failed", err) - } - if !hasPM { - summary.Status = OverageSkippedNoPM - return summary, nil // retained; re-attempted next sweep through the same idem key +// SweepModuleOverage charges (or resolves) every per-module install timer whose +// grace has elapsed as of `at`. Idempotent + resumable: a resolved timer drops +// out of the work list (grace_resolved), and a per-timer failure is counted but +// never aborts the batch (the next sweep retries it through the same +// deterministic Stripe keys). +func (s *Service) SweepModuleOverage(ctx context.Context, at time.Time) (*SweepModuleOverageResult, error) { + if at.IsZero() { + return nil, billing.InvalidInput("sweep instant required") } - custID, err := s.store.AccountStripeCustomer(ctx, cand.ID) + cands, err := s.store.ModuleOverageTimersPastGrace(ctx, at.UTC()) if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) + return nil, billing.Internal("list module overage timers past grace failed", err) } - if custID == "" { - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) - } - - desc := fmt.Sprintf("MirrorStack module overage top-up (account pool, %d over, +%d) — account %s", overCount, deltaOverCount, cand.ID) - item, err := s.stripe.CreateInvoiceItem(ctx, custID, deltaCents, chargeCurrency, desc, accountOverageTopUpItemIdemKey(cand.ID, periodStart, overCount)) - if err != nil { - return nil, billing.StripeError("overage top-up invoice item failed", err) - } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, accountOverageTopUpInvoiceIdemKey(cand.ID, periodStart, overCount)) - if err != nil { - return nil, billing.StripeError("overage top-up invoice failed", err) - } - - if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: cand.ID, - StripeInvoiceID: inv.ID, - Status: inv.Status, - AmountDueCents: inv.AmountDue, - AmountPaidCents: inv.AmountPaid, - Currency: chargeCurrency, - // The incremental coverage starts at THIS sweep's instant (the - // conservative proration basis above), never retroactively. - PeriodStart: usage.ProrationCoverageStart(at, periodStart), - PeriodEnd: periodEnd, - }); err != nil { - return nil, billing.Internal("invoice mirror upsert failed", err) - } - - if err := s.store.TopUpAccountOverageSnapshot(ctx, AccountOverageSnapshot{ - AccountID: cand.ID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - OverCount: overCount, - ChargedMicros: existing.ChargedMicros + deltaMicros, // cumulative total actually billed for the period - Source: "grace", - InvoiceItemID: item.ID, - }); err != nil { - return nil, billing.Internal("account overage snapshot top-up failed", err) + res := &SweepModuleOverageResult{Pending: len(cands)} + for _, c := range cands { + r, err := s.ChargeModuleOverage(ctx, c, at) + if err != nil { + slog.ErrorContext(ctx, "module overage charge failed", + "timer_id", c.ID, "app_id", c.AppID, "error", err) + res.Failed++ + continue + } + switch r.Status { + case ModuleOverageCharged: + res.Charged++ + case ModuleOverageIncluded: + res.Included++ + default: + res.Skipped++ + } + slog.InfoContext(ctx, "module overage grace sweep", + "timer_id", c.ID, "app_id", c.AppID, "status", string(r.Status), + "charged_cents", r.ChargedCents, "stripe_invoice_id", r.StripeInvoiceID) } - - summary.Status = OverageToppedUp - summary.StripeInvoiceID = inv.ID - return summary, nil -} - -// accountOverageItemIdemKey / accountOverageInvoiceIdemKey build the -// deterministic per-(account, period) Stripe Idempotency-Keys for the pooled -// overage charge. The (account, period_start) pair is the stable charge -// identity — each account's pooled overage bills at most once per period (the -// account_overage_snapshots guard) — so both the mid-period grace sweep and the -// boundary leg, if they ever target the SAME period, reuse the SAME Stripe -// objects and can never double-charge. Mirrors the app-ii-/app-inv- pattern. -func accountOverageItemIdemKey(accountID uuid.UUID, periodStart time.Time) string { - return "acct-overage-ii-" + accountID.String() + "-" + strconv.FormatInt(periodStart.UTC().Unix(), 10) -} - -func accountOverageInvoiceIdemKey(accountID uuid.UUID, periodStart time.Time) string { - return "acct-overage-inv-" + accountID.String() + "-" + strconv.FormatInt(periodStart.UTC().Unix(), 10) + return res, nil } -// accountOverageTopUpItemIdemKey / accountOverageTopUpInvoiceIdemKey build the -// deterministic Stripe Idempotency-Keys for an INCREMENTAL top-up charge -// (finding #3), keyed additionally by the TARGET cumulative over-count so a -// retry of the SAME top-up (pool unchanged since) reuses the SAME Stripe -// objects, while a further pool change before it resolves computes a NEW -// (legitimately distinct) key rather than colliding with the one in flight. -func accountOverageTopUpItemIdemKey(accountID uuid.UUID, periodStart time.Time, targetOverCount int) string { - return accountOverageItemIdemKey(accountID, periodStart) + "-topup-" + strconv.Itoa(targetOverCount) +// moduleOverageItemIdemKey / moduleOverageInvoiceIdemKey build the deterministic +// per-timer Stripe Idempotency-Keys for the Leg 1 overage charge. The timer id is +// the stable charge identity (each install charges at most once — the +// grace_resolved guard), so a re-attempt (a retried sweep after a crash between +// the Stripe call and the mark) reuses the SAME Stripe objects and can never +// double-charge even before the row is marked resolved. +func moduleOverageItemIdemKey(timerID uuid.UUID) string { + return "mod-overage-ii-" + timerID.String() } -func accountOverageTopUpInvoiceIdemKey(accountID uuid.UUID, periodStart time.Time, targetOverCount int) string { - return accountOverageInvoiceIdemKey(accountID, periodStart) + "-topup-" + strconv.Itoa(targetOverCount) +func moduleOverageInvoiceIdemKey(timerID uuid.UUID) string { + return "mod-overage-inv-" + timerID.String() } diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 170732a..a720aec 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -1,15 +1,14 @@ package cycle_test -// Account-wide POOLED module overage (migration 032): the grace-timer recompute -// (RegisterApp / SyncAppModules), the mid-period grace sweep (ChargeAccountOverage -// + AccountsInOverageGrace), and the no-double-charge interaction with the -// boundary advance leg. Reuses the in-memory fakeStore (service_test.go) + -// fakeStripe (charge_test.go). +// Per-module-instance overage — Leg 1 (migration 033): the per-module grace +// charge sweep (SweepModuleOverage / ChargeModuleOverage), the LIVE FIFO +// included-vs-over determination, the install-anchored proration, and the FIFO +// monotonicity / permanent-inclusion property. Reuses the in-memory fakeStore +// (service_test.go) + fakeStripe (charge_test.go) + the registeredAccount / +// registerMirror / appsSvc helpers (apps_test.go / proration_test.go). import ( "context" - "errors" - "strings" "testing" "time" @@ -17,518 +16,318 @@ import ( "github.com/stretchr/testify/require" "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" - "github.com/mirrorstack-ai/billing-engine/internal/account/usage" ) -func overageSvc(store *fakeStore, sc *fakeStripe, now time.Time) *cycle.Service { - return cycle.NewService(store, sc).WithNow(func() time.Time { return now }) +// seedTimer inserts one live, unresolved install timer directly into the fake +// (bypassing RegisterApp so a test can pin exact install dates), grace expiring +// at install + 3 days, and returns its id. +func seedTimer(store *fakeStore, accountID, appID uuid.UUID, installedAt time.Time) uuid.UUID { + id := uuid.New() + store.timers[id] = &fakeTimer{ + id: id, + accountID: accountID, + appID: appID, + installedAt: installedAt, + graceExpiresAt: installedAt.AddDate(0, 0, 3), + } + return id } -// --- recompute: the timer arms / clears on pool crossings ------------------- - -func TestRecompute_PoolCrossingFiveArmsTimer(t *testing.T) { - // Two apps' installs INTERLEAVE and SUM to the account pool: app1=3 (pool 3, - // under 5 → not armed), then app2=4 (pool 7, over 5 → arms overage_since at - // the register instant). Proves the timer keys off the ACCOUNT-WIDE sum, not - // either app alone. - now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) - store := newFakeStore() - user, acct := registeredAccount(store) - svc := overageSvc(store, newFakeStripe(), now) - - _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: uuid.New(), ModuleCount: 3, CreatedAt: now, - }) - require.NoError(t, err) - require.NotContains(t, store.overageSince, acct, "pool 3 is under 5 → timer not armed") - - _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: uuid.New(), ModuleCount: 4, CreatedAt: now, - }) - require.NoError(t, err) - require.Contains(t, store.overageSince, acct, "pool 3+4=7 crosses 5 → timer armed") - require.Equal(t, now, store.overageSince[acct], "armed at the crossing instant") +// liveTimerCount counts an app's currently-live (not-removed) install timers. +func liveTimerCount(store *fakeStore, appID uuid.UUID) int { + n := 0 + for _, t := range store.timers { + if t.appID == appID && !t.removed { + n++ + } + } + return n } -func TestRecompute_FirstCrossingWinsTimerNotMoved(t *testing.T) { - // A LATER recompute that finds the pool still over must NOT move the anchor - // (first-crossing-wins) — the grace window is measured from the FIRST cross. - first := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) - store := newFakeStore() - user, acct := registeredAccount(store) - appID := uuid.New() - - _, err := overageSvc(store, newFakeStripe(), first).RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: appID, ModuleCount: 6, CreatedAt: first, - }) - require.NoError(t, err) - require.Equal(t, first, store.overageSince[acct]) - - // Later: bump the count further; the pool is still over, so the anchor stays. - later := first.AddDate(0, 0, 2) - _, err = overageSvc(store, newFakeStripe(), later).SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ - AppID: appID, ModuleCount: intPtr(9), - }) - require.NoError(t, err) - require.Equal(t, first, store.overageSince[acct], "still-over recompute must not move the first-cross anchor") +// seedIncluded seeds n live install timers already resolved-as-included at the +// SAME (earliest) install instant, so they occupy the included FIFO slots and +// stay out of the sweep's work list. +func seedIncluded(store *fakeStore, accountID, appID uuid.UUID, installedAt time.Time, n int) { + for i := 0; i < n; i++ { + id := seedTimer(store, accountID, appID, installedAt) + store.timers[id].graceResolved = true + } } -func TestRecompute_DroppingUnderFiveClearsTimer(t *testing.T) { - // Pool over 5 arms the timer; a later uninstall that drops the pool back to - // ≤5 CLEARS it (no charge — the grace never elapsed). - now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) +// --- Scenario 4: two modules a day apart → two independent prorated charges --- + +func TestModuleOverage_Scenario4_TwoModulesTwoDaysTwoAmounts(t *testing.T) { + // registeredAccount activates on the 4th → anchor day 4 → the period + // CONTAINING a mid-June install is [June 4, July 4) = 30 days. The account + // already has 5 included modules, so two NEW modules installed a day apart are + // both "over" and each gets its OWN independently-anchored grace charge, + // prorated from ITS OWN install date to the period end: + // * module A installed June 10 → grace ends June 13 → charge $3 × 24/30 = + // $2.40 (240¢); remain_days = whole UTC days in [June 10, July 4) = 24. + // * module B installed June 11 → grace ends June 14 → charge $3 × 23/30 = + // $2.30 (230¢); remain_days = [June 11, July 4) = 23. store := newFakeStore() - user, acct := registeredAccount(store) - svc := overageSvc(store, newFakeStripe(), now) - appID := uuid.New() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() - _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ - OwnerUserID: user, AppID: appID, ModuleCount: 7, CreatedAt: now, - }) - require.NoError(t, err) - require.Contains(t, store.overageSince, acct) + // 5 pre-existing included modules (installed back on the anchor day), so the + // two newcomers land in the "over" bucket. + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) - _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: intPtr(2)}) - require.NoError(t, err) - require.NotContains(t, store.overageSince, acct, "dropping to pool 2 clears the grace timer") -} + appAB := uuid.New() + timerA := seedTimer(store, acct, appAB, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + timerB := seedTimer(store, acct, appAB, time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)) -func TestRecompute_DeleteDroppingUnderFiveClearsTimer(t *testing.T) { - // Deleting an app (not just a count sync) also drops the pool and clears the - // timer — deleted apps leave the live pool. - now := time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) - store := newFakeStore() - user, acct := registeredAccount(store) - svc := overageSvc(store, newFakeStripe(), now) - a1, a2 := uuid.New(), uuid.New() - - _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{OwnerUserID: user, AppID: a1, ModuleCount: 4, CreatedAt: now}) - require.NoError(t, err) - _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{OwnerUserID: user, AppID: a2, ModuleCount: 4, CreatedAt: now}) + // Sweep on June 13 (A's grace elapsed, B's has not) → charges A only. + resA, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 13, 9, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.Contains(t, store.overageSince, acct, "pool 8 armed") - - _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: a2, Deleted: true}) + require.Equal(t, 1, resA.Pending, "only A is past grace on June 13") + require.Equal(t, 1, resA.Charged) + require.Equal(t, 0, resA.Failed) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg, "A: $3 × 24/30 = $2.40") + require.Equal(t, "mod-overage-ii-"+timerA.String(), sc.itemCalls[0].idemKey) + require.Equal(t, "mod-overage-inv-"+timerA.String(), sc.invoiceCalls[0].idemKey) + + ta := store.timers[timerA] + require.True(t, ta.graceResolved) + require.True(t, ta.graceCharged) + require.Equal(t, time.Date(2026, 6, 13, 9, 0, 0, 0, time.UTC), ta.graceChargedAt) + // The stored item id is the GENUINE Stripe object id, NOT the idempotency-key + // string (a prior-PR bug this guards against). + require.Contains(t, ta.graceInvoiceItemID, "ii_test_") + require.NotEqual(t, "mod-overage-ii-"+timerA.String(), ta.graceInvoiceItemID) + require.Contains(t, ta.graceInvoiceID, "in_test_") + + // B untouched so far. + require.False(t, store.timers[timerB].graceResolved) + + // Sweep on June 14 (B's grace now elapsed) → charges B, a DIFFERENT amount on + // a DIFFERENT day; A is already resolved and never re-charged. + resB, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.NotContains(t, store.overageSince, acct, "deleting one app drops the pool to 4 → timer cleared") + require.Equal(t, 1, resB.Pending, "only B remains past grace on June 14 (A resolved)") + require.Equal(t, 1, resB.Charged) + require.Len(t, sc.itemCalls, 2, "A must not be charged a second time") + require.EqualValues(t, 230, sc.itemCalls[1].amountCfg, "B: $3 × 23/30 = $2.30") + require.Equal(t, "mod-overage-ii-"+timerB.String(), sc.itemCalls[1].idemKey) + + tb := store.timers[timerB] + require.True(t, tb.graceCharged) + require.Contains(t, tb.graceInvoiceItemID, "ii_test_") } -// --- grace window: holds for exactly the grace period ----------------------- +// --- FIFO monotonicity: an included module is a PERMANENT verdict ------------- -func TestAccountsInOverageGrace_HoldsForExactlyThreeDays(t *testing.T) { - // The sweep work list includes an account only once its grace timer has - // elapsed: overage_since <= at − 3d. Pin the boundary at EXACTLY 3 days. +func TestModuleOverage_IncludedIsPermanentNeverReEvaluated(t *testing.T) { + // Once a grace-check finds a module "included", that verdict is permanent + // (grace_resolved) — it is never re-checked and never charged, even after the + // pool later grows well past the included 5. Monotonicity: a new install + // always gets the latest installed_at, so an existing row's rank can only + // improve (over→included), never regress (included→over). store := newFakeStore() - acct := uuid.New() - since := time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC) - store.overageSince[acct] = since - store.activation[acct] = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) - - // 1s BEFORE the 3-day mark → still in grace, excluded. - cands, err := cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), since.Add(3*24*time.Hour-time.Second)) - require.NoError(t, err) - require.Empty(t, cands, "grace still holding just under 3 days") + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + // 5 early installs — all "included" (ranks 0-4), initially UNRESOLVED. + early := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) + var earlyIDs []uuid.UUID + for i := 0; i < 5; i++ { + earlyIDs = append(earlyIDs, seedTimer(store, acct, app, early)) + } - // EXACTLY 3 days → grace elapsed, included. - cands, err = cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), since.Add(3*24*time.Hour)) + // First sweep past their grace → all 5 resolved as included, none charged. + res, err := svc.SweepModuleOverage(ctx, early.AddDate(0, 0, 4)) require.NoError(t, err) - require.Len(t, cands, 1, "grace elapsed at exactly 3 days") - require.Equal(t, acct, cands[0].ID) - require.Equal(t, since, cands[0].OverageSince) -} + require.Equal(t, 5, res.Pending) + require.Equal(t, 5, res.Included) + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls, "included modules are never charged") + for _, id := range earlyIDs { + require.True(t, store.timers[id].graceResolved) + require.False(t, store.timers[id].graceCharged) + } -func TestAccountsInOverageGrace_ExcludesUnactivated(t *testing.T) { - // An un-activated account (no card) is never charged, so it never enters the - // sweep even past the grace window. - store := newFakeStore() - acct := uuid.New() - store.overageSince[acct] = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - // no activation row + // Now install 10 MORE modules a month later — the pool jumps to 15, but the 5 + // early installs keep ranks 0-4 (monotonicity); the newcomers are "over". + late := time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC) + for i := 0; i < 10; i++ { + seedTimer(store, acct, app, late) + } - cands, err := cycle.NewService(store, newFakeStripe()).AccountsInOverageGrace(context.Background(), time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) + // Second sweep → only the 10 unresolved newcomers are candidates and charged; + // the 5 already-included ones are NOT re-evaluated and stay uncharged forever. + res2, err := svc.SweepModuleOverage(ctx, late.AddDate(0, 0, 4)) require.NoError(t, err) - require.Empty(t, cands) -} - -// --- mid-period grace charge ------------------------------------------------ - -// armedOverageAccount seeds a fully-chargeable account (activation anchor day 1, -// usable PM, Stripe customer) with `n` apps of `perApp` modules each, created -// before `createdBefore`, and its grace timer armed at `since`. Returns the -// account id. -func armedOverageAccount(store *fakeStore, n, perApp int, since, createdBefore time.Time) uuid.UUID { - acct := uuid.New() - store.activation[acct] = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) // anchor day 1 → calendar-month periods - store.hasPM = true - store.stripeCustomer = "cus_overage" - store.overageSince[acct] = since - for i := 0; i < n; i++ { - seedAppCreated(store, acct, perApp, false, createdBefore.AddDate(0, 0, -1)) + require.Equal(t, 10, res2.Pending, "only the unresolved newcomers are candidates") + require.Equal(t, 10, res2.Charged) + require.Len(t, sc.itemCalls, 10) + for _, id := range earlyIDs { + require.False(t, store.timers[id].graceCharged, + "an included module is never charged even after the pool grows past 5") + require.True(t, store.timers[id].graceResolved) } - return acct } -func TestChargeAccountOverage_ChargesFullWhenGraceEndedBeforePeriodStart(t *testing.T) { - // Grace ended May 31 (before the current period [Jun 1, Jul 1)), so the - // pooled overage is charged in FULL for the period. Pool = 2 apps × 4 = 8 → - // 3 over → $9 → 900 cents. One invoice item with the deterministic - // per-(account, period) idem key; one 'grace' snapshot frozen. - at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // graceEnd May 31 < Jun 1 +// --- rank flips over→included before grace elapses → NOT charged -------------- + +func TestModuleOverage_FlipsToIncludedWhenEarlierRemovedBeforeGrace(t *testing.T) { + // A module whose rank flips from "over" to "included" before its own grace + // elapses (because an earlier module was removed) must NOT be charged overage + // — the determination is LIVE at the grace-check. store := newFakeStore() - jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - acct := armedOverageAccount(store, 2, 4, since, jun1) + _, acct := registeredAccount(store) sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + // 5 earlier installs (ranks 0-4) + X installed a day later (rank 5 = over). + early := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) + var earlyIDs []uuid.UUID + for i := 0; i < 5; i++ { + earlyIDs = append(earlyIDs, seedTimer(store, acct, app, early)) + } + x := seedTimer(store, acct, app, time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)) - summary, err := overageSvc(store, sc, at).ChargeAccountOverage(context.Background(), cycle.OverageGraceCandidate{ - ID: acct, OverageSince: since, ActivatedAt: store.activation[acct], - }, at) - require.NoError(t, err) - require.Equal(t, cycle.OverageCharged, summary.Status) - require.Equal(t, 3, summary.OverCount) - require.EqualValues(t, 900, summary.ChargedCents) + // One earlier module is removed BEFORE X's grace elapses → X's live rank + // improves to 4 → included. + store.timers[earlyIDs[0]].removed = true + store.timers[earlyIDs[0]].removedAt = time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC) - require.Len(t, sc.itemCalls, 1) - require.EqualValues(t, 900, sc.itemCalls[0].amountCfg) - require.Contains(t, sc.itemCalls[0].idemKey, "acct-overage-ii-"+acct.String()) - require.Len(t, sc.invoiceCalls, 1) - - snap, ok := store.overageSnaps[acctSnapKey{acct, jun1}] - require.True(t, ok, "the grace charge must freeze a snapshot for the period") - require.Equal(t, "grace", snap.Source) - require.Equal(t, 3, snap.OverCount) - require.EqualValues(t, 9_000_000, snap.ChargedMicros) + // Sweep past all remaining graces → X (and the 4 remaining early installs) are + // all resolved as included; nothing is charged. + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 5, res.Pending, "the removed module is out of the work list") + require.Equal(t, 5, res.Included) + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls) + require.True(t, store.timers[x].graceResolved) + require.False(t, store.timers[x].graceCharged, "an over→included flip is never charged") } -func TestChargeAccountOverage_ProratesFromGraceEndMidPeriod(t *testing.T) { - // Grace ends mid-period (Jun 4 → coverage [Jun 4, Jul 1) = 27 of 30 days). - // Pool = 6 → 1 over → $3 → prorated 3e6 × 27/30 = 2_700_000 → 270 cents. - at := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 6, 1, 6, 0, 0, 0, time.UTC) // graceEnd Jun 4 (truncated day) +// --- a module removed within its own grace is never charged ------------------- + +func TestModuleOverage_RemovedWithinGraceNeverCharged(t *testing.T) { store := newFakeStore() - acct := armedOverageAccount(store, 1, 6, since, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) + _, acct := registeredAccount(store) sc := newFakeStripe() - - summary, err := overageSvc(store, sc, at).ChargeAccountOverage(context.Background(), cycle.OverageGraceCandidate{ - ID: acct, OverageSince: since, ActivatedAt: store.activation[acct], - }, at) + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + app := uuid.New() + over := seedTimer(store, acct, app, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + // Removed on day 1 (well within its own 3-day grace). + store.timers[over].removed = true + store.timers[over].removedAt = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC) + + // A sweep long past grace must still never charge it (removed rows are out of + // the work list). + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.Equal(t, cycle.OverageCharged, summary.Status) - require.EqualValues(t, 270, summary.ChargedCents) + require.Equal(t, 0, res.Pending, "a removed timer is excluded from the sweep") + require.Empty(t, sc.itemCalls) + require.False(t, store.timers[over].graceCharged) } -func TestChargeAccountOverage_FiresOnceAndExcludedFromBoundary(t *testing.T) { - // THE double-charge invariant. The mid-period sweep charges the pooled - // overage for [Jun 1, Jul 1); a SECOND sweep is a no-op (the snapshot guards - // it); and the BOUNDARY that closes [Jun 1, Jul 1) must NOT charge the - // overage again (it sees the snapshot) — it bills only the flat advance base. - at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // full overage this period +// --- over module with no usable PM is skipped and retried (not resolved) ------ + +func TestModuleOverage_NoPMSkipsAndRetries(t *testing.T) { store := newFakeStore() - store.chargedTotal = 0 - jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - acct := armedOverageAccount(store, 2, 4, since, jun1) // pool 8 → $9 overage + _, acct := registeredAccount(store) + store.hasPM = false // account activated but no usable default PM sc := newFakeStripe() - svc := overageSvc(store, sc, at) - cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + svc := cycle.NewService(store, sc) + ctx := context.Background() - // Sweep #1 charges. - first, err := svc.ChargeAccountOverage(context.Background(), cand, at) - require.NoError(t, err) - require.Equal(t, cycle.OverageCharged, first.Status) - require.Len(t, sc.invoiceCalls, 1) + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + app := uuid.New() + over := seedTimer(store, acct, app, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) - // Sweep #2 (same period) is a no-op — the snapshot guards it. - second, err := svc.ChargeAccountOverage(context.Background(), cand, at.Add(time.Hour)) + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.Equal(t, cycle.OverageSkippedAlreadyCharged, second.Status) - require.Len(t, sc.invoiceCalls, 1, "no second overage invoice") - - // The BOUNDARY closing [Jun 1, Jul 1): flat advance base (2 × $20 = 40e6), - // and ZERO pooled overage — the mid-period snapshot excludes it. - resp, err := svc.RunBillingCycle(context.Background(), acct, jun1, jul1, 0) - require.NoError(t, err) - require.Equal(t, cycle.RunStatusInvoiced, resp.Status) - require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) - require.EqualValues(t, 0, resp.AccountOverageMicros, "the mid-period overage is NOT charged again at the boundary") - require.EqualValues(t, 4_000, resp.ChargedCents) // base only - - // Exactly two invoices total: the grace overage + the boundary base. - require.Len(t, sc.invoiceCalls, 2) - // The 'grace' snapshot (not 'advance') still owns the period row. - require.Equal(t, "grace", store.overageSnaps[acctSnapKey{acct, jun1}].Source) -} - -func TestRunBillingCycle_BoundaryChargesFullPooledOverageWhenNoSweep(t *testing.T) { - // When the grace sweep never billed a period (e.g. grace expired at cutover), - // the boundary charges the FULL pooled overage for the closing period and - // writes its own 'advance' snapshot. Pool = 8 → 3 over → $9 on top of the - // flat base. - store := newFakeStore() - store.chargedTotal = 0 + require.Equal(t, 1, res.Pending) + require.Equal(t, 0, res.Charged) + require.Equal(t, 1, res.Skipped) + require.Empty(t, sc.itemCalls, "no PM → no Stripe call") + // NOT resolved — it stays a candidate for the next sweep once a PM is added. + require.False(t, store.timers[over].graceResolved) + + // Add a PM → the next sweep charges it (idempotent per-timer idem keys). store.hasPM = true - store.stripeCustomer = "cus_bnd_overage" - seedApp(store, chargeAccount, 4, false) - seedApp(store, chargeAccount, 4, false) - sc := newFakeStripe() - - resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + res2, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros) - require.EqualValues(t, 9_000_000, resp.AccountOverageMicros) - require.EqualValues(t, 4_900, resp.ChargedCents) // (40e6 + 9e6) / 10_000 - - snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] - require.True(t, ok) - require.Equal(t, "advance", snap.Source) - require.Equal(t, 3, snap.OverCount) - require.EqualValues(t, 9_000_000, snap.ChargedMicros) + require.Equal(t, 1, res2.Charged) + require.Len(t, sc.itemCalls, 1) + require.True(t, store.timers[over].graceCharged) } -func TestChargeAccountOverage_UninstallDoesNotRefund(t *testing.T) { - // After the pooled overage was charged for a period, dropping back under the - // pool clears the timer but NEVER refunds the charge already taken (D1e). A - // later sweep in the SAME period finds the snapshot and no-ops; nothing - // negative is ever invoiced. - at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) +// --- unactivated accounts are never swept ------------------------------------- + +func TestModuleOverage_UnactivatedAccountNeverSwept(t *testing.T) { store := newFakeStore() - jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - acct := armedOverageAccount(store, 2, 4, since, jun1) + _, acct := registeredAccount(store) + delete(store.activation, acct) // never bound a card sc := newFakeStripe() - svc := overageSvc(store, sc, at) - cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} + svc := cycle.NewService(store, sc) + ctx := context.Background() - _, err := svc.ChargeAccountOverage(context.Background(), cand, at) - require.NoError(t, err) - require.Len(t, sc.invoiceCalls, 1) - require.EqualValues(t, 9_000_000, store.overageSnaps[acctSnapKey{acct, jun1}].ChargedMicros) - - // Now every module is uninstalled down under the pool via SyncAppModules — - // the recompute CLEARS the timer, but the already-charged snapshot (the money - // taken) is untouched: no refund (D1e). - for id, app := range store.apps { - if app.AccountID == acct { - _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: id, ModuleCount: intPtr(0)}) - require.NoError(t, err) - } - } - require.NotContains(t, store.overageSince, acct, "dropping under the pool clears the timer") - require.EqualValues(t, 9_000_000, store.overageSnaps[acctSnapKey{acct, jun1}].ChargedMicros, - "the charged snapshot survives — uninstall never refunds") - require.Len(t, sc.invoiceCalls, 1, "no refund / negative invoice on uninstall") - - // And a re-run of the sweep in the SAME period is a no-op (snapshot guard) — - // definitely no refund/re-charge. - again, err := svc.ChargeAccountOverage(context.Background(), cand, at.Add(time.Hour)) - require.NoError(t, err) - require.Equal(t, cycle.OverageSkippedAlreadyCharged, again.Status) - require.Len(t, sc.invoiceCalls, 1) -} - -// Guard: the overage amount is exactly the pooled tier, never a per-app tier. -func TestAccountOverageMicros_IsPooledNotPerApp(t *testing.T) { - // Two apps of 4 modules each: per-app each is UNDER the old 5 tier (would be - // $0 overage each pre-032), but POOLED they are 8 → 3 over → $9. This is the - // whole point of the reversal. - require.EqualValues(t, 9_000_000, usage.AccountOverageMicros(4+4)) -} + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) -// --- PR #47 review fixes: regression tests ---------------------------------- - -// Finding #1 [CRITICAL] — cross-leg double charge. Before the fix, -// ChargeAccountOverage called Stripe BEFORE writing account_overage_snapshots; -// a crash between "Stripe succeeded" and "the row committed" left NO row for -// the boundary to see, so it independently charged the FULL pooled overage -// again under a disjoint Idempotency-Key namespace — a real double charge. -// The fix claims the period (a 'pending' row) BEFORE calling Stripe, so the -// claim survives any crash after that point. This test simulates the crash by -// injecting a failure at the LAST step (flipping 'pending' → 'charged') — -// AFTER Stripe already succeeded — and proves the boundary that runs next -// still sees the claim and does NOT charge the overage a second time. -func TestChargeAccountOverage_CrashAfterStripeSuccess_BoundaryDoesNotDoubleCharge(t *testing.T) { - at := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) // graceEnd May 31 < Jun 1 → full overage this period - store := newFakeStore() - jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) - acct := armedOverageAccount(store, 2, 4, since, jun1) // pool 8 → 3 over → $9.00 pooled overage - sc := newFakeStripe() - svc := overageSvc(store, sc, at) - cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} - - // Simulate the crash: Stripe already confirmed the charge (item + invoice - // calls below prove it), but the LAST write — flipping the claim row from - // 'pending' to 'charged' — fails, exactly like a Lambda dying right there. - store.errMarkOverageSnap = errors.New("boom: process died before the claim flipped to charged") - _, err := svc.ChargeAccountOverage(context.Background(), cand, at) - require.Error(t, err, "the crash surfaces as an error to the caller (the Lambda dies / retries)") - - // Stripe's charge already went through before the crash. - require.Len(t, sc.invoiceCalls, 1, "the grace leg's Stripe call already succeeded before the simulated crash") - require.EqualValues(t, 900, sc.itemCalls[0].amountCfg) - - // CRITICAL: the claim row survives the crash (it was written BEFORE Stripe, - // not after) — this is the durable evidence the OTHER leg must respect. - snap, ok := store.overageSnaps[acctSnapKey{acct, jun1}] - require.True(t, ok, "the pending claim row must survive the crash") - require.Equal(t, "grace", snap.Source) - - // The BOUNDARY now closes [Jun 1, Jul 1). WITHOUT the fix (no row would - // exist at this point), it would independently charge the FULL $9.00 - // pooled overage AGAIN under its own ii- Idempotency-Key — a real - // double charge totaling $18.00 for a $9.00 debt. WITH the fix, it must see - // the claim and charge ZERO overage. - store.errMarkOverageSnap = nil // the injected fault was specific to the grace leg's crash - resp, err := svc.RunBillingCycle(context.Background(), acct, jun1, jul1, 0) + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) - require.EqualValues(t, 0, resp.AccountOverageMicros, - "the boundary must NOT independently charge the pooled overage the grace leg already claimed, crash or not") - require.EqualValues(t, 40_000_000, resp.AdvanceBaseMicros, "the flat per-app base is unaffected") - require.EqualValues(t, 4_000, resp.ChargedCents, "base only ($40.00) — NOT $49.00, which would be the double charge") - - // Exactly TWO invoices total: the grace leg's original $9.00 overage charge - // + the boundary's base-only invoice. Never a second overage charge. - require.Len(t, sc.invoiceCalls, 2) + require.Equal(t, 0, res.Pending, "unactivated accounts are excluded from the work list") + require.Empty(t, sc.itemCalls) + require.False(t, store.timers[over].graceResolved) } -// Finding #2 [HIGH] — boundary retry livelock. Before the fix, a reclaim of a -// 'pending' billing_run (after InsertAccountOverageSnapshot succeeded but -// MarkBillingRun crashed) recomputed advanceOverage FRESH from -// snapshot-presence, collapsing it from a real amount to $0 — a DIFFERENT -// combined total reusing the SAME deterministic Stripe Idempotency-Key, which -// a real Stripe would reject (a mismatched-parameter idempotency-key reuse), -// permanently stuck. The fix freezes the overage amount into the -// account_overage_snapshots row at the FIRST attempt and REUSES it verbatim on -// every reclaim of the same run. -func TestRunBillingCycle_ReclaimAfterMarkBillingRunFailure_KeepsStableOverageAmount(t *testing.T) { - store := newFakeStore() - store.chargedTotal = 0 - store.hasPM = true - store.stripeCustomer = "cus_reclaim_overage" - seedApp(store, chargeAccount, 4, false) - seedApp(store, chargeAccount, 4, false) // pool 8 → 3 over → $9.00 pooled overage - sc := newFakeStripe() - svc := chargeSvc(store, sc) - - // Attempt #1: the combined charge (base $40.00 + overage $9.00 = $49.00 = - // 4_900 cents) succeeds at Stripe and the overage snapshot is claimed + - // marked 'charged' — but MarkBillingRun then fails/crashes, so the run row - // stays 'pending' (non-terminal). - store.errMarkRun = errors.New("boom: process died before the run's terminal write landed") - _, err := svc.RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) - require.Error(t, err) - require.Len(t, sc.invoiceCalls, 1) - require.EqualValues(t, 4_900, sc.itemCalls[0].amountCfg) - - snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] - require.True(t, ok) - require.Equal(t, "advance", snap.Source) - require.Equal(t, cycle.OverageSnapshotCharged, snap.Status) - require.EqualValues(t, 9_000_000, snap.ChargedMicros) - - // Attempt #2: RECLAIM — InsertBillingRun reuses the SAME run id (the row is - // still 'pending'). WITHOUT the fix, advanceOverage recomputes to $0 (the - // snapshot "looks already billed" from the boundary's own prior write), - // giving a DIFFERENT combined total ($40.00 = 4_000 cents) reusing the SAME - // Idempotency-Key — a real Stripe rejects this and the run is stuck - // forever. WITH the fix, the overage amount is FROZEN and reused, so - // attempt #2 computes the IDENTICAL $49.00 = 4_900 cents. - store.errMarkRun = nil - resp, err := svc.RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) - require.NoError(t, err) - require.True(t, resp.FirstRun, "the pending run is reclaimed for a fresh attempt") - require.Equal(t, cycle.RunStatusInvoiced, resp.Status) - require.EqualValues(t, 9_000_000, resp.AccountOverageMicros, - "the overage amount must stay stable across the reclaim retry, never recompute to 0") - require.EqualValues(t, 4_900, resp.ChargedCents, "the SAME combined total as attempt #1 — never 4_000 (base-only)") - require.Len(t, sc.invoiceCalls, 2, "the reclaim re-calls Stripe with the SAME idem key (safe/idempotent for the real client)") - require.EqualValues(t, 4_900, sc.itemCalls[1].amountCfg, "attempt #2's item amount matches attempt #1's exactly") - require.Equal(t, sc.itemCalls[0].idemKey, sc.itemCalls[1].idemKey, "the SAME deterministic ii- key is reused across the reclaim") - require.Len(t, store.insertedRuns, 1, "reclaim reuses the same run row, never a second one") -} +// --- SyncAppModules timer synthesis (grow + LIFO shrink + delete) ------------- -// Finding #3 [MEDIUM, judgment call] — pooled-overage growth after the -// period's grace charge went permanently unbilled (recomputeAccountOverage -// only arms/clears the timer; it never re-evaluated an already-charged -// period). The fix charges an INCREMENTAL top-up, conservatively prorated from -// the sweep's own instant to the period end (never retroactively). -func TestChargeAccountOverage_PoolGrowthMidPeriodChargesIncrementalTopUp(t *testing.T) { - // First charge: pool 8 (2 apps × 4) → 3 over → $9.00 (900 cents), the exact - // fixture TestChargeAccountOverage_ChargesFullWhenGraceEndedBeforePeriodStart - // uses. Then a THIRD app (4 modules) installs mid-period, growing the pool - // to 12 → 7 over. A later sweep pass (Jun 20 — 11 days left of the 30-day - // [Jun 1, Jul 1) period) must charge the INCREMENTAL 4-module delta - // prorated for the remaining 11 days: 4 × $3.00 × 11/30 = $4.40 (440 - // cents) — never $0 (the pre-fix behavior, permanently unbilled) and never - // the full $12.00 (never retroactive for time before the sweep noticed). - at1 := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC) - since := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) +func TestSyncAppModules_GrowsAndLIFOShrinksTimers(t *testing.T) { store := newFakeStore() - jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) - acct := armedOverageAccount(store, 2, 4, since, jun1) + user, _ := registeredAccount(store) sc := newFakeStripe() - cand := cycle.OverageGraceCandidate{ID: acct, OverageSince: since, ActivatedAt: store.activation[acct]} - - first, err := overageSvc(store, sc, at1).ChargeAccountOverage(context.Background(), cand, at1) - require.NoError(t, err) - require.Equal(t, cycle.OverageCharged, first.Status) - require.EqualValues(t, 900, first.ChargedCents) + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() - // Pool grows mid-period: a third app (4 modules) installs, 8 → 12. - seedAppCreated(store, acct, 4, false, jun1.AddDate(0, 0, -1)) + // Register with 2 modules → 2 timers at created_at. + registerMirror(t, svc, user, appID, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), 2) + require.Equal(t, 2, liveTimerCount(store, appID)) - at2 := time.Date(2026, 6, 20, 9, 0, 0, 0, time.UTC) - second, err := overageSvc(store, sc, at2).ChargeAccountOverage(context.Background(), cand, at2) - require.NoError(t, err) - require.Equal(t, cycle.OverageToppedUp, second.Status) - require.Equal(t, 7, second.OverCount, "pool 12 − included 5 = 7 over, the NEW cumulative over-count") - require.EqualValues(t, 440, second.ChargedCents, "4 incremental modules × $3.00 × 11/30 remaining days = $4.40 — never $0, never $12.00") - - require.Len(t, sc.invoiceCalls, 2, "one invoice for the original charge, one for the top-up") - require.NotEqual(t, sc.itemCalls[0].idemKey, sc.itemCalls[1].idemKey, "the top-up uses its OWN Idempotency-Key, distinct from the original charge") - - snap := store.overageSnaps[acctSnapKey{acct, jun1}] - require.Equal(t, 7, snap.OverCount) - require.EqualValues(t, 13_400_000, snap.ChargedMicros, - "cumulative: the original $9.00 + the $4.40 top-up = $13.40 total billed for the period — the display reads this exact figure") - require.Equal(t, cycle.OverageSnapshotCharged, snap.Status) - - // A third pass with NO further pool growth is a clean no-op (no third - // charge, D1e — never re-bill what is already covered). - third, err := overageSvc(store, sc, at2.Add(time.Hour)).ChargeAccountOverage(context.Background(), cand, at2.Add(time.Hour)) + // Grow 2 → 5: inserts 3 new timers anchored at now (appsNow). + five := 5 + _, err := svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: &five}) require.NoError(t, err) - require.Equal(t, cycle.OverageSkippedAlreadyCharged, third.Status) - require.Len(t, sc.invoiceCalls, 2, "no third invoice when the pool hasn't grown further") -} - -// Finding #4 [MEDIUM] — the boundary's account_overage_snapshots row -// (source='advance') stored the literal Idempotency-Key STRING ("ii-") -// as InvoiceItemID instead of the genuine Stripe invoice item id, because -// s.charge() discarded CreateInvoiceItem's return value. The fix threads the -// real item back from s.charge() and stores IT. -func TestRunBillingCycle_AdvanceOverageSnapshotStoresGenuineStripeItemID(t *testing.T) { - store := newFakeStore() - store.chargedTotal = 0 - store.hasPM = true - store.stripeCustomer = "cus_item_id" - seedApp(store, chargeAccount, 4, false) - seedApp(store, chargeAccount, 4, false) // pool 8 → 3 over → $9.00 pooled overage - sc := newFakeStripe() + require.Equal(t, 5, liveTimerCount(store, appID)) - resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + // Shrink 5 → 3: LIFO-removes the 2 NEWEST (the appsNow installs), leaving the + // 2 original created_at timers + 1 of the appsNow ones. + three := 3 + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: &three}) require.NoError(t, err) - require.EqualValues(t, 9_000_000, resp.AccountOverageMicros) - require.Len(t, sc.itemCalls, 1) - - snap, ok := store.overageSnaps[acctSnapKey{chargeAccount, periodStart}] - require.True(t, ok) + require.Equal(t, 3, liveTimerCount(store, appID)) + // Both original created_at timers survive (they are the OLDEST — LIFO removes + // newest first). + created := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + origLive := 0 + for _, tm := range store.timers { + if tm.appID == appID && !tm.removed && tm.installedAt.Equal(created) { + origLive++ + } + } + require.Equal(t, 2, origLive, "LIFO removal keeps the oldest installs") - idemKey := sc.itemCalls[0].idemKey - require.True(t, strings.HasPrefix(idemKey, "ii-"), "sanity: the item was created under the ii- idempotency key") - require.NotEqual(t, idemKey, snap.InvoiceItemID, - "the stored id must be the GENUINE Stripe invoice item id, never the ii- idempotency-key string") - require.True(t, strings.HasPrefix(snap.InvoiceItemID, "ii_test_"), - "must be the REAL Stripe invoice item id the fake client generated for this call") + // Delete the app → all remaining live timers soft-removed. + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Equal(t, 0, liveTimerCount(store, appID)) } diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 8c5dccb..a26b3fe 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -70,12 +70,11 @@ type fakeStore struct { activation map[uuid.UUID]time.Time baseSnapshots map[snapKey]fakeBaseSnapshot - // account-wide POOLED overage state (migration 032). overageSince models - // accounts.overage_since (present → armed at that instant); overageSnaps - // models account_overage_snapshots keyed (account, period_start) like the - // PRIMARY KEY, recording the row each charge leg froze. - overageSince map[uuid.UUID]time.Time - overageSnaps map[acctSnapKey]cycle.AccountOverageSnapshot + // per-module install-timer state (migration 033). timers models + // ms_billing.app_module_overage_timers keyed by surrogate id; each row's + // removed/graceResolved/graceCharged* fields mirror the columns the FIFO + + // Leg 1 sweep read and write. + timers map[uuid.UUID]*fakeTimer // captured charge writes insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) @@ -118,27 +117,30 @@ type fakeStore struct { errPendingProration error // AppsPendingProration errChargeLocked error // ChargeProrationLocked - errPooledCount error // PooledModuleCount - errStartOverage error // StartAccountOverage - errClearOverage error // ClearAccountOverage - errOverageGrace error // AccountsInOverageGrace - errOverageSnap error // AccountOverageSnapshot - errInsertOverageSnap error // InsertAccountOverageSnapshot - errMarkOverageSnap error // MarkAccountOverageSnapshotCharged - errTopUpOverageSnap error // TopUpAccountOverageSnapshot - - // overageClaimLoses, when > 0, makes the NEXT N InsertAccountOverageSnapshot - // calls report "lost the race" (inserted=false) WITHOUT actually writing - // anything — simulating a concurrent claim that beat this call, so a test - // can drive the claim-insert-loses-the-race path deterministically. - overageClaimLoses int -} - -// acctSnapKey mirrors the account_overage_snapshots PRIMARY KEY -// (account_id, period_start). -type acctSnapKey struct { - account uuid.UUID - periodStart time.Time + errLiveTimerCount error // LiveModuleTimerCountForApp + errInsertTimers error // InsertModuleOverageTimers + errRemoveNewest error // SoftRemoveNewestModuleTimers + errRemoveAllTimers error // SoftRemoveAllModuleTimersForApp + errTimersPastGrace error // ModuleOverageTimersPastGrace + errTimerRank error // LiveModuleTimerRankBefore + errMarkIncluded error // MarkModuleTimerIncluded + errMarkTimerCharged error // MarkModuleTimerCharged +} + +// fakeTimer models one ms_billing.app_module_overage_timers row (migration 033). +type fakeTimer struct { + id uuid.UUID + accountID uuid.UUID + appID uuid.UUID + installedAt time.Time + graceExpiresAt time.Time + removed bool + removedAt time.Time + graceResolved bool + graceCharged bool + graceChargedAt time.Time + graceInvoiceID string + graceInvoiceItemID string } // snapKey mirrors the app_base_snapshots PRIMARY KEY (app_id, period_start). @@ -178,8 +180,7 @@ func newFakeStore() *fakeStore { accountsByUser: map[uuid.UUID]uuid.UUID{}, activation: map[uuid.UUID]time.Time{}, baseSnapshots: map[snapKey]fakeBaseSnapshot{}, - overageSince: map[uuid.UUID]time.Time{}, - overageSnaps: map[acctSnapKey]cycle.AccountOverageSnapshot{}, + timers: map[uuid.UUID]*fakeTimer{}, // Default collection state: arrears mode with a high credit limit + no // spend ceiling, so the existing charge tests (which don't set risk // fields) flow through the gate to the charge path unchanged. Risk tests @@ -580,112 +581,152 @@ func (f *fakeStore) InsertAdvanceBaseSnapshot(_ context.Context, snap cycle.AppB return nil } -// --- account-wide POOLED overage fake (migration 032) ----------------------- +// --- per-module install-timer fake (migration 033) -------------------------- -// PooledModuleCount sums module_count over the account's live apps, mirroring -// the SQL SUM(module_count) WHERE account_id = ? AND deleted_at IS NULL. -func (f *fakeStore) PooledModuleCount(_ context.Context, accountID uuid.UUID) (int, error) { - if f.errPooledCount != nil { - return 0, f.errPooledCount +func (f *fakeStore) LiveModuleTimerCountForApp(_ context.Context, appID uuid.UUID) (int, error) { + if f.errLiveTimerCount != nil { + return 0, f.errLiveTimerCount } - sum := 0 - for _, app := range f.apps { - if app.AccountID == accountID && !app.Deleted { - sum += app.ModuleCount + n := 0 + for _, t := range f.timers { + if t.appID == appID && !t.removed { + n++ } } - return sum, nil + return n, nil } -func (f *fakeStore) StartAccountOverage(_ context.Context, accountID uuid.UUID, since time.Time) error { - if f.errStartOverage != nil { - return f.errStartOverage +func (f *fakeStore) InsertModuleOverageTimers(_ context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error { + if f.errInsertTimers != nil { + return f.errInsertTimers } - if _, armed := f.overageSince[accountID]; !armed { - f.overageSince[accountID] = since // first-crossing-wins (WHERE overage_since IS NULL) + for i := 0; i < n; i++ { + id := uuid.New() + f.timers[id] = &fakeTimer{ + id: id, + accountID: accountID, + appID: appID, + installedAt: installedAt, + graceExpiresAt: graceExpiresAt, + } } return nil } -func (f *fakeStore) ClearAccountOverage(_ context.Context, accountID uuid.UUID) error { - if f.errClearOverage != nil { - return f.errClearOverage +// liveTimersForApp returns the app's live timers sorted (installed_at DESC, id +// DESC) — the LIFO removal order. +func (f *fakeStore) liveTimersForApp(appID uuid.UUID) []*fakeTimer { + var out []*fakeTimer + for _, t := range f.timers { + if t.appID == appID && !t.removed { + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { + if !out[i].installedAt.Equal(out[j].installedAt) { + return out[i].installedAt.After(out[j].installedAt) + } + return out[i].id.String() > out[j].id.String() + }) + return out +} + +func (f *fakeStore) SoftRemoveNewestModuleTimers(_ context.Context, appID uuid.UUID, n int, removedAt time.Time) error { + if f.errRemoveNewest != nil { + return f.errRemoveNewest + } + live := f.liveTimersForApp(appID) + for i := 0; i < n && i < len(live); i++ { + live[i].removed = true + live[i].removedAt = removedAt } - delete(f.overageSince, accountID) // idempotent (WHERE overage_since IS NOT NULL) return nil } -func (f *fakeStore) AccountsInOverageGrace(_ context.Context, cutoff time.Time) ([]cycle.OverageGraceCandidate, error) { - if f.errOverageGrace != nil { - return nil, f.errOverageGrace +func (f *fakeStore) SoftRemoveAllModuleTimersForApp(_ context.Context, appID uuid.UUID, removedAt time.Time) error { + if f.errRemoveAllTimers != nil { + return f.errRemoveAllTimers } - out := []cycle.OverageGraceCandidate{} - for id, since := range f.overageSince { - activatedAt, activated := f.activation[id] - if !activated { - continue // activated_at IS NOT NULL gate - } - if since.After(cutoff) { - continue // grace still holding (overage_since <= cutoff) + for _, t := range f.timers { + if t.appID == appID && !t.removed { + t.removed = true + t.removedAt = removedAt } - out = append(out, cycle.OverageGraceCandidate{ID: id, OverageSince: since, ActivatedAt: activatedAt}) } - return out, nil + return nil } -func (f *fakeStore) AccountOverageSnapshot(_ context.Context, accountID uuid.UUID, periodStart time.Time) (cycle.AccountOverageSnapshot, bool, error) { - if f.errOverageSnap != nil { - return cycle.AccountOverageSnapshot{}, false, f.errOverageSnap +func (f *fakeStore) ModuleOverageTimersPastGrace(_ context.Context, at time.Time) ([]cycle.ModuleOverageCandidate, error) { + if f.errTimersPastGrace != nil { + return nil, f.errTimersPastGrace + } + var out []cycle.ModuleOverageCandidate + for _, t := range f.timers { + if t.removed || t.graceResolved || t.graceExpiresAt.After(at) { + continue + } + activatedAt, activated := f.activation[t.accountID] + if !activated { + continue // activated_at IS NOT NULL gate + } + out = append(out, cycle.ModuleOverageCandidate{ + ID: t.id, + AccountID: t.accountID, + AppID: t.appID, + InstalledAt: t.installedAt, + GraceExpiresAt: t.graceExpiresAt, + ActivatedAt: activatedAt, + }) } - snap, ok := f.overageSnaps[acctSnapKey{accountID, periodStart}] - return snap, ok, nil + // Ordered (installed_at, id) like the query, so the sweep charges oldest-first + // deterministically. + sort.Slice(out, func(i, j int) bool { + if !out[i].InstalledAt.Equal(out[j].InstalledAt) { + return out[i].InstalledAt.Before(out[j].InstalledAt) + } + return out[i].ID.String() < out[j].ID.String() + }) + return out, nil } -func (f *fakeStore) InsertAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) (bool, error) { - if f.errInsertOverageSnap != nil { - return false, f.errInsertOverageSnap - } - if f.overageClaimLoses > 0 { - f.overageClaimLoses-- - return false, nil // simulate a concurrent claim winning the race +func (f *fakeStore) LiveModuleTimerRankBefore(_ context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) { + if f.errTimerRank != nil { + return 0, f.errTimerRank } - // ON CONFLICT (account_id, period_start) DO NOTHING: an existing row wins. - k := acctSnapKey{snap.AccountID, snap.PeriodStart} - if _, exists := f.overageSnaps[k]; exists { - return false, nil + rank := 0 + for _, t := range f.timers { + if t.accountID != accountID || t.removed { + continue + } + if t.installedAt.Before(installedAt) || + (t.installedAt.Equal(installedAt) && t.id.String() < timerID.String()) { + rank++ + } } - f.overageSnaps[k] = snap - return true, nil + return rank, nil } -func (f *fakeStore) MarkAccountOverageSnapshotCharged(_ context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error { - if f.errMarkOverageSnap != nil { - return f.errMarkOverageSnap +func (f *fakeStore) MarkModuleTimerIncluded(_ context.Context, timerID uuid.UUID) error { + if f.errMarkIncluded != nil { + return f.errMarkIncluded } - k := acctSnapKey{accountID, periodStart} - snap, ok := f.overageSnaps[k] - if !ok { - return nil // defensive: mirrors the DB's unconditional UPDATE affecting 0 rows + if t, ok := f.timers[timerID]; ok && !t.graceResolved { + t.graceResolved = true } - snap.Status = cycle.OverageSnapshotCharged - snap.InvoiceItemID = invoiceItemID - f.overageSnaps[k] = snap return nil } -func (f *fakeStore) TopUpAccountOverageSnapshot(_ context.Context, snap cycle.AccountOverageSnapshot) error { - if f.errTopUpOverageSnap != nil { - return f.errTopUpOverageSnap +func (f *fakeStore) MarkModuleTimerCharged(_ context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error { + if f.errMarkTimerCharged != nil { + return f.errMarkTimerCharged } - k := acctSnapKey{snap.AccountID, snap.PeriodStart} - existing, ok := f.overageSnaps[k] - if !ok || existing.Status != cycle.OverageSnapshotCharged { - return nil // mirrors the DB's WHERE status='charged' guard: a non-match is a no-op + if t, ok := f.timers[timerID]; ok && !t.graceResolved { + t.graceResolved = true + t.graceCharged = true + t.graceChargedAt = chargedAt + t.graceInvoiceID = invoiceID + t.graceInvoiceItemID = invoiceItemID } - existing.OverCount = snap.OverCount - existing.ChargedMicros = snap.ChargedMicros - existing.InvoiceItemID = snap.InvoiceItemID - f.overageSnaps[k] = existing return nil } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index f356075..64700a3 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -261,98 +261,67 @@ type Store interface { // re-run never rewrites what was already recorded as billed. InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSnapshot) error - // --- account-wide POOLED module overage (migration 032) ----------------- - - // PooledModuleCount returns the account-wide pooled installed-module count: - // SUM(module_count) over the account's LIVE apps. The overage timer recompute - // and the mid-period grace sweep tier on it (overage = $3 × max(0, this − - // IncludedModules)). - PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) - - // StartAccountOverage stamps the account's grace-timer anchor (overage_since) - // the FIRST time its pool crosses IncludedModules — WHERE overage_since IS - // NULL, so it is first-crossing-wins/idempotent (a later recompute that finds - // it already armed is a no-op). - StartAccountOverage(ctx context.Context, accountID uuid.UUID, since time.Time) error - - // ClearAccountOverage disarms the grace timer (overage_since → NULL) when the - // pool drops back to ≤ IncludedModules — WHERE overage_since IS NOT NULL, so - // it is idempotent. No refund (D1e): clearing only stops FUTURE accrual. - ClearAccountOverage(ctx context.Context, accountID uuid.UUID) error - - // AccountsInOverageGrace returns every account whose grace timer has EXPIRED - // as of cutoff (overage_since <= cutoff) and that is chargeable (activated) — - // the mid-period grace sweep's work list, with each account's overage_since - // (grace anchor) and activated_at (period anchor). - AccountsInOverageGrace(ctx context.Context, cutoff time.Time) ([]OverageGraceCandidate, error) - - // AccountOverageSnapshot reads the frozen pooled overage a charge leg - // claimed/billed for ONE (account, period) — the double-charge guard both - // the grace sweep and the boundary consult (found=true → this period's - // pooled overage is CLAIMED — status 'pending' or 'charged' — the OTHER leg - // must never independently charge it; the claiming leg resumes/reuses it). - // found=false → never claimed. - AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (snap AccountOverageSnapshot, found bool, err error) - - // InsertAccountOverageSnapshot CLAIMS one period's pooled overage charge for - // a leg — callers write status="pending" BEFORE calling Stripe (the - // crash-safe marker; see migration 032's status column doc) — with - // ON CONFLICT (account_id, period_start) DO NOTHING. inserted=false means - // the row already existed (a prior claim by this leg or the other one) and - // the caller MUST re-read AccountOverageSnapshot and defer to the winner - // rather than proceed to charge Stripe under its own claim. - InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) (inserted bool, err error) - - // MarkAccountOverageSnapshotCharged flips a claimed row to status="charged" - // once Stripe actually created the invoice item/invoice, recording the - // GENUINE Stripe invoice item id (never an idempotency-key string). - MarkAccountOverageSnapshotCharged(ctx context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error - - // TopUpAccountOverageSnapshot records an incremental charge against an - // already-'charged' period whose pool grew further before the period closed - // (the mid-period sweep's top-up leg, a judgment call — see - // cycle/overage.go's topUpGraceOverage doc). snap.OverCount/ChargedMicros - // are the NEW cumulative totals for the period. - TopUpAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error + // --- per-module-instance overage timers (migration 033) ----------------- + + // LiveModuleTimerCountForApp returns the count of an app's currently-live + // (removed_at IS NULL) install timers — the reconciliation input RegisterApp + // / SyncAppModules use to bring the live-timer set into line with the app's + // module_count idempotently across fire-and-forget retries. + LiveModuleTimerCountForApp(ctx context.Context, appID uuid.UUID) (int, error) + + // InsertModuleOverageTimers inserts n identical install timers for one app, + // all anchored at installedAt with grace expiring at graceExpiresAt (= + // installedAt + the 3-day grace window). n <= 0 is a no-op. + InsertModuleOverageTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error + + // SoftRemoveNewestModuleTimers LIFO-soft-removes the n NEWEST currently-live + // install timers for one app (a SyncAppModules shrink removes what was added + // most recently). n <= 0 is a no-op. + SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error + + // SoftRemoveAllModuleTimersForApp soft-removes every still-live install timer + // for an app — the app-deletion path. Idempotent (WHERE removed_at IS NULL). + SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error + + // ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install + // timers whose grace window has elapsed as of `at`, on chargeable (activated) + // accounts — each with the account's activation anchor so the sweep resolves + // the install's period window without a second read. + ModuleOverageTimersPastGrace(ctx context.Context, at time.Time) ([]ModuleOverageCandidate, error) + + // LiveModuleTimerRankBefore returns the 0-based FIFO rank of one install timer + // among the account's currently-live timers ordered (installed_at ASC, id + // ASC): the count of live timers ordering STRICTLY BEFORE it. rank < + // IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". Computed + // fresh at every grace-check (never cached). + LiveModuleTimerRankBefore(ctx context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) + + // MarkModuleTimerIncluded stamps the TERMINAL "included" verdict + // (grace_resolved=true, no charge) — first-write-wins (WHERE grace_resolved + // IS false). Monotonicity makes it permanent; the row is never re-checked. + MarkModuleTimerIncluded(ctx context.Context, timerID uuid.UUID) error + + // MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once + // Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved=true and + // the GENUINE Stripe invoice / invoice-item ids (never idempotency-key + // strings). WHERE grace_resolved IS false keeps a crash-retry idempotent. + MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error } -// OverageGraceCandidate is one account the mid-period grace sweep evaluates: its -// id plus the two anchors the sweep needs — OverageSince (the grace timer's -// start; grace ends at OverageSince + the grace window) and ActivatedAt (the -// billing-period anchor, ADR 0005, used to resolve the current window). -type OverageGraceCandidate struct { - ID uuid.UUID - OverageSince time.Time - ActivatedAt time.Time +// ModuleOverageCandidate is one per-module-instance install timer the Leg 1 +// grace sweep evaluates (migration 033): its surrogate id + app/account, the +// InstalledAt anchor (FIFO key AND proration anchor), GraceExpiresAt (already +// elapsed for a candidate), and the owning account's ActivatedAt (the billing- +// period anchor, ADR 0005, used to resolve the install's period window). +type ModuleOverageCandidate struct { + ID uuid.UUID + AccountID uuid.UUID + AppID uuid.UUID + InstalledAt time.Time + GraceExpiresAt time.Time + ActivatedAt time.Time } -// AccountOverageSnapshot is the in-memory form of a -// ms_billing.account_overage_snapshots row (migration 032): what one charge leg -// claimed/billed one account for one period's POOLED module overage. -// PeriodStart is the display + double-charge lookup key; ChargedMicros is the -// exact overage the invoice billed (prorated for a 'grace' row, full for an -// 'advance' row, or the cumulative total after a top-up); OverCount is the -// pooled over-count it tiered on; Source is 'grace' or 'advance'; Status is -// 'pending' (claimed, Stripe not yet confirmed — the crash-safe marker) or -// 'charged' (Stripe confirmed); InvoiceItemID is the genuine Stripe item id -// (empty while Status=="pending", or for a 0-cent charge). -type AccountOverageSnapshot struct { - AccountID uuid.UUID - PeriodStart time.Time - PeriodEnd time.Time - OverCount int - ChargedMicros int64 - Source string - Status string - InvoiceItemID string -} - -// Account overage snapshot status values (migration 032's status column). -const ( - OverageSnapshotPending = "pending" - OverageSnapshotCharged = "charged" -) - // AppModuleCount pairs one live roster app with its module_count snapshot — // one advance-base input row. The boundary leg needs the app id (not just the // count) to write the per-app-period base snapshot it bills (migration 028). @@ -1245,113 +1214,105 @@ func (s *pgxStore) InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSn return err } -// --- account-wide POOLED module overage (migration 032) -------------------- +// --- per-module-instance overage timers (migration 033) -------------------- -func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) { - sum, err := s.q.SumLiveModuleCount(ctx, accountID.String()) +func (s *pgxStore) LiveModuleTimerCountForApp(ctx context.Context, appID uuid.UUID) (int, error) { + n, err := s.q.LiveModuleTimerCountForApp(ctx, appID.String()) if err != nil { return 0, err } - return int(sum), nil + return int(n), nil } -func (s *pgxStore) StartAccountOverage(ctx context.Context, accountID uuid.UUID, since time.Time) error { - // 0 rows = already armed (first-crossing-wins, WHERE overage_since IS NULL) — - // a no-op, not an error: the original anchor survives. - _, err := s.q.StartAccountOverage(ctx, db.StartAccountOverageParams{ - ID: accountID.String(), - OverageSince: pgtype.Timestamptz{Time: since, Valid: true}, +func (s *pgxStore) InsertModuleOverageTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error { + if n <= 0 { + return nil // generate_series(1, 0) would be a no-op anyway; skip the round-trip + } + return s.q.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ + AccountID: accountID.String(), + AppID: appID.String(), + InstalledAt: installedAt, + GraceExpiresAt: graceExpiresAt, + Count: int32(n), //nolint:gosec // n = a module_count delta, bounded by maxModuleCount (100000), far below int32 max }) - return err } -func (s *pgxStore) ClearAccountOverage(ctx context.Context, accountID uuid.UUID) error { - // 0 rows = already clear (WHERE overage_since IS NOT NULL) — idempotent no-op. - _, err := s.q.ClearAccountOverage(ctx, accountID.String()) - return err +func (s *pgxStore) SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error { + if n <= 0 { + return nil + } + return s.q.SoftRemoveNewestModuleTimers(ctx, db.SoftRemoveNewestModuleTimersParams{ + AppID: appID.String(), + LimitCount: int32(n), //nolint:gosec // n = a module_count delta, bounded by maxModuleCount (100000), far below int32 max + RemovedAt: removedAt, + }) +} + +func (s *pgxStore) SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + return s.q.SoftRemoveAllModuleTimersForApp(ctx, db.SoftRemoveAllModuleTimersForAppParams{ + AppID: appID.String(), + RemovedAt: pgtype.Timestamptz{Time: removedAt, Valid: true}, + }) } -func (s *pgxStore) AccountsInOverageGrace(ctx context.Context, cutoff time.Time) ([]OverageGraceCandidate, error) { - rows, err := s.q.AccountsInOverageGrace(ctx, pgtype.Timestamptz{Time: cutoff, Valid: true}) +func (s *pgxStore) ModuleOverageTimersPastGrace(ctx context.Context, at time.Time) ([]ModuleOverageCandidate, error) { + rows, err := s.q.ModuleOverageTimersPastGrace(ctx, at) if err != nil { return nil, err } - out := make([]OverageGraceCandidate, 0, len(rows)) + out := make([]ModuleOverageCandidate, 0, len(rows)) for _, r := range rows { id, err := uuid.Parse(r.ID) if err != nil { return nil, err } - // The query filters both columns NOT NULL, so a non-Valid value here is a - // driver anomaly; skip it defensively rather than anchor on the zero time. - if !r.OverageSince.Valid || !r.ActivatedAt.Valid { + acct, err := uuid.Parse(r.AccountID) + if err != nil { + return nil, err + } + app, err := uuid.Parse(r.AppID) + if err != nil { + return nil, err + } + // The query filters activated_at IS NOT NULL, so a non-Valid value here is + // a driver anomaly; skip it defensively rather than anchor on the zero time. + if !r.ActivatedAt.Valid { continue } - out = append(out, OverageGraceCandidate{ - ID: id, - OverageSince: r.OverageSince.Time, - ActivatedAt: r.ActivatedAt.Time, + out = append(out, ModuleOverageCandidate{ + ID: id, + AccountID: acct, + AppID: app, + InstalledAt: r.InstalledAt, + GraceExpiresAt: r.GraceExpiresAt, + ActivatedAt: r.ActivatedAt.Time, }) } return out, nil } -func (s *pgxStore) AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (AccountOverageSnapshot, bool, error) { - row, err := s.q.SelectAccountOverageSnapshot(ctx, db.SelectAccountOverageSnapshotParams{ +func (s *pgxStore) LiveModuleTimerRankBefore(ctx context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) { + rank, err := s.q.LiveModuleTimerRankBefore(ctx, db.LiveModuleTimerRankBeforeParams{ AccountID: accountID.String(), - PeriodStart: periodStart, + InstalledAt: installedAt, + TimerID: timerID.String(), }) - if errors.Is(err, pgx.ErrNoRows) { - return AccountOverageSnapshot{}, false, nil - } if err != nil { - return AccountOverageSnapshot{}, false, err - } - return AccountOverageSnapshot{ - AccountID: accountID, - PeriodStart: periodStart, - OverCount: int(row.OverCount), - ChargedMicros: row.ChargedMicros, - Source: row.Source, - Status: row.Status, - }, true, nil -} - -func (s *pgxStore) InsertAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) (bool, error) { - // rows=0 = ON CONFLICT DO NOTHING kept an existing row (a prior claim by - // this leg or the other one) — the caller must re-read and defer to it, - // never proceed to charge Stripe under its own (lost) claim. - rows, err := s.q.InsertAccountOverageSnapshot(ctx, db.InsertAccountOverageSnapshotParams{ - AccountID: snap.AccountID.String(), - PeriodStart: snap.PeriodStart, - PeriodEnd: snap.PeriodEnd, - OverCount: int32(snap.OverCount), //nolint:gosec // over_count = pooled sum − IncludedModules; the pool is Σ validated module_counts (each ≤ maxModuleCount), far below int32 max - ChargedMicros: snap.ChargedMicros, - Source: snap.Source, - Status: snap.Status, - InvoiceItemID: pgtype.Text{String: snap.InvoiceItemID, Valid: snap.InvoiceItemID != ""}, - }) - if err != nil { - return false, err + return 0, err } - return rows > 0, nil + return int(rank), nil } -func (s *pgxStore) MarkAccountOverageSnapshotCharged(ctx context.Context, accountID uuid.UUID, periodStart time.Time, invoiceItemID string) error { - return s.q.MarkAccountOverageSnapshotCharged(ctx, db.MarkAccountOverageSnapshotChargedParams{ - AccountID: accountID.String(), - PeriodStart: periodStart, - InvoiceItemID: pgtype.Text{String: invoiceItemID, Valid: invoiceItemID != ""}, - }) +func (s *pgxStore) MarkModuleTimerIncluded(ctx context.Context, timerID uuid.UUID) error { + return s.q.MarkModuleTimerIncluded(ctx, timerID.String()) } -func (s *pgxStore) TopUpAccountOverageSnapshot(ctx context.Context, snap AccountOverageSnapshot) error { - return s.q.TopUpAccountOverageSnapshot(ctx, db.TopUpAccountOverageSnapshotParams{ - AccountID: snap.AccountID.String(), - PeriodStart: snap.PeriodStart, - OverCount: int32(snap.OverCount), //nolint:gosec // over_count = pooled sum − IncludedModules; the pool is Σ validated module_counts (each ≤ maxModuleCount), far below int32 max - ChargedMicros: snap.ChargedMicros, - InvoiceItemID: pgtype.Text{String: snap.InvoiceItemID, Valid: snap.InvoiceItemID != ""}, +func (s *pgxStore) MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error { + return s.q.MarkModuleTimerCharged(ctx, db.MarkModuleTimerChargedParams{ + TimerID: timerID.String(), + GraceChargedAt: chargedAt, + GraceInvoiceID: pgtype.Text{String: invoiceID, Valid: invoiceID != ""}, + GraceInvoiceItemID: pgtype.Text{String: invoiceItemID, Valid: invoiceItemID != ""}, }) } diff --git a/internal/account/cycle/types.go b/internal/account/cycle/types.go index 1e395a4..0a77310 100644 --- a/internal/account/cycle/types.go +++ b/internal/account/cycle/types.go @@ -206,23 +206,15 @@ type ChargeSummary struct { ArrearsMicros int64 // AdvanceBaseMicros is the NEW period's advance base fee: Σ over the - // account's live apps of the FLAT BaseFeeMicros (module overage is - // account-wide pooled, migration 032 — no longer a per-app tier here). 0 for - // a pre-backfill account (no mirror rows). + // account's live apps of the FLAT BaseFeeMicros. Module overage is NO longer + // billed at the boundary in this leg — it rides per-module-instance grace + // timers (migration 033, Leg 1 in cycle/overage.go); the boundary + // per-module overage precharge (scenario 6) is a Stage B follow-up. 0 for a + // pre-backfill account (no mirror rows). AdvanceBaseMicros int64 - // AccountOverageMicros is the CLOSING period's account-wide POOLED module - // overage (migration 032): $3 × max(0, Σ live-app module_count − - // IncludedModules), charged ONCE at the boundary only when the mid-period - // grace sweep did NOT already bill it (no account_overage_snapshots row for - // the period). 0 when already billed mid-period or the account is under the - // pool. The invoice total is ArrearsMicros + AdvanceBaseMicros + - // AccountOverageMicros; only when ALL are 0 is the Stripe call skipped. - AccountOverageMicros int64 - // ChargedCents is the whole-cent amount sent to Stripe (micros → cents - // round-half-up over arrears + advance base + pooled overage). 0 when no - // charge happened. + // round-half-up over arrears + advance base). 0 when no charge happened. ChargedCents int64 // StripeInvoiceID is the created Stripe invoice id, empty when no charge diff --git a/internal/account/db/models.go b/internal/account/db/models.go index e1e4206..5fde5a1 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -331,24 +331,10 @@ type MsBillingAccount struct { SpendCeilingMicros pgtype.Int8 `json:"spend_ceiling_micros"` // UTC instant the account bound its FIRST credit card (billing-account activation). Immutable, first-bind-wins; billing-period anchor day = activated_at day-of-month (ADR 0005). NULL = never activated -> skipped by cmd/billing-cycle. ActivatedAt pgtype.Timestamptz `json:"activated_at"` - // UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 (account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account. - OverageSince pgtype.Timestamptz `json:"overage_since"` // Per-account size threshold (micro-USD) above which a SUCCESSFUL off-session charge is disclosed as "large" on the billing page. NULL = platform default ($100 = 100000000 micros). Resolved at charge time; pure disclosure, changes no charging behaviour. AutoCollectThresholdMicros pgtype.Int8 `json:"auto_collect_threshold_micros"` } -type MsBillingAccountOverageSnapshot struct { - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - OverCount int32 `json:"over_count"` - ChargedMicros int64 `json:"charged_micros"` - Source string `json:"source"` - Status string `json:"status"` - InvoiceItemID pgtype.Text `json:"invoice_item_id"` - CreatedAt time.Time `json:"created_at"` -} - type MsBillingAddCardRequest struct { ID string `json:"id"` AccountID string `json:"account_id"` @@ -384,6 +370,20 @@ type MsBillingAppBaseSnapshot struct { CreatedAt time.Time `json:"created_at"` } +type MsBillingAppModuleOverageTimer struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + RemovedAt pgtype.Timestamptz `json:"removed_at"` + GraceChargedAt pgtype.Timestamptz `json:"grace_charged_at"` + GraceResolved bool `json:"grace_resolved"` + GraceInvoiceID pgtype.Text `json:"grace_invoice_id"` + GraceInvoiceItemID pgtype.Text `json:"grace_invoice_item_id"` + CreatedAt time.Time `json:"created_at"` +} + type MsBillingBillingPeriod struct { ID string `json:"id"` AccountID string `json:"account_id"` diff --git a/internal/account/db/module_timers.sql.go b/internal/account/db/module_timers.sql.go new file mode 100644 index 0000000..801d66c --- /dev/null +++ b/internal/account/db/module_timers.sql.go @@ -0,0 +1,248 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: module_timers.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertModuleOverageTimers = `-- name: InsertModuleOverageTimers :exec +INSERT INTO ms_billing.app_module_overage_timers + (account_id, app_id, installed_at, grace_expires_at) +SELECT $1::uuid, $2::uuid, $3::timestamptz, $4::timestamptz +FROM generate_series(1, $5::int) +` + +type InsertModuleOverageTimersParams struct { + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + Count int32 `json:"count"` +} + +// InsertModuleOverageTimers inserts N identical install timers for one app, all +// anchored at the SAME installed_at / grace_expires_at (RegisterApp's K +// co-created modules share created_at; a SyncAppModules grow shares now()). +// generate_series(1, @count) with @count <= 0 yields no rows — a safe no-op. +func (q *Queries) InsertModuleOverageTimers(ctx context.Context, arg InsertModuleOverageTimersParams) error { + _, err := q.db.Exec(ctx, insertModuleOverageTimers, + arg.AccountID, + arg.AppID, + arg.InstalledAt, + arg.GraceExpiresAt, + arg.Count, + ) + return err +} + +const liveModuleTimerCountForApp = `-- name: LiveModuleTimerCountForApp :one + +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE app_id = $1 + AND removed_at IS NULL +` + +// Queries backing the per-module-instance overage timers (migration 033, +// DESIGN.md "Base fee — v2"). One row per module INSTALL EVENT; the charge +// layer (cycle/overage.go) synthesizes instances (the RPC carries only an +// integer module_count), determines included-vs-over LIVE by FIFO, and charges +// the over rows once their own grace elapses. +// LiveModuleTimerCountForApp counts an app's currently-live (removed_at IS NULL) +// install timers — the reconciliation input RegisterApp / SyncAppModules use to +// decide how many rows to insert or LIFO-remove so the live-timer set matches the +// app's module_count idempotently across fire-and-forget retries. ::bigint keeps +// it a non-nullable scalar. +func (q *Queries) LiveModuleTimerCountForApp(ctx context.Context, appID string) (int64, error) { + row := q.db.QueryRow(ctx, liveModuleTimerCountForApp, appID) + var live_count int64 + err := row.Scan(&live_count) + return live_count, err +} + +const liveModuleTimerRankBefore = `-- name: LiveModuleTimerRankBefore :one +SELECT COALESCE(count(*), 0)::bigint AS rank +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1::uuid + AND removed_at IS NULL + AND (installed_at < $2::timestamptz + OR (installed_at = $2::timestamptz AND id < $3::uuid)) +` + +type LiveModuleTimerRankBeforeParams struct { + AccountID string `json:"account_id"` + InstalledAt time.Time `json:"installed_at"` + TimerID string `json:"timer_id"` +} + +// LiveModuleTimerRankBefore returns how many of the account's currently-live +// install timers order STRICTLY BEFORE a given (installed_at, id) under the FIFO +// ordering (installed_at ASC, id ASC) — i.e. the target's 0-based FIFO rank. +// rank < IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". +// Computed fresh at every grace-check (never cached). Backed by +// app_module_overage_timers_live_fifo_idx. +func (q *Queries) LiveModuleTimerRankBefore(ctx context.Context, arg LiveModuleTimerRankBeforeParams) (int64, error) { + row := q.db.QueryRow(ctx, liveModuleTimerRankBefore, arg.AccountID, arg.InstalledAt, arg.TimerID) + var rank int64 + err := row.Scan(&rank) + return rank, err +} + +const markModuleTimerCharged = `-- name: MarkModuleTimerCharged :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true, + grace_charged_at = $1::timestamptz, + grace_invoice_id = $2, + grace_invoice_item_id = $3 +WHERE id = $4::uuid + AND grace_resolved = false +` + +type MarkModuleTimerChargedParams struct { + GraceChargedAt time.Time `json:"grace_charged_at"` + GraceInvoiceID pgtype.Text `json:"grace_invoice_id"` + GraceInvoiceItemID pgtype.Text `json:"grace_invoice_item_id"` + TimerID string `json:"timer_id"` +} + +// MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once +// Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved = true and +// the GENUINE Stripe invoice / invoice-item ids (never idempotency-key strings). +// WHERE grace_resolved = false keeps a crash-retry idempotent (the deterministic +// per-timer Stripe keys already dedupe the charge itself). +func (q *Queries) MarkModuleTimerCharged(ctx context.Context, arg MarkModuleTimerChargedParams) error { + _, err := q.db.Exec(ctx, markModuleTimerCharged, + arg.GraceChargedAt, + arg.GraceInvoiceID, + arg.GraceInvoiceItemID, + arg.TimerID, + ) + return err +} + +const markModuleTimerIncluded = `-- name: MarkModuleTimerIncluded :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true +WHERE id = $1 + AND grace_resolved = false +` + +// MarkModuleTimerIncluded stamps a TERMINAL "included" verdict (grace_resolved = +// true, no charge) on a timer the grace-check found within the included 5. +// WHERE grace_resolved = false is first-write-wins (a concurrent sweep that +// already resolved it affects 0 rows). Monotonicity makes this verdict permanent +// — the row is never re-checked. +func (q *Queries) MarkModuleTimerIncluded(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, markModuleTimerIncluded, id) + return err +} + +const moduleOverageTimersPastGrace = `-- name: ModuleOverageTimersPastGrace :many +SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, + a.activated_at +FROM ms_billing.app_module_overage_timers t +JOIN ms_billing.accounts a ON a.id = t.account_id +WHERE t.removed_at IS NULL + AND t.grace_resolved = false + AND t.grace_expires_at <= $1 + AND a.activated_at IS NOT NULL +ORDER BY t.installed_at, t.id +` + +type ModuleOverageTimersPastGraceRow struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + ActivatedAt pgtype.Timestamptz `json:"activated_at"` +} + +// ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install +// timers whose grace window has elapsed as of $1, on accounts that are chargeable +// (activated_at IS NOT NULL — the same activation gate as the spine + proration +// leg). Each row carries the account's activation anchor so the sweep can resolve +// the install's period window without a second read. Ordered (installed_at, id) +// so the oldest install charges first (matches the FIFO ordering). Backed by +// app_module_overage_timers_sweep_idx. +func (q *Queries) ModuleOverageTimersPastGrace(ctx context.Context, graceExpiresAt time.Time) ([]ModuleOverageTimersPastGraceRow, error) { + rows, err := q.db.Query(ctx, moduleOverageTimersPastGrace, graceExpiresAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ModuleOverageTimersPastGraceRow{} + for rows.Next() { + var i ModuleOverageTimersPastGraceRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AppID, + &i.InstalledAt, + &i.GraceExpiresAt, + &i.ActivatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softRemoveAllModuleTimersForApp = `-- name: SoftRemoveAllModuleTimersForApp :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $2 +WHERE app_id = $1 + AND removed_at IS NULL +` + +type SoftRemoveAllModuleTimersForAppParams struct { + AppID string `json:"app_id"` + RemovedAt pgtype.Timestamptz `json:"removed_at"` +} + +// SoftRemoveAllModuleTimersForApp soft-removes EVERY still-live install timer for +// an app — the app-deletion path. Idempotent: a re-fire affects the rows already +// removed 0 times (WHERE removed_at IS NULL). +func (q *Queries) SoftRemoveAllModuleTimersForApp(ctx context.Context, arg SoftRemoveAllModuleTimersForAppParams) error { + _, err := q.db.Exec(ctx, softRemoveAllModuleTimersForApp, arg.AppID, arg.RemovedAt) + return err +} + +const softRemoveNewestModuleTimers = `-- name: SoftRemoveNewestModuleTimers :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $1::timestamptz +WHERE id IN ( + SELECT id + FROM ms_billing.app_module_overage_timers + WHERE app_id = $2::uuid + AND removed_at IS NULL + ORDER BY installed_at DESC, id DESC + LIMIT $3::int +) +` + +type SoftRemoveNewestModuleTimersParams struct { + RemovedAt time.Time `json:"removed_at"` + AppID string `json:"app_id"` + LimitCount int32 `json:"limit_count"` +} + +// SoftRemoveNewestModuleTimers LIFO-soft-removes the N NEWEST currently-live +// install timers for one app (a SyncAppModules shrink removes what was added most +// recently). Ordered (installed_at DESC, id DESC) — the reverse of the FIFO +// ordering. Sets removed_at = @removed_at on exactly those rows. +func (q *Queries) SoftRemoveNewestModuleTimers(ctx context.Context, arg SoftRemoveNewestModuleTimersParams) error { + _, err := q.db.Exec(ctx, softRemoveNewestModuleTimers, arg.RemovedAt, arg.AppID, arg.LimitCount) + return err +} diff --git a/internal/account/db/overage.sql.go b/internal/account/db/overage.sql.go index a1f679a..6a4dcad 100644 --- a/internal/account/db/overage.sql.go +++ b/internal/account/db/overage.sql.go @@ -7,211 +7,8 @@ package db import ( "context" - "time" - - "github.com/jackc/pgx/v5/pgtype" ) -const accountsInOverageGrace = `-- name: AccountsInOverageGrace :many -SELECT id, overage_since, activated_at -FROM ms_billing.accounts -WHERE overage_since IS NOT NULL - AND overage_since <= $1 - AND activated_at IS NOT NULL -` - -type AccountsInOverageGraceRow struct { - ID string `json:"id"` - OverageSince pgtype.Timestamptz `json:"overage_since"` - ActivatedAt pgtype.Timestamptz `json:"activated_at"` -} - -// AccountsInOverageGrace returns every account whose grace timer has EXPIRED as -// of the cutoff (overage_since <= $1 = now − 3 days) and that can be charged -// (activated_at IS NOT NULL — same activation gate as the spine). The -// mid-period sweep iterates these, derives each account's current anchored -// period from activated_at, and charges the pooled overage once per period -// (guarded by account_overage_snapshots). overage_since is returned so the -// sweep prorates from grace-end (overage_since + 3d) to the period end. -func (q *Queries) AccountsInOverageGrace(ctx context.Context, overageSince pgtype.Timestamptz) ([]AccountsInOverageGraceRow, error) { - rows, err := q.db.Query(ctx, accountsInOverageGrace, overageSince) - if err != nil { - return nil, err - } - defer rows.Close() - items := []AccountsInOverageGraceRow{} - for rows.Next() { - var i AccountsInOverageGraceRow - if err := rows.Scan(&i.ID, &i.OverageSince, &i.ActivatedAt); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const clearAccountOverage = `-- name: ClearAccountOverage :execrows -UPDATE ms_billing.accounts -SET overage_since = NULL -WHERE id = $1 - AND overage_since IS NOT NULL -` - -// ClearAccountOverage disarms the timer when the pool drops back to ≤5: -// overage_since → NULL. WHERE overage_since IS NOT NULL keeps it idempotent (a -// recompute on an already-under account affects 0 rows). No refund is issued -// (D1e) — clearing only stops FUTURE accrual; overage already charged this -// period stays billed via its account_overage_snapshots row. -func (q *Queries) ClearAccountOverage(ctx context.Context, id string) (int64, error) { - result, err := q.db.Exec(ctx, clearAccountOverage, id) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const insertAccountOverageSnapshot = `-- name: InsertAccountOverageSnapshot :execrows -INSERT INTO ms_billing.account_overage_snapshots - (account_id, period_start, period_end, over_count, charged_micros, source, status, invoice_item_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (account_id, period_start) DO NOTHING -` - -type InsertAccountOverageSnapshotParams struct { - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - OverCount int32 `json:"over_count"` - ChargedMicros int64 `json:"charged_micros"` - Source string `json:"source"` - Status string `json:"status"` - InvoiceItemID pgtype.Text `json:"invoice_item_id"` -} - -// InsertAccountOverageSnapshot claims ONE (account, period)'s pooled overage -// charge for a leg — status='pending' is written BEFORE the leg calls Stripe -// (see migration 032's status column comment); the leg later calls -// MarkAccountOverageSnapshotCharged once Stripe actually succeeds. ON CONFLICT -// (account_id, period_start) DO NOTHING: an existing row — a prior grace claim, -// or a prior reclaimed boundary attempt's own row — wins, so a re-run never -// rewrites what was already claimed. :execrows so the caller can tell whether -// ITS insert won the race (rows=1) or lost to a concurrent claim (rows=0) and -// must re-read + defer to the winner instead of proceeding to charge Stripe. -func (q *Queries) InsertAccountOverageSnapshot(ctx context.Context, arg InsertAccountOverageSnapshotParams) (int64, error) { - result, err := q.db.Exec(ctx, insertAccountOverageSnapshot, - arg.AccountID, - arg.PeriodStart, - arg.PeriodEnd, - arg.OverCount, - arg.ChargedMicros, - arg.Source, - arg.Status, - arg.InvoiceItemID, - ) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const markAccountOverageSnapshotCharged = `-- name: MarkAccountOverageSnapshotCharged :exec -UPDATE ms_billing.account_overage_snapshots -SET status = 'charged', - invoice_item_id = $3 -WHERE account_id = $1 - AND period_start = $2 -` - -type MarkAccountOverageSnapshotChargedParams struct { - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - InvoiceItemID pgtype.Text `json:"invoice_item_id"` -} - -// MarkAccountOverageSnapshotCharged flips a claimed row to 'charged' once -// Stripe actually created the invoice item/invoice, recording the GENUINE -// Stripe invoice item id (finding #4 — never the idempotency-key string). -// Unconditional on status (not just WHERE status='pending'): a retry that -// re-enters this leg after already flipping the row to 'charged' (e.g. the -// Stripe call succeeded, the write here committed, but a LATER step in the -// caller failed) must still be able to re-affirm the row harmlessly — the -// caller only ever calls this with the Stripe values it just confirmed, so -// overwriting an already-'charged' row with the same (idempotent) values is -// safe by construction (deterministic per-(account,period) Idempotency-Keys -// guarantee Stripe returns the SAME object on every re-call). -func (q *Queries) MarkAccountOverageSnapshotCharged(ctx context.Context, arg MarkAccountOverageSnapshotChargedParams) error { - _, err := q.db.Exec(ctx, markAccountOverageSnapshotCharged, arg.AccountID, arg.PeriodStart, arg.InvoiceItemID) - return err -} - -const selectAccountOverageSnapshot = `-- name: SelectAccountOverageSnapshot :one -SELECT over_count, charged_micros, source, status -FROM ms_billing.account_overage_snapshots -WHERE account_id = $1 - AND period_start = $2 -` - -type SelectAccountOverageSnapshotParams struct { - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` -} - -type SelectAccountOverageSnapshotRow struct { - OverCount int32 `json:"over_count"` - ChargedMicros int64 `json:"charged_micros"` - Source string `json:"source"` - Status string `json:"status"` -} - -// SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg -// claimed/billed for ONE (account, period) — the double-charge guard for the -// charge side (a row means "this period's pooled overage is claimed — pending -// or charged — skip/resume it, never independently charge it") AND the -// authoritative display value for GetAccountBill's pooled-overage line. Exact -// period_start match (both the grace sweep and the boundary leg key on the -// anchored window start); no row → never claimed → the caller charges it -// (charge side) or falls back to the live pooled estimate (display side). -func (q *Queries) SelectAccountOverageSnapshot(ctx context.Context, arg SelectAccountOverageSnapshotParams) (SelectAccountOverageSnapshotRow, error) { - row := q.db.QueryRow(ctx, selectAccountOverageSnapshot, arg.AccountID, arg.PeriodStart) - var i SelectAccountOverageSnapshotRow - err := row.Scan( - &i.OverCount, - &i.ChargedMicros, - &i.Source, - &i.Status, - ) - return i, err -} - -const startAccountOverage = `-- name: StartAccountOverage :execrows -UPDATE ms_billing.accounts -SET overage_since = $2 -WHERE id = $1 - AND overage_since IS NULL -` - -type StartAccountOverageParams struct { - ID string `json:"id"` - OverageSince pgtype.Timestamptz `json:"overage_since"` -} - -// StartAccountOverage arms the account's grace timer: it stamps overage_since -// the FIRST time the pool crosses 5. WHERE overage_since IS NULL makes it -// first-crossing-wins (idempotent — a later recompute that finds the pool still -// over affects 0 rows and never moves the anchor), exactly like activated_at's -// first-bind-wins. :execrows so the caller can observe (and tolerate) the -// already-armed no-op. -func (q *Queries) StartAccountOverage(ctx context.Context, arg StartAccountOverageParams) (int64, error) { - result, err := q.db.Exec(ctx, startAccountOverage, arg.ID, arg.OverageSince) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - const sumLiveModuleCount = `-- name: SumLiveModuleCount :one SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count @@ -220,59 +17,21 @@ WHERE account_id = $1 AND deleted_at IS NULL ` -// Queries backing the account-wide POOLED module overage (migration 032, owner -// spec 2026-07-05). The pool is SUM(module_count) over the account's LIVE apps; -// overage = $3 × max(0, pool − 5), charged once per account per period through -// the account_overage_snapshots ledger (the double-charge guard). These queries -// serve BOTH the charge side (cycle package: the recompute + the mid-period -// grace sweep + the boundary leg) and the display side (usage package: -// GetAccountBill's pooled-overage line). Money never lives on the apps mirror; -// the pool is derived on read. -// SumLiveModuleCount returns the account-wide POOLED installed-module count: -// SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps. This is -// the single source of the pool both the overage timer recompute and the charge -// legs tier on: overage applies to max(0, this − IncludedModules). COALESCE to 0 -// so an account with no live apps sums to 0 (never over). ::bigint keeps the -// aggregate a non-nullable scalar. +// Queries backing the account-wide module-count SUM (migration 027/030). Under +// the per-module-instance overage model (migration 033) this is no longer a +// charge input — the charge legs tier on per-install timers (module_timers.sql). +// It survives only as the DISPLAY read: GetAccountBill's account-overage line +// shows the steady-state monthly estimate $3 × max(0, pooled − IncludedModules) +// (usage.AccountOverageMicros) from the CURRENT live pool. Money never lives on +// the apps mirror; the pool is derived on read. +// SumLiveModuleCount returns the account-wide installed-module count: +// SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps — +// the display's steady-state overage estimate input. COALESCE to 0 so an +// account with no live apps sums to 0. ::bigint keeps the aggregate a +// non-nullable scalar. func (q *Queries) SumLiveModuleCount(ctx context.Context, accountID string) (int64, error) { row := q.db.QueryRow(ctx, sumLiveModuleCount, accountID) var pooled_count int64 err := row.Scan(&pooled_count) return pooled_count, err } - -const topUpAccountOverageSnapshot = `-- name: TopUpAccountOverageSnapshot :exec -UPDATE ms_billing.account_overage_snapshots -SET over_count = $3, - charged_micros = $4, - invoice_item_id = $5 -WHERE account_id = $1 - AND period_start = $2 - AND status = 'charged' -` - -type TopUpAccountOverageSnapshotParams struct { - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - OverCount int32 `json:"over_count"` - ChargedMicros int64 `json:"charged_micros"` - InvoiceItemID pgtype.Text `json:"invoice_item_id"` -} - -// TopUpAccountOverageSnapshot records an INCREMENTAL charge against an already- -// 'charged' period whose pool grew further before the period closed (finding -// #3 — a judgment call, see cycle/overage.go's topUpGraceOverage doc). Only -// fires from a 'charged' row (a 'pending' row is a resume-in-progress, never a -// top-up target); over_count/charged_micros are overwritten with the NEW -// cumulative totals so the ledger + the display always show what was actually -// billed in total for the period. -func (q *Queries) TopUpAccountOverageSnapshot(ctx context.Context, arg TopUpAccountOverageSnapshotParams) error { - _, err := q.db.Exec(ctx, topUpAccountOverageSnapshot, - arg.AccountID, - arg.PeriodStart, - arg.OverCount, - arg.ChargedMicros, - arg.InvoiceItemID, - ) - return err -} diff --git a/internal/account/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql new file mode 100644 index 0000000..6de0fcd --- /dev/null +++ b/internal/account/db/queries/module_timers.sql @@ -0,0 +1,108 @@ +-- Queries backing the per-module-instance overage timers (migration 033, +-- DESIGN.md "Base fee — v2"). One row per module INSTALL EVENT; the charge +-- layer (cycle/overage.go) synthesizes instances (the RPC carries only an +-- integer module_count), determines included-vs-over LIVE by FIFO, and charges +-- the over rows once their own grace elapses. + +-- LiveModuleTimerCountForApp counts an app's currently-live (removed_at IS NULL) +-- install timers — the reconciliation input RegisterApp / SyncAppModules use to +-- decide how many rows to insert or LIFO-remove so the live-timer set matches the +-- app's module_count idempotently across fire-and-forget retries. ::bigint keeps +-- it a non-nullable scalar. +-- name: LiveModuleTimerCountForApp :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE app_id = $1 + AND removed_at IS NULL; + +-- InsertModuleOverageTimers inserts N identical install timers for one app, all +-- anchored at the SAME installed_at / grace_expires_at (RegisterApp's K +-- co-created modules share created_at; a SyncAppModules grow shares now()). +-- generate_series(1, @count) with @count <= 0 yields no rows — a safe no-op. +-- name: InsertModuleOverageTimers :exec +INSERT INTO ms_billing.app_module_overage_timers + (account_id, app_id, installed_at, grace_expires_at) +SELECT @account_id::uuid, @app_id::uuid, @installed_at::timestamptz, @grace_expires_at::timestamptz +FROM generate_series(1, @count::int); + +-- SoftRemoveNewestModuleTimers LIFO-soft-removes the N NEWEST currently-live +-- install timers for one app (a SyncAppModules shrink removes what was added most +-- recently). Ordered (installed_at DESC, id DESC) — the reverse of the FIFO +-- ordering. Sets removed_at = @removed_at on exactly those rows. +-- name: SoftRemoveNewestModuleTimers :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = @removed_at::timestamptz +WHERE id IN ( + SELECT id + FROM ms_billing.app_module_overage_timers + WHERE app_id = @app_id::uuid + AND removed_at IS NULL + ORDER BY installed_at DESC, id DESC + LIMIT @limit_count::int +); + +-- SoftRemoveAllModuleTimersForApp soft-removes EVERY still-live install timer for +-- an app — the app-deletion path. Idempotent: a re-fire affects the rows already +-- removed 0 times (WHERE removed_at IS NULL). +-- name: SoftRemoveAllModuleTimersForApp :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $2 +WHERE app_id = $1 + AND removed_at IS NULL; + +-- ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install +-- timers whose grace window has elapsed as of $1, on accounts that are chargeable +-- (activated_at IS NOT NULL — the same activation gate as the spine + proration +-- leg). Each row carries the account's activation anchor so the sweep can resolve +-- the install's period window without a second read. Ordered (installed_at, id) +-- so the oldest install charges first (matches the FIFO ordering). Backed by +-- app_module_overage_timers_sweep_idx. +-- name: ModuleOverageTimersPastGrace :many +SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, + a.activated_at +FROM ms_billing.app_module_overage_timers t +JOIN ms_billing.accounts a ON a.id = t.account_id +WHERE t.removed_at IS NULL + AND t.grace_resolved = false + AND t.grace_expires_at <= $1 + AND a.activated_at IS NOT NULL +ORDER BY t.installed_at, t.id; + +-- LiveModuleTimerRankBefore returns how many of the account's currently-live +-- install timers order STRICTLY BEFORE a given (installed_at, id) under the FIFO +-- ordering (installed_at ASC, id ASC) — i.e. the target's 0-based FIFO rank. +-- rank < IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". +-- Computed fresh at every grace-check (never cached). Backed by +-- app_module_overage_timers_live_fifo_idx. +-- name: LiveModuleTimerRankBefore :one +SELECT COALESCE(count(*), 0)::bigint AS rank +FROM ms_billing.app_module_overage_timers +WHERE account_id = @account_id::uuid + AND removed_at IS NULL + AND (installed_at < @installed_at::timestamptz + OR (installed_at = @installed_at::timestamptz AND id < @timer_id::uuid)); + +-- MarkModuleTimerIncluded stamps a TERMINAL "included" verdict (grace_resolved = +-- true, no charge) on a timer the grace-check found within the included 5. +-- WHERE grace_resolved = false is first-write-wins (a concurrent sweep that +-- already resolved it affects 0 rows). Monotonicity makes this verdict permanent +-- — the row is never re-checked. +-- name: MarkModuleTimerIncluded :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true +WHERE id = $1 + AND grace_resolved = false; + +-- MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once +-- Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved = true and +-- the GENUINE Stripe invoice / invoice-item ids (never idempotency-key strings). +-- WHERE grace_resolved = false keeps a crash-retry idempotent (the deterministic +-- per-timer Stripe keys already dedupe the charge itself). +-- name: MarkModuleTimerCharged :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true, + grace_charged_at = @grace_charged_at::timestamptz, + grace_invoice_id = @grace_invoice_id, + grace_invoice_item_id = @grace_invoice_item_id +WHERE id = @timer_id::uuid + AND grace_resolved = false; diff --git a/internal/account/db/queries/overage.sql b/internal/account/db/queries/overage.sql index 755999a..28ad37b 100644 --- a/internal/account/db/queries/overage.sql +++ b/internal/account/db/queries/overage.sql @@ -1,120 +1,18 @@ --- Queries backing the account-wide POOLED module overage (migration 032, owner --- spec 2026-07-05). The pool is SUM(module_count) over the account's LIVE apps; --- overage = $3 × max(0, pool − 5), charged once per account per period through --- the account_overage_snapshots ledger (the double-charge guard). These queries --- serve BOTH the charge side (cycle package: the recompute + the mid-period --- grace sweep + the boundary leg) and the display side (usage package: --- GetAccountBill's pooled-overage line). Money never lives on the apps mirror; --- the pool is derived on read. - --- SumLiveModuleCount returns the account-wide POOLED installed-module count: --- SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps. This is --- the single source of the pool both the overage timer recompute and the charge --- legs tier on: overage applies to max(0, this − IncludedModules). COALESCE to 0 --- so an account with no live apps sums to 0 (never over). ::bigint keeps the --- aggregate a non-nullable scalar. +-- Queries backing the account-wide module-count SUM (migration 027/030). Under +-- the per-module-instance overage model (migration 033) this is no longer a +-- charge input — the charge legs tier on per-install timers (module_timers.sql). +-- It survives only as the DISPLAY read: GetAccountBill's account-overage line +-- shows the steady-state monthly estimate $3 × max(0, pooled − IncludedModules) +-- (usage.AccountOverageMicros) from the CURRENT live pool. Money never lives on +-- the apps mirror; the pool is derived on read. + +-- SumLiveModuleCount returns the account-wide installed-module count: +-- SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps — +-- the display's steady-state overage estimate input. COALESCE to 0 so an +-- account with no live apps sums to 0. ::bigint keeps the aggregate a +-- non-nullable scalar. -- name: SumLiveModuleCount :one SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count FROM ms_billing.apps WHERE account_id = $1 AND deleted_at IS NULL; - --- StartAccountOverage arms the account's grace timer: it stamps overage_since --- the FIRST time the pool crosses 5. WHERE overage_since IS NULL makes it --- first-crossing-wins (idempotent — a later recompute that finds the pool still --- over affects 0 rows and never moves the anchor), exactly like activated_at's --- first-bind-wins. :execrows so the caller can observe (and tolerate) the --- already-armed no-op. --- name: StartAccountOverage :execrows -UPDATE ms_billing.accounts -SET overage_since = $2 -WHERE id = $1 - AND overage_since IS NULL; - --- ClearAccountOverage disarms the timer when the pool drops back to ≤5: --- overage_since → NULL. WHERE overage_since IS NOT NULL keeps it idempotent (a --- recompute on an already-under account affects 0 rows). No refund is issued --- (D1e) — clearing only stops FUTURE accrual; overage already charged this --- period stays billed via its account_overage_snapshots row. --- name: ClearAccountOverage :execrows -UPDATE ms_billing.accounts -SET overage_since = NULL -WHERE id = $1 - AND overage_since IS NOT NULL; - --- AccountsInOverageGrace returns every account whose grace timer has EXPIRED as --- of the cutoff (overage_since <= $1 = now − 3 days) and that can be charged --- (activated_at IS NOT NULL — same activation gate as the spine). The --- mid-period sweep iterates these, derives each account's current anchored --- period from activated_at, and charges the pooled overage once per period --- (guarded by account_overage_snapshots). overage_since is returned so the --- sweep prorates from grace-end (overage_since + 3d) to the period end. --- name: AccountsInOverageGrace :many -SELECT id, overage_since, activated_at -FROM ms_billing.accounts -WHERE overage_since IS NOT NULL - AND overage_since <= $1 - AND activated_at IS NOT NULL; - --- SelectAccountOverageSnapshot reads the frozen pooled overage a charge leg --- claimed/billed for ONE (account, period) — the double-charge guard for the --- charge side (a row means "this period's pooled overage is claimed — pending --- or charged — skip/resume it, never independently charge it") AND the --- authoritative display value for GetAccountBill's pooled-overage line. Exact --- period_start match (both the grace sweep and the boundary leg key on the --- anchored window start); no row → never claimed → the caller charges it --- (charge side) or falls back to the live pooled estimate (display side). --- name: SelectAccountOverageSnapshot :one -SELECT over_count, charged_micros, source, status -FROM ms_billing.account_overage_snapshots -WHERE account_id = $1 - AND period_start = $2; - --- InsertAccountOverageSnapshot claims ONE (account, period)'s pooled overage --- charge for a leg — status='pending' is written BEFORE the leg calls Stripe --- (see migration 032's status column comment); the leg later calls --- MarkAccountOverageSnapshotCharged once Stripe actually succeeds. ON CONFLICT --- (account_id, period_start) DO NOTHING: an existing row — a prior grace claim, --- or a prior reclaimed boundary attempt's own row — wins, so a re-run never --- rewrites what was already claimed. :execrows so the caller can tell whether --- ITS insert won the race (rows=1) or lost to a concurrent claim (rows=0) and --- must re-read + defer to the winner instead of proceeding to charge Stripe. --- name: InsertAccountOverageSnapshot :execrows -INSERT INTO ms_billing.account_overage_snapshots - (account_id, period_start, period_end, over_count, charged_micros, source, status, invoice_item_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (account_id, period_start) DO NOTHING; - --- MarkAccountOverageSnapshotCharged flips a claimed row to 'charged' once --- Stripe actually created the invoice item/invoice, recording the GENUINE --- Stripe invoice item id (finding #4 — never the idempotency-key string). --- Unconditional on status (not just WHERE status='pending'): a retry that --- re-enters this leg after already flipping the row to 'charged' (e.g. the --- Stripe call succeeded, the write here committed, but a LATER step in the --- caller failed) must still be able to re-affirm the row harmlessly — the --- caller only ever calls this with the Stripe values it just confirmed, so --- overwriting an already-'charged' row with the same (idempotent) values is --- safe by construction (deterministic per-(account,period) Idempotency-Keys --- guarantee Stripe returns the SAME object on every re-call). --- name: MarkAccountOverageSnapshotCharged :exec -UPDATE ms_billing.account_overage_snapshots -SET status = 'charged', - invoice_item_id = $3 -WHERE account_id = $1 - AND period_start = $2; - --- TopUpAccountOverageSnapshot records an INCREMENTAL charge against an already- --- 'charged' period whose pool grew further before the period closed (finding --- #3 — a judgment call, see cycle/overage.go's topUpGraceOverage doc). Only --- fires from a 'charged' row (a 'pending' row is a resume-in-progress, never a --- top-up target); over_count/charged_micros are overwritten with the NEW --- cumulative totals so the ledger + the display always show what was actually --- billed in total for the period. --- name: TopUpAccountOverageSnapshot :exec -UPDATE ms_billing.account_overage_snapshots -SET over_count = $3, - charged_micros = $4, - invoice_item_id = $5 -WHERE account_id = $1 - AND period_start = $2 - AND status = 'charged'; diff --git a/internal/account/usage/accountbill.go b/internal/account/usage/accountbill.go index f137396..e783b82 100644 --- a/internal/account/usage/accountbill.go +++ b/internal/account/usage/accountbill.go @@ -172,16 +172,14 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) infraTotal += parts.InfraTotalMicros } - // Account-wide POOLED module overage (migration 032), SNAPSHOT-FIRST exactly - // like the per-app base: a charged period reads the frozen - // account_overage_snapshots row (what the grace sweep or the boundary - // actually invoiced), so the displayed overage IS what was billed even after - // later SyncAppModules pool changes. An un-charged period (pre-032 history, - // an account under the pool, or an in-progress period no leg has billed yet) - // falls back to the LIVE estimate from the CURRENT pooled sum — the full - // monthly overage the account would owe at steady state (the display doesn't - // prorate the estimate; the charge legs own proration). It is an ACCOUNT - // line, never allocated back per app. + // Account overage line (migration 033): the steady-state monthly estimate + // from the CURRENT live pool — $3 × max(0, pooled − IncludedModules). Under + // the per-module-instance model overage is billed per install on its own + // grace timer (Leg 1); the display shows the live steady-state figure (the + // display doesn't prorate; the charge legs own proration) rather than a + // per-period frozen snapshot (a faithful per-period display from the timer + // rows is a Stage B follow-up). It is an ACCOUNT line, never allocated back + // per app. accountOverage, err := s.accountOverageMicros(ctx, accountID, periodStart) if err != nil { return nil, err @@ -210,19 +208,15 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) }, nil } -// accountOverageMicros resolves the account-wide pooled overage for the display, -// snapshot-first: the frozen account_overage_snapshots charge for the period if -// a leg billed it, else the live estimate AccountOverageMicros(current pooled -// sum). The PaaS credit never offsets it (overage rides ON TOP, like the base -// fee) so it is resolved outside the credit math. -func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (int64, error) { - charged, snapped, err := s.store.AccountOverageSnapshot(ctx, accountID, periodStart) - if err != nil { - return 0, billing.Internal("account overage snapshot lookup failed", err) - } - if snapped { - return charged, nil - } +// accountOverageMicros resolves the account overage shown on the bill: the +// steady-state monthly estimate AccountOverageMicros(current pooled sum) = $3 × +// max(0, pooled − IncludedModules). Under the per-module-instance overage model +// (migration 033) overage is billed per install on its own grace timer (Leg 1, +// cycle/overage.go); the display shows the live steady-state figure rather than a +// per-period frozen snapshot (a faithful per-period display from the timer rows +// is a Stage B follow-up). The PaaS credit never offsets it (overage rides ON +// TOP, like the base fee), so it is resolved outside the credit math. +func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID, _ time.Time) (int64, error) { pooled, err := s.store.PooledModuleCount(ctx, accountID) if err != nil { return 0, billing.Internal("pooled module count lookup failed", err) diff --git a/internal/account/usage/service_test.go b/internal/account/usage/service_test.go index efe7da7..709fd74 100644 --- a/internal/account/usage/service_test.go +++ b/internal/account/usage/service_test.go @@ -40,12 +40,10 @@ type fakeStore struct { appMirrors map[uuid.UUID]usage.AppMirrorInfo // app_id → ms_billing.apps roster row (migration 027) baseSnapshots map[string]usage.AppBaseSnapshotInfo // app_id/period_start → charged-base snapshot (migration 028) - // account-wide POOLED overage (migration 032): PooledModuleCount sums the - // appMirrors' module_count (like the SQL over ms_billing.apps); overageSnaps - // keys account_id/period_start → the frozen pooled-overage charged_micros. - overageSnaps map[string]int64 + // per-module overage display (migration 033): PooledModuleCount sums the + // appMirrors' module_count (like the SQL over ms_billing.apps) — the sole + // input to GetAccountBill's steady-state account-overage estimate. errPooledCount error - errOverageSnap error // usageAppIDs is what AppIDsWithUsage enumerates (the usage half of // GetAccountBill's roster); the mirror half is DERIVED from appMirrors with @@ -128,7 +126,6 @@ func newFakeStore() *fakeStore { visibility: map[uuid.UUID]usage.Visibility{}, appMirrors: map[uuid.UUID]usage.AppMirrorInfo{}, baseSnapshots: map[string]usage.AppBaseSnapshotInfo{}, - overageSnaps: map[string]int64{}, appBillRowsByApp: map[uuid.UUID][]usage.AppMetricUsageRaw{}, appInfraBillRowsByApp: map[uuid.UUID][]usage.AppInfraUsage{}, appModuleInfraBillRowsByApp: map[uuid.UUID][]usage.AppModuleInfraUsage{}, @@ -191,16 +188,11 @@ func (f *fakeStore) AppBaseSnapshot(_ context.Context, appID uuid.UUID, periodSt return s, ok, nil } -// overageSnapKey mirrors the account_overage_snapshots PRIMARY KEY -// (account_id, period_start). -func overageSnapKey(accountID uuid.UUID, periodStart time.Time) string { - return accountID.String() + "/" + periodStart.UTC().Format(time.RFC3339Nano) -} - -// PooledModuleCount returns the account's live pooled module count (migration -// 030): Σ module_count over the non-deleted appMirrors, mirroring the SQL over -// ms_billing.apps. The single-account usage fake holds one account's roster, so -// it sums every live mirror row. +// PooledModuleCount returns the account's live pooled module count: Σ +// module_count over the non-deleted appMirrors, mirroring the SQL over +// ms_billing.apps — the sole input to GetAccountBill's steady-state +// account-overage estimate (migration 033). The single-account usage fake holds +// one account's roster, so it sums every live mirror row. func (f *fakeStore) PooledModuleCount(_ context.Context, _ uuid.UUID) (int, error) { if f.errPooledCount != nil { return 0, f.errPooledCount @@ -214,17 +206,6 @@ func (f *fakeStore) PooledModuleCount(_ context.Context, _ uuid.UUID) (int, erro return sum, nil } -// AccountOverageSnapshot returns the fake migration-030 pooled-overage charge -// for one (account, period_start); absent → never charged, so GetAccountBill -// falls back to the live pooled estimate. -func (f *fakeStore) AccountOverageSnapshot(_ context.Context, accountID uuid.UUID, periodStart time.Time) (int64, bool, error) { - if f.errOverageSnap != nil { - return 0, false, f.errOverageSnap - } - m, ok := f.overageSnaps[overageSnapKey(accountID, periodStart)] - return m, ok, nil -} - // AccountAnchorDay returns the configured anchor day for an account, defaulting // to 1 (the UTC calendar month) so tests that don't set one keep the pre-anchor // window behavior. diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index e837f5f..4fe6904 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -209,20 +209,12 @@ type Store interface { // residual ledger rows still enumerate through AppIDsWithUsage). MirroredAppIDs(ctx context.Context, accountID uuid.UUID, periodStart, periodEnd time.Time) ([]uuid.UUID, error) - // PooledModuleCount returns the account-wide POOLED installed-module count: - // SUM(module_count) over the account's LIVE apps (migration 032). It is the - // live input to GetAccountBill's account-wide overage ESTIMATE when the - // current period has no frozen account_overage_snapshots row yet. + // PooledModuleCount returns the account-wide installed-module count: + // SUM(module_count) over the account's LIVE apps. Under the per-module- + // instance overage model (migration 033) it is the sole input to + // GetAccountBill's account-overage line, shown as the steady-state estimate + // $3 × max(0, pooled − IncludedModules) (usage.AccountOverageMicros). PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) - - // AccountOverageSnapshot reads the frozen pooled overage a charge leg billed - // for ONE (account, period) — the AUTHORITATIVE display value for - // GetAccountBill's account-wide overage line (what the grace sweep or the - // boundary actually invoiced). found=false → the period's pooled overage was - // never charged (pre-032 history, an account under the pool, or an - // in-progress period no leg has billed yet) and the caller falls back to the - // live pooled ESTIMATE. - AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (chargedMicros int64, found bool, err error) } // AppBaseSnapshotInfo is the display-read projection of a @@ -625,8 +617,11 @@ func (s *pgxStore) MirroredAppIDs(ctx context.Context, accountID uuid.UUID, peri return parseAppIDs(rows) } -// PooledModuleCount sums module_count over the account's live apps (migration -// 030) — the live input to GetAccountBill's account-wide overage estimate. +// PooledModuleCount sums module_count over the account's live apps — the live +// input to GetAccountBill's steady-state account-overage estimate ($3 × max(0, +// pooled − IncludedModules)). Under the per-module-instance overage model +// (migration 033) the display no longer reads a per-period frozen snapshot; the +// live pooled estimate IS the shown account-overage line. func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) { sum, err := s.q.SumLiveModuleCount(ctx, accountID.String()) if err != nil { @@ -635,23 +630,6 @@ func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) ( return int(sum), nil } -// AccountOverageSnapshot reads the frozen pooled overage charged for one -// (account, period) — pgx.ErrNoRows → found=false (the service falls back to -// the live pooled estimate). -func (s *pgxStore) AccountOverageSnapshot(ctx context.Context, accountID uuid.UUID, periodStart time.Time) (int64, bool, error) { - row, err := s.q.SelectAccountOverageSnapshot(ctx, db.SelectAccountOverageSnapshotParams{ - AccountID: accountID.String(), - PeriodStart: periodStart, - }) - if errors.Is(err, pgx.ErrNoRows) { - return 0, false, nil // never overage-charged → live estimate - } - if err != nil { - return 0, false, err - } - return row.ChargedMicros, true, nil -} - // parseAppIDs decodes a generated query's text app_id column into uuid.UUIDs, // shared by the two GetAccountBill enumeration reads. func parseAppIDs(rows []string) ([]uuid.UUID, error) { diff --git a/internal/account/usage/types.go b/internal/account/usage/types.go index e725121..f471e66 100644 --- a/internal/account/usage/types.go +++ b/internal/account/usage/types.go @@ -552,14 +552,12 @@ type GetAccountBillResponse struct { ModuleUsageTotalMicros int64 `json:"module_usage_total_micros"` InfraTotalMicros int64 `json:"infra_total_micros"` - // AccountOverageMicros is the account-wide POOLED module overage for the - // period (migration 032): $3 × max(0, Σ live-app module_count − - // IncludedModules), charged ONCE per account (NOT per app, NOT folded into - // any Apps[].base_fee_micros). It is snapshot-first like the per-app base: - // the current period's account_overage_snapshots row when a charge leg - // billed it (the grace sweep or the boundary), else a live estimate from the - // CURRENT pooled sum. Additive field (pre-032 consumers that ignore it read - // an unchanged response shape); included in TotalMicros below. + // AccountOverageMicros is the account's module overage for the period + // (migration 033): $3 × max(0, Σ live-app module_count − IncludedModules), + // an ACCOUNT line (NOT per app, NOT folded into any Apps[].base_fee_micros). + // Under the per-module-instance model overage is billed per install on its + // own grace timer (Leg 1); this display value is the steady-state estimate + // from the CURRENT live pool. Included in TotalMicros below. AccountOverageMicros int64 `json:"account_overage_micros"` // PaasCreditMicros is the ACCOUNT-level PaaS credit, applied ONCE here diff --git a/migrations/billing/033_app_module_overage_timers.down.sql b/migrations/billing/033_app_module_overage_timers.down.sql new file mode 100644 index 0000000..ce8020e --- /dev/null +++ b/migrations/billing/033_app_module_overage_timers.down.sql @@ -0,0 +1,27 @@ +-- Down for 033 — drop the per-module-instance overage timers and RESTORE +-- migration 032's account-wide pooled overage schema (overage_since column + +-- account_overage_snapshots ledger), so an up/down/up round-trip against 032 is +-- clean. The restored definitions are copied verbatim from 032's up. + +DROP TABLE IF EXISTS ms_billing.app_module_overage_timers; + +ALTER TABLE ms_billing.accounts + ADD COLUMN IF NOT EXISTS overage_since TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.accounts.overage_since IS + 'UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 ' + '(account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. ' + 'Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account.'; + +CREATE TABLE IF NOT EXISTS ms_billing.account_overage_snapshots ( + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + over_count INT NOT NULL CHECK (over_count >= 0), + charged_micros BIGINT NOT NULL CHECK (charged_micros >= 0), + source TEXT NOT NULL CHECK (source IN ('grace', 'advance')), + status TEXT NOT NULL CHECK (status IN ('pending', 'charged')), + invoice_item_id TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, period_start) +); diff --git a/migrations/billing/033_app_module_overage_timers.up.sql b/migrations/billing/033_app_module_overage_timers.up.sql new file mode 100644 index 0000000..33b6270 --- /dev/null +++ b/migrations/billing/033_app_module_overage_timers.up.sql @@ -0,0 +1,109 @@ +-- Migration 033 — per-module-instance overage timers (owner session amendment +-- 2026-07-05, DESIGN.md "Base fee — v2: creation grace + per-module overage +-- timers"). SUPERSEDES the account-wide single-timer pooled overage model +-- (migration 032): overage is no longer ONE grace timer per account tiering on +-- SUM(module_count); it is now ONE independently-anchored grace timer per module +-- INSTALL EVENT, each priced from its OWN install date. +-- +-- Migration 032's account-wide schema never shipped to main, so this fully +-- REPLACES it (not deprecate-in-place): the up drops accounts.overage_since and +-- ms_billing.account_overage_snapshots outright, and the down recreates them so +-- up/down/up round-trips cleanly against 032. +-- +-- The model (DESIGN.md v2): +-- * RegisterApp(count=K) inserts K rows anchored installed_at = created_at; +-- SyncAppModules N→M inserts M−N new rows (installed_at = now) when growing, +-- LIFO soft-removes N−M rows (newest installs first) when shrinking; app +-- deletion soft-removes all the app's still-live rows. +-- * "included vs over" is derived LIVE at every grace-check, never cached: across +-- the account's currently-live rows ordered (installed_at ASC, id ASC), the +-- first IncludedModules (5) are "included", the rest are "over". Monotonicity: +-- a new install always gets the latest installed_at, so an existing row's rank +-- can only improve (over → included, never included → over) — so once a +-- grace-check finds a row "included" that verdict is PERMANENT (grace_resolved). +-- * Leg 1 (the per-module grace charge): once removed_at IS NULL AND +-- grace_expires_at <= now AND grace_resolved = false, the sweep charges the +-- "over" rows $3 prorated from installed_at's UTC day to the current period end +-- (install-anchored), via a per-timer Stripe invoice, then stamps +-- grace_charged_at / grace_resolved and the REAL Stripe ids. +-- +-- Money never lives on this table — the overage rate is a code constant +-- (usage.ModuleOverageFeeMicros); the table carries only the per-install timer +-- lifecycle. Companion docs update pending in mirrorstack-docs/db/ms_billing/. + +-- Retire the superseded account-wide pooled overage schema (migration 032). +-- Drop the snapshot ledger first, then the grace-anchor column. +DROP TABLE IF EXISTS ms_billing.account_overage_snapshots; + +ALTER TABLE ms_billing.accounts + DROP COLUMN IF EXISTS overage_since; + +CREATE TABLE IF NOT EXISTS ms_billing.app_module_overage_timers ( + -- Surrogate PK: one row per module INSTALL EVENT (not per module identity — + -- the RPC layer carries only an integer module_count, so instances are + -- synthesized). The id is the stable charge identity: the per-timer Stripe + -- Idempotency-Keys (mod-overage-ii- / mod-overage-inv-) derive from it. + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- The billing account the install belongs to. Cascade: dropping the account + -- drops its timers. Denormalized from the app (rather than joined every + -- FIFO-check) so the live-FIFO ordering query is a single-table scan. + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + + -- The app the module was installed into. Cascade off the apps mirror row. + app_id UUID NOT NULL REFERENCES ms_billing.apps(app_id) ON DELETE CASCADE, + + -- The install instant — BOTH the FIFO ordering key (earliest installs are the + -- "included" 5) AND the proration anchor (Leg 1 prorates $3 from this UTC day + -- to the period end). For RegisterApp's co-created modules this is created_at; + -- for a later SyncAppModules install it is that sync's now(). + installed_at TIMESTAMPTZ NOT NULL, + + -- installed_at + the 3-day grace window. Stored (not derived on read) so the + -- sweep's work-list is a plain indexed range scan on this column. + grace_expires_at TIMESTAMPTZ NOT NULL, + + -- NULL while the install is LIVE; set to the removal instant on a + -- SyncAppModules uninstall (LIFO) or an app deletion. A removed row leaves the + -- live-FIFO ordering and the sweep work-list. No refund (D1e): removing a + -- module already charged this period never claws back money. + removed_at TIMESTAMPTZ NULL, + + -- The instant Leg 1 actually charged this install's overage. NULL until a + -- grace-check decides "over" and the Stripe charge succeeds (an "included" + -- resolution leaves it NULL — nothing was charged). + grace_charged_at TIMESTAMPTZ NULL, + + -- TRUE once a grace-check has run and reached a TERMINAL verdict for this + -- install: either "included" (permanent, monotonicity — never charge, never + -- re-check) or "over and charged". Drops the row from the sweep work-list. + grace_resolved BOOLEAN NOT NULL DEFAULT false, + + -- The genuine Stripe invoice / invoice-item ids of the Leg 1 overage charge + -- (NULL until charged, or for an "included" resolution). The item id is the + -- REAL Stripe object id, never the idempotency-key string. + grace_invoice_id TEXT NULL, + grace_invoice_item_id TEXT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Live-FIFO ordering: the "which installs are included vs over" query scans an +-- account's live rows ordered (installed_at, id). Partial on removed_at IS NULL so +-- the index holds only live installs. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_live_fifo_idx + ON ms_billing.app_module_overage_timers (account_id, installed_at, id) + WHERE removed_at IS NULL; + +-- Sweep work-list: rows whose grace has elapsed and are not yet resolved. Partial +-- on the exact predicates the sweep filters (removed_at IS NULL AND grace_resolved +-- = false) so it stays tiny — every resolved or removed row drops out. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_sweep_idx + ON ms_billing.app_module_overage_timers (grace_expires_at) + WHERE removed_at IS NULL AND grace_resolved = false; + +-- LIFO removal: SyncAppModules shrink removes the app's NEWEST live installs +-- first, ordered (installed_at DESC, id DESC). Partial on the app's live rows. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_app_live_idx + ON ms_billing.app_module_overage_timers (app_id, installed_at DESC, id DESC) + WHERE removed_at IS NULL; From 8ae079895fdafecad3ba59816b8fba9578291b99 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 09:55:25 +0800 Subject: [PATCH 09/30] =?UTF-8?q?feat:=20Stage=20B=20=E2=80=94=20boundary?= =?UTF-8?q?=20overage=20(Leg=202),=20combined=20creation=20invoice=20(scen?= =?UTF-8?q?ario=203),=20shared=20auto-collect=20helper=20+=20scenario=20su?= =?UTF-8?q?ite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the per-module overage timer model (DESIGN.md "Base fee — v2"): the mid-period grace charge (Leg 1) and the account creation grace shipped in Stage A; this adds the two remaining charge legs, unifies the disclosure flag, and switches the display to the timer table. No schema change — all new reads are window-function SELECTs over migration 033's app_module_overage_timers. Leg 2 — boundary precharge (charge.go / scenario 6): the boundary invoice now carries, alongside the closed period's usage arrears + the new period's flat advance base, a FULL $3-per-module precharge for every ONGOING over-module — a live install timer that is "over" per the live FIFO AND already charged at least once (grace_charged_at set). Counted per-row via CountOngoingOverModuleTimers (row_number() FIFO rank > IncludedModules AND grace_charged_at IS NOT NULL), pooled into the same one invoice, guarded by the same billing_run idempotency. A timer still inside its own grace (never charged) is excluded — it stays purely on Leg 1's timer and is never double-counted. New ChargeSummary.AdvanceOverageMicros. Scenario 3 — combined creation invoice (proration.go): when an app's creation grace charge fires, its co-created over-modules (installed_at == created_at, over per live FIFO, via CoCreatedOverModuleTimers) are billed as ADDITIONAL line items on the SAME CreateInvoice — one combined invoice, not two. Each is $3 prorated over the identical day-0→period-end window as the base, using the SAME per-timer idem keys Leg 1 would use (so a racing sweep can't double-charge), and marked charged in the SAME transaction that arms the app guard (ProrationCharge. TimerCharges, persisted by persistProrationCharge). cmd/billing-cycle now runs the proration sweep BEFORE the overage sweep so the combined invoice wins and Leg 1 skips the already-resolved co-created rows. Scenario 5 — one shared flagLargeAutoCollect(chargedMicros, acct) helper (charge.go) wraps collection.IsLargeAutoCollect and is called from ALL charge sites (creation/ combined, Leg 1 grace, boundary) — the disclosure flag is no longer reimplemented per leg. The combined/boundary flags reflect the FULL debit (base + every overage line). Display (accountbill.go): GetAccountBill's account-overage line reads the live install-timer count (CountLiveModuleTimersForAccount) instead of SUM(apps. module_count) — the overage model's source of truth. Removed the now-dead SumLiveModuleCount query + PooledModuleCount, and the dead ProratedOverageMicros (superseded by per-install ProratedBaseMicros) + its test. Tests: new end-to-end scenario suite (scenarios_test.go) — one test per scenario 1-6 with exact amounts/invoice-counts (scenario 3 asserts exactly ONE invoice with 3 items; scenario 6 asserts the in-grace over-module is excluded; scenario 5 asserts the flag at every site under low/high thresholds). New integration test covering the three window-function reads against real Postgres. Full build / vet (incl. -tags integration) / gofmt / unit + integration test suites green. Co-Authored-By: Claude Opus 4.8 --- cmd/account-api/infra_route_test.go | 5 +- cmd/billing-cycle/main.go | 28 +- cmd/infra-egress-sync/main_test.go | 5 +- internal/account/cycle/charge.go | 116 ++++--- .../cycle/migration033_integration_test.go | 64 ++++ internal/account/cycle/overage.go | 3 +- internal/account/cycle/overage_test.go | 69 +--- internal/account/cycle/proration.go | 100 ++++-- internal/account/cycle/scenarios_test.go | 310 ++++++++++++++++++ internal/account/cycle/service_test.go | 60 ++++ internal/account/cycle/store.go | 54 +++ internal/account/cycle/types.go | 19 +- internal/account/db/module_timers.sql.go | 115 +++++++ internal/account/db/overage.sql.go | 37 --- internal/account/db/queries/module_timers.sql | 61 ++++ internal/account/db/queries/overage.sql | 18 - internal/account/usage/accountbill.go | 41 +-- internal/account/usage/basefee.go | 62 ++-- internal/account/usage/basefee_test.go | 18 - internal/account/usage/service_test.go | 26 +- internal/account/usage/store.go | 31 +- 21 files changed, 932 insertions(+), 310 deletions(-) create mode 100644 internal/account/cycle/scenarios_test.go delete mode 100644 internal/account/db/overage.sql.go delete mode 100644 internal/account/db/queries/overage.sql diff --git a/cmd/account-api/infra_route_test.go b/cmd/account-api/infra_route_test.go index 40f705f..180d8d0 100644 --- a/cmd/account-api/infra_route_test.go +++ b/cmd/account-api/infra_route_test.go @@ -86,12 +86,9 @@ 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) { +func (stubUsageStore) LiveModuleTimerCountForAccount(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() diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index 5e9bdb5..7591d04 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -70,10 +70,17 @@ func main() { } // Local one-shot run: close every card-bound account's just-ended anchored - // period as of now AND sweep the creation-proration grace queue, then exit. - // No HTTP listener — the dev cycle is a single batch invocation. + // period as of now, sweep the creation-proration grace queue (scenario 3 — + // combined creation invoice), then sweep the per-module overage grace queue + // (Leg 1), then exit. No HTTP listener — the dev cycle is a single batch. at := time.Now().UTC() res := runCycle(context.Background(), svc, at) + // Proration BEFORE the overage sweep: the creation-proration charge folds an + // app's co-created over-modules onto ONE combined invoice (scenario 3), then + // the overage sweep (Leg 1) finds them already resolved and skips them — so a + // co-created over-module is billed on the combined invoice, not a second one. + sweepFailed := runProrationSweep(context.Background(), svc, at) + runOverageSweep(context.Background(), svc, at, &res) slog.Info("billing-cycle local run complete", "as_of", res.AsOf, "activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged, @@ -81,7 +88,6 @@ func main() { "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) - sweepFailed := runProrationSweep(context.Background(), svc, at) if res.Failed > 0 || sweepFailed { os.Exit(1) } @@ -125,6 +131,11 @@ func handler(svc *cycle.Service) func(context.Context, events.CloudWatchEvent) e at = time.Now().UTC() } res := runCycle(ctx, svc, at.UTC()) + // Proration BEFORE the overage sweep (see main()): the combined creation + // invoice (scenario 3) resolves an app's co-created over-modules first, so + // the overage sweep (Leg 1) skips them — one combined invoice, not two. + runProrationSweep(ctx, svc, at.UTC()) + runOverageSweep(ctx, svc, at.UTC(), &res) slog.InfoContext(ctx, "billing-cycle lambda run complete", "as_of", res.AsOf, "activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged, @@ -132,8 +143,6 @@ func handler(svc *cycle.Service) func(context.Context, events.CloudWatchEvent) e "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) - // Sweep the creation-proration grace queue as of the same instant. - runProrationSweep(ctx, svc, at.UTC()) // A per-account charge failure (or a per-app proration failure) is recorded // (billing_runs status='failed') // and does NOT fail the batch — the next cycle retries it. The handler @@ -255,15 +264,6 @@ func runCycle(ctx context.Context, svc *cycle.Service, at time.Time) cycleResult } tally(&res, a.ID, chargeSummary) } - - // Mid-period PER-MODULE overage grace sweep (migration 033, Leg 1): - // independent of the boundary close above. Every per-module install timer - // whose OWN 3-day grace has elapsed is evaluated LIVE by FIFO — the "over" - // installs are charged $3 prorated from their install date (a deliberate - // mid-period charge), the "included" ones resolved permanently. Idempotent per - // timer via the grace_resolved guard + the deterministic per-timer Stripe idem - // keys, so firing daily never double-charges. - runOverageSweep(ctx, svc, at, &res) return res } diff --git a/cmd/infra-egress-sync/main_test.go b/cmd/infra-egress-sync/main_test.go index cb5e1a9..5e4ffc0 100644 --- a/cmd/infra-egress-sync/main_test.go +++ b/cmd/infra-egress-sync/main_test.go @@ -128,12 +128,9 @@ 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) { +func (f *fakeStore) LiveModuleTimerCountForAccount(_ 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) } diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 53494c5..b83b693 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -31,10 +31,7 @@ import ( // LIVE ms_billing.apps rows (deleted_at IS NULL — a deleted app stops // accruing base, D1e, though its usage arrears above still bill) that // EXISTED BEFORE the new period opened (created_at < the closed window's -// period_end) of the FLAT BaseFeeMicros. Module overage is NO longer billed -// here — it rides per-module-instance grace timers (migration 033, Leg 1 in -// cycle/overage.go); the boundary per-module overage precharge for ongoing -// modules (scenario 6) is a Stage B follow-up. An app created INSIDE the new +// period_end) of the FLAT BaseFeeMicros. An app created INSIDE the new // period is excluded — RegisterApp's creation-proration leg already charged // its new-period base (full or prorated); it joins the advance leg at the // NEXT boundary. module_count is snapshotted AT CHARGE TIME, and each billed @@ -44,7 +41,13 @@ import ( // account with NO mirror rows (pre-backfill) gets base 0 — exactly the // pre-027 arrears-only invoice — until the api-platform backfill populates // the roster. -// 4. arrears + base == 0 (both zero) → MarkBillingRun('invoiced') with NO +// 3b. ADVANCE overage leg (scenario 6, Leg 2): the NEW period's FULL $3-per- +// module precharge for every ONGOING over-module — a live install timer that +// is "over" per the live FIFO AND already charged at least once (survived its +// grace in an earlier period, continuing into the new one). On the SAME +// invoice, guarded by the SAME billing_run idempotency. A timer still inside +// its own grace stays purely on Leg 1's timer and is never double-counted here. +// 4. arrears + base + overage == 0 → MarkBillingRun('invoiced') with NO // Stripe call. We NEVER auto-create a Stripe Customer with nothing to // charge (design §4 Axis 4). // 5. no usable default PM → MarkBillingRun('skipped_no_pm'). The usage is @@ -142,22 +145,43 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("live app roster read failed", err) } - // Each live app contributes ONLY its FLAT base. Module overage is NO longer - // billed here — it rides per-module-instance grace timers (migration 033, - // Leg 1 in cycle/overage.go); the boundary per-module overage precharge for - // ongoing modules (scenario 6) is a Stage B follow-up. + // Each live app contributes ONLY its FLAT base. Module overage is billed + // SEPARATELY below (the advance-overage / Leg 2 precharge), not folded into an + // app's base — it rides per-module-instance grace timers (migration 033). var advanceBase int64 for range apps { advanceBase += usage.BaseFeeMicros } - summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase} + // ADVANCE OVERAGE leg (scenario 6, Leg 2): the NEW period's $3-per-module + // precharge for every ONGOING over-module — a live install timer that is both + // "over" per the live FIFO AND already charged at least once (grace_charged_at + // set), i.e. a module that survived its own grace in an earlier period and + // continues into the new one. It is billed FULL (not prorated — the module + // exists for the whole new period), on the SAME boundary invoice as arrears + + // base, guarded by the SAME billing_run idempotency (keyed per-run, decided + // per-module-row now). A timer still inside its OWN grace (grace_charged_at + // NULL) is EXCLUDED here — it stays purely on Leg 1's independent timer and is + // never double-counted at the boundary. Empty/pre-backfill → 0. + overCount, err := s.store.CountOngoingOverModuleTimers(ctx, accountID, usage.IncludedModules) + if err != nil { + return nil, billing.Internal("ongoing over-module timer count failed", err) + } + advanceOverage := usage.ModuleOverageFeeMicros * int64(overCount) + + // The whole boundary invoice: closed period's netted usage arrears + the new + // period's advance base + the new period's advance overage. The allowance nets + // USAGE only (never base or overage — both ride ON TOP, matching bill.go). + boundaryTotal := arrears + advanceBase + advanceOverage - // Zero-skip: only when arrears AND base are both zero (empty/zero period with - // no live apps) is there nothing to invoice — mark invoiced with NO Stripe - // call, never auto-create a Customer with nothing to charge. A zero total can - // never breach a limit/ceiling, so this short-circuits ahead of the risk gate. - if arrears == 0 && advanceBase == 0 { + summary := &ChargeSummary{FirstRun: true, ArrearsMicros: arrears, AdvanceBaseMicros: advanceBase, AdvanceOverageMicros: advanceOverage} + + // Zero-skip: only when arrears, base AND overage are all zero (empty/zero + // period with no live apps or ongoing over-modules) is there nothing to + // invoice — mark invoiced with NO Stripe call, never auto-create a Customer + // with nothing to charge. A zero total can never breach a limit/ceiling, so + // this short-circuits ahead of the risk gate. + if boundaryTotal == 0 { if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero arrears) failed", err) } @@ -169,11 +193,11 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // must NEVER auto-charge accrued arrears above the customer-set per-cycle // ceiling. A breach skips the charge (usage RETAINED) rather than charging a // shocking amount — checked against the NETTED USAGE arrears only, so the - // allowance is credited first and the predictable, customer-visible base fee - // never trips a cap that exists to guard against USAGE surprises. (When a - // breach skips, the whole invoice — base included — waits for the re-attempt, - // keeping one-invoice-per-boundary.) Independent of mode/credit-limit (a hard - // cap, not a trust judgment). + // allowance is credited first and the predictable, customer-visible base fee + + // overage never trip a cap that exists to guard against USAGE surprises. (When + // a breach skips, the whole invoice — base + overage included — waits for the + // re-attempt, keeping one-invoice-per-boundary.) Independent of mode/credit- + // limit (a hard cap, not a trust judgment). if collection.ExceedsSpendCeiling(toCollectionAccount(acct), arrears) { // skipped_ceiling, NOT skipped_prepaid: the ceiling is a per-cycle cap, not // a mode transition — the account stays in arrears mode and the next cycle @@ -271,15 +295,15 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } // One invoice: closed period's netted usage arrears + the new period's advance - // base, converted micros → whole cents ONCE at the Stripe boundary (a single - // deterministic rounding point for the total). - cents, err := centsFromMicros(arrears + advanceBase) + // base + the new period's advance overage, converted micros → whole cents ONCE + // at the Stripe boundary (a single deterministic rounding point for the total). + cents, err := centsFromMicros(boundaryTotal) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } if cents == 0 { - // A sub-half-cent arrears total (an advance base, when present, is always ≥ - // $20 and can never round to 0) — never call Stripe for $0. + // A sub-half-cent arrears total (an advance base/overage, when present, is + // always ≥ $3 and can never round to 0) — never call Stripe for $0. if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero cents) failed", err) } @@ -290,7 +314,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // Charge. A failure after the PM gate marks the run 'failed' (auditable) and // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0) + inv, err := s.charge(ctx, runID, custID, cents, advanceBase+advanceOverage > 0) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -301,12 +325,13 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.StripeError("charge failed", err) } - // Post-hoc large-charge disclosure (migration 034): the charge SUCCEEDED - // above; flag it as "large" iff the amount just charged (netted usage - // arrears + advance base, in micros, the SAME value converted to cents - // above) exceeds the account's threshold resolved AT CHARGE TIME (its - // per-account override, or the platform default when NULL). Pure disclosure — - // it changes NO charging behaviour, only surfaces the already-successful debit. + // Post-hoc large-charge disclosure (migration 034, scenario 5): the charge + // SUCCEEDED above; flag it as "large" iff the amount just charged (netted + // usage arrears + advance base + advance overage, in micros — the SAME + // boundaryTotal converted to cents above) exceeds the account's threshold + // resolved AT CHARGE TIME (its per-account override, or the platform default + // when NULL) via the shared flagLargeAutoCollect helper. Pure disclosure — it + // changes NO charging behaviour, only surfaces the already-successful debit. // // The threshold is RE-RESOLVED HERE — immediately after the Stripe call // succeeded — rather than reusing `acct` loaded at the top of this @@ -332,7 +357,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri Currency: chargeCurrency, PeriodStart: periodStart, PeriodEnd: periodEnd, - IsLargeAutoCollect: collection.IsLargeAutoCollect(arrears+advanceBase, postChargeAcct.AutoCollectThresholdMicros), + IsLargeAutoCollect: flagLargeAutoCollect(boundaryTotal, postChargeAcct), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } @@ -366,11 +391,12 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } // charge creates the Stripe invoice item + draft invoice for the boundary total -// (usage arrears + advance base), with the two deterministic Idempotency-Keys -// (ii-, inv-) so a re-run reuses the same Stripe objects. withBase only -// widens the line DESCRIPTION when the total includes an advance base fee — a -// pure-usage invoice keeps the historical line text. Returns the created invoice -// projection (id/status/amounts) for the mirror upsert. +// (usage arrears + advance base + advance overage), with the two deterministic +// Idempotency-Keys (ii-, inv-) so a re-run reuses the same Stripe +// objects. withBase only widens the line DESCRIPTION when the total includes an +// advance base fee and/or ongoing-module overage — a pure-usage invoice keeps the +// historical line text. Returns the created invoice projection (id/status/amounts) +// for the mirror upsert. func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { desc := fmt.Sprintf("MirrorStack usage — run %s", runID) if withBase { @@ -466,6 +492,20 @@ func (s *Service) LatestClosedPeriodEnd(ctx context.Context, accountID uuid.UUID func invoiceItemIdemKey(runID uuid.UUID) string { return "ii-" + runID.String() } func invoiceIdemKey(runID uuid.UUID) string { return "inv-" + runID.String() } +// flagLargeAutoCollect is the ONE large-charge disclosure resolver (scenario 5, +// migration 034), called from EVERY off-session charge site — the boundary leg +// (charge.go), the creation/combined leg (proration.go), and the per-module grace +// leg (overage.go / Leg 1) — so the "large auto-collect" flag on a mirrored +// invoice row is computed identically everywhere and never reimplemented per leg. +// chargedMicros is the RAW pre-cents-conversion amount that just successfully +// charged; acct MUST be the account state read immediately AFTER the Stripe call +// succeeded (its per-account threshold override, or the platform default when +// nil), so a threshold edit landing concurrently with the charge is honored the +// same way at every site. +func flagLargeAutoCollect(chargedMicros int64, acct AccountCollection) bool { + return collection.IsLargeAutoCollect(chargedMicros, acct.AutoCollectThresholdMicros) +} + // toCollectionAccount maps the cycle store's AccountCollection to the pure-policy // collection.Account the risk-judge reasons over. Kept here so the collection // package stays free of any persistence type. diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 61a8a56..5244210 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -94,3 +94,67 @@ func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { require.NoError(t, err) require.Zero(t, n) } + +// Stage B: the row_number()-windowed reads backing scenario 3 (CoCreatedOverModuleTimers), +// scenario 6 / Leg 2 (CountOngoingOverModuleTimers), and the display +// (CountLiveModuleTimersForAccount) — validated against real Postgres, since the +// unit fakes only approximate the FIFO window function in Go. +func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + _, err := pool.Exec(ctx, `UPDATE ms_billing.accounts SET activated_at = $2 WHERE id = $1`, + acct.String(), mustTime(t, "2026-05-04T00:00:00Z")) + require.NoError(t, err) + + 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)) + + // appA: 7 co-created install timers at created_at → FIFO ranks 0-6, so 2 are + // "over" (rank ≥ IncludedModules=5). + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appA, created, created.AddDate(0, 0, 3), 7)) + + // CountLiveModuleTimersForAccount (via the usage store, the display's real + // consumer) = the account-overage over-count input (7 live). + usageStore := usage.NewStore(pool) + liveN, err := usageStore.LiveModuleTimerCountForAccount(ctx, acct) + require.NoError(t, err) + require.Equal(t, 7, liveN) + + // CoCreatedOverModuleTimers: the 2 co-created over-modules (rank ≥ 5), unresolved. + over, err := store.CoCreatedOverModuleTimers(ctx, acct, appA, created, usage.IncludedModules) + require.NoError(t, err) + require.Len(t, over, 2, "7 co-created → 2 over the included 5") + + // Before any charge, none are "ongoing" (grace_charged_at all NULL). + ongoing, err := store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + require.NoError(t, err) + require.Zero(t, ongoing, "nothing charged yet → no ongoing over-module") + + // Charge the 2 co-created over-modules (scenario 3) → they become "ongoing". + for _, id := range over { + require.NoError(t, store.MarkModuleTimerCharged(ctx, id, created.AddDate(0, 0, 3), "in_x", "ii_x")) + } + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + require.NoError(t, err) + require.Equal(t, 2, ongoing, "the 2 charged over-modules are ongoing") + // And they're no longer co-created candidates (grace_resolved now true). + over, err = store.CoCreatedOverModuleTimers(ctx, acct, appA, created, usage.IncludedModules) + require.NoError(t, err) + require.Empty(t, over) + + // appB adds one LATER over-module (rank 7) still in its own grace (uncharged): + // live count rises to 8, but the ongoing count stays 2 (uncharged excluded). + late := mustTime(t, "2026-06-28T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, late, late.AddDate(0, 0, 3), 1)) + liveN, err = usageStore.LiveModuleTimerCountForAccount(ctx, acct) + require.NoError(t, err) + require.Equal(t, 8, liveN) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + require.NoError(t, err) + require.Equal(t, 2, ongoing, "the in-grace (uncharged) over-module is NOT ongoing") +} diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index d761254..65e6662 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -40,7 +40,6 @@ import ( "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" - "github.com/mirrorstack-ai/billing-engine/internal/account/collection" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) @@ -199,7 +198,7 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // charged amount agree by construction. PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), PeriodEnd: periodEnd, - IsLargeAutoCollect: collection.IsLargeAutoCollect(proratedMicros, acct.AutoCollectThresholdMicros), + IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index a720aec..9773423 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -54,70 +54,11 @@ func seedIncluded(store *fakeStore, accountID, appID uuid.UUID, installedAt time } } -// --- Scenario 4: two modules a day apart → two independent prorated charges --- - -func TestModuleOverage_Scenario4_TwoModulesTwoDaysTwoAmounts(t *testing.T) { - // registeredAccount activates on the 4th → anchor day 4 → the period - // CONTAINING a mid-June install is [June 4, July 4) = 30 days. The account - // already has 5 included modules, so two NEW modules installed a day apart are - // both "over" and each gets its OWN independently-anchored grace charge, - // prorated from ITS OWN install date to the period end: - // * module A installed June 10 → grace ends June 13 → charge $3 × 24/30 = - // $2.40 (240¢); remain_days = whole UTC days in [June 10, July 4) = 24. - // * module B installed June 11 → grace ends June 14 → charge $3 × 23/30 = - // $2.30 (230¢); remain_days = [June 11, July 4) = 23. - store := newFakeStore() - _, acct := registeredAccount(store) - sc := newFakeStripe() - svc := cycle.NewService(store, sc) - ctx := context.Background() - - // 5 pre-existing included modules (installed back on the anchor day), so the - // two newcomers land in the "over" bucket. - seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) - - appAB := uuid.New() - timerA := seedTimer(store, acct, appAB, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) - timerB := seedTimer(store, acct, appAB, time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC)) - - // Sweep on June 13 (A's grace elapsed, B's has not) → charges A only. - resA, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 13, 9, 0, 0, 0, time.UTC)) - require.NoError(t, err) - require.Equal(t, 1, resA.Pending, "only A is past grace on June 13") - require.Equal(t, 1, resA.Charged) - require.Equal(t, 0, resA.Failed) - require.Len(t, sc.itemCalls, 1) - require.EqualValues(t, 240, sc.itemCalls[0].amountCfg, "A: $3 × 24/30 = $2.40") - require.Equal(t, "mod-overage-ii-"+timerA.String(), sc.itemCalls[0].idemKey) - require.Equal(t, "mod-overage-inv-"+timerA.String(), sc.invoiceCalls[0].idemKey) - - ta := store.timers[timerA] - require.True(t, ta.graceResolved) - require.True(t, ta.graceCharged) - require.Equal(t, time.Date(2026, 6, 13, 9, 0, 0, 0, time.UTC), ta.graceChargedAt) - // The stored item id is the GENUINE Stripe object id, NOT the idempotency-key - // string (a prior-PR bug this guards against). - require.Contains(t, ta.graceInvoiceItemID, "ii_test_") - require.NotEqual(t, "mod-overage-ii-"+timerA.String(), ta.graceInvoiceItemID) - require.Contains(t, ta.graceInvoiceID, "in_test_") - - // B untouched so far. - require.False(t, store.timers[timerB].graceResolved) - - // Sweep on June 14 (B's grace now elapsed) → charges B, a DIFFERENT amount on - // a DIFFERENT day; A is already resolved and never re-charged. - resB, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) - require.NoError(t, err) - require.Equal(t, 1, resB.Pending, "only B remains past grace on June 14 (A resolved)") - require.Equal(t, 1, resB.Charged) - require.Len(t, sc.itemCalls, 2, "A must not be charged a second time") - require.EqualValues(t, 230, sc.itemCalls[1].amountCfg, "B: $3 × 23/30 = $2.30") - require.Equal(t, "mod-overage-ii-"+timerB.String(), sc.itemCalls[1].idemKey) - - tb := store.timers[timerB] - require.True(t, tb.graceCharged) - require.Contains(t, tb.graceInvoiceItemID, "ii_test_") -} +// Scenario 4 (pool crosses 5 later, one module at a time → two independent +// prorated charges on different days) lives in the end-to-end scenario suite +// (scenarios_test.go, TestScenario4_PoolCrossesFiveLaterPerModuleTimers). This +// file keeps the Leg-1 PROPERTY tests (FIFO monotonicity, over→included flips, +// removed-in-grace, no-PM retry, unactivated) the scenario suite doesn't repeat. // --- FIFO monotonicity: an included module is a PERMANENT verdict ------------- diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 0df91dc..d12bfbc 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -43,7 +43,6 @@ import ( "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" - "github.com/mirrorstack-ai/billing-engine/internal/account/collection" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) @@ -119,11 +118,28 @@ const ( // snapshot to freeze (migration 028, source='proration'), and the invoice id // that arms the one-shot guard. Cents is echoed to the caller. A nil return from // the callback means "nothing to charge" (0 cents) — the store persists nothing. +// +// TimerCharges (scenario 3) are the co-created over-module install timers billed +// as ADDITIONAL line items on this SAME invoice. persistProrationCharge stamps +// each grace_resolved/grace_charged in the SAME transaction that arms the app +// guard, so the combined charge is all-or-nothing: a co-created over-module and +// the app base fee are billed and marked together, never one without the other. type ProrationCharge struct { - InvoiceID string - Cents int64 - Invoice InvoiceMirror - Snapshot AppBaseSnapshot + InvoiceID string + Cents int64 + Invoice InvoiceMirror + Snapshot AppBaseSnapshot + TimerCharges []ModuleTimerCharge +} + +// ModuleTimerCharge is one co-created over-module install timer's terminal +// "over and charged" mark (scenario 3): the timer id + the REAL Stripe +// invoice/invoice-item ids of the line it rode on the combined creation invoice. +type ModuleTimerCharge struct { + TimerID uuid.UUID + ChargedAt time.Time + InvoiceID string + InvoiceItemID string } // ChargeCreationProration charges (once) the creation-period base proration for @@ -253,13 +269,11 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) // anchor from the activation day). Derived from created_at, NEVER from now, // so the amount is deterministic regardless of when the sweep fires. periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(locked.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) - // The creation proration is the FLAT per-app base only (migration 032 — - // module overage is no longer a per-app tier; it is billed per module - // instance via its own grace timer, and co-created over-modules are added - // as a SEPARATE line on this same invoice by the module-overage grace - // leg). created_module_count is still frozen at RegisterApp time and - // recorded on the snapshot for display, but it no longer moves the base - // amount — a create with 0 or 50 modules prorates the identical flat base. + // The creation proration is the FLAT per-app base (migration 032 — module + // overage is no longer a per-app tier; it is billed per module instance via + // its own grace timer). created_module_count is still frozen at RegisterApp + // time and recorded on the snapshot for display, but it no longer moves the + // base amount — a create with 0 or 50 modules prorates the identical flat base. prorated := usage.ProratedBaseMicros(usage.BaseFeeMicros, locked.CreatedAt, periodStart, periodEnd) c, err := centsFromMicros(prorated) if err != nil { @@ -273,20 +287,60 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if _, err := s.stripe.CreateInvoiceItem(ctx, custID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { return nil, billing.StripeError("proration invoice item failed", err) } + + // Scenario 3 — the combined creation invoice. Modules co-created with the + // app (install date == created_at) that are "over" per the live FIFO have + // their OWN grace elapse at this SAME instant (same GraceDays anchor), so + // bill them as ADDITIONAL line items on this SAME invoice rather than minting + // a second one. Each is $3 prorated over the IDENTICAL day-0 → period-end + // window as the base (same created_at, so every co-created module is the + // same amount). They use the SAME per-timer idem keys (mod-overage-ii-) + // Leg 1 would use, so if Leg 1 races and charges one first, this + // CreateInvoiceItem returns the already-consumed item and the money lands + // exactly once; the grace_resolved guard (persistProrationCharge) records the + // winner. Priced/marked BEFORE the invoice is created so all lines pool onto it. + overTimers, err := s.store.CoCreatedOverModuleTimers(ctx, locked.AccountID, locked.AppID, locked.CreatedAt, usage.IncludedModules) + if err != nil { + return nil, billing.Internal("co-created over-module timers lookup failed", err) + } + overageMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, locked.CreatedAt, periodStart, periodEnd) + overageCents, err := centsFromMicros(overageMicros) + if err != nil { + return nil, billing.Internal("overage micros to cents conversion failed", err) + } + var timerCharges []ModuleTimerCharge + var overageTotalMicros int64 + if overageCents > 0 { + overDesc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", locked.AppID) + for _, timerID := range overTimers { + item, err := s.stripe.CreateInvoiceItem(ctx, custID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) + if err != nil { + return nil, billing.StripeError("combined module overage invoice item failed", err) + } + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, + ChargedAt: s.nowFn().UTC(), + InvoiceItemID: item.ID, + }) + overageTotalMicros += overageMicros + } + } + inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, appProrationInvoiceIdemKey(locked.AppID)) if err != nil { return nil, billing.StripeError("proration invoice failed", err) } + // Stamp the (single) combined-invoice id onto each co-created overage mark. + for i := range timerCharges { + timerCharges[i].InvoiceID = inv.ID + } // Resolve the account's large-charge disclosure threshold AT CHARGE TIME, - // immediately AFTER the Stripe calls above succeeded — the SAME point - // relative to the actual charge that RunBillingCycle's boundary leg - // resolves its own threshold (charge.go). Both charge legs agreeing on - // this point means a threshold edit landing concurrently with a charge is - // honored identically by both. A prorated app base fee rarely crosses the - // $100 default, but the flag is computed uniformly at every off-session - // charge call site (migration 034) so no successful large debit escapes - // disclosure. + // immediately AFTER the Stripe calls above succeeded — the SAME point (via + // the shared flagLargeAutoCollect helper, scenario 5) every off-session + // charge site uses, so a threshold edit landing concurrently with a charge is + // honored identically everywhere. The flag reflects the FULL combined debit + // (base + every co-created overage line). acct, err := s.store.AccountCollection(ctx, locked.AccountID) if err != nil { return nil, billing.Internal("account collection lookup failed", err) @@ -308,10 +362,11 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) Currency: chargeCurrency, PeriodStart: partialStart, PeriodEnd: periodEnd, - IsLargeAutoCollect: collection.IsLargeAutoCollect(prorated, acct.AutoCollectThresholdMicros), + IsLargeAutoCollect: flagLargeAutoCollect(prorated+overageTotalMicros, acct), }, // Freeze what was billed keyed by the FULL anchored period_start (the - // display identity, migration 028); BaseMicros is the prorated amount. + // display identity, migration 028); BaseMicros is the prorated BASE amount + // (the co-created overage rides the per-module timers, not the base snapshot). Snapshot: AppBaseSnapshot{ AppID: locked.AppID, PeriodStart: periodStart, @@ -319,6 +374,7 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) ModuleCount: locked.CreatedModuleCount, BaseMicros: prorated, }, + TimerCharges: timerCharges, }, nil }) if err != nil { diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go new file mode 100644 index 0000000..ae4f967 --- /dev/null +++ b/internal/account/cycle/scenarios_test.go @@ -0,0 +1,310 @@ +package cycle_test + +// End-to-end scenario regression suite for the per-module overage timer model +// (DESIGN.md "Base fee — v2: creation grace + per-module overage timers", +// scenarios 1–6). Each test drives the SAME code paths cmd/billing-cycle drives +// (RegisterApp → SweepCreationProrations → SweepModuleOverage → RunBillingCycle) +// and asserts the exact dollar amounts + the exact Stripe invoice-count the spec +// calls out. Reuses the in-memory fakeStore (service_test.go) + fakeStripe +// (charge_test.go) + the registeredAccount / appsSvc / seedApp / seedTimer / +// seedIncluded helpers. +// +// Fixture: registeredAccount activates 2026-05-04 → anchor day 4, so the anchored +// period CONTAINING a mid-June instant is [2026-06-04, 2026-07-04) = 30 days. An +// app created 2026-06-19 has remain_days = whole UTC days in [Jun 19, Jul 4) = 15, +// exactly HALF the period, so each prorated amount is a clean half: +// * base $20 × 15/30 = $10.00 → 1000¢ +// * overage $3 × 15/30 = $1.50 → 150¢ + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" +) + +func ptrI64(v int64) *int64 { return &v } + +var scenarioCreatedAt = timeUTC(2026, 6, 19, 12) // mid-period create (anchor 4) + +// scenarioSweepAt is past scenarioCreatedAt + GraceDays (Jun 22), so both the +// app's creation grace and its co-created modules' grace have elapsed. +var scenarioSweepAt = timeUTC(2026, 6, 25, 9) + +func timeUTC(y int, m, d, h int) time.Time { + return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC) +} + +// --- Scenario 1: app just created → no charge; deleted in grace → never charged - + +func TestScenario1_CreatedThenDeletedInGraceNeverCharged(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // Create with 3 modules — no charge fires at creation (creation grace). + registerMirror(t, svc, user, appID, scenarioCreatedAt, 3) + require.Empty(t, sc.itemCalls, "no charge at creation (scenario 1)") + require.Equal(t, 3, liveTimerCount(store, appID)) + + // Deleted WITHIN grace (day 1) → the app + all its install timers drop out. + _, err := svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Equal(t, 0, liveTimerCount(store, appID), "delete soft-removes all timers") + + // Both grace sweeps, run long past grace, charge NOTHING — a deleted app is out + // of the proration work-list and its timers are out of the overage work-list. + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, pro.Charged) + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged) + + require.Empty(t, sc.invoiceCalls, "an app deleted in grace is NEVER charged (scenario 1)") + require.Empty(t, store.invoices) +} + +// --- Scenario 2: survives grace, pool ≤ 5 → base-only prorated charge ---------- + +func TestScenario2_SurvivesGracePoolWithinIncludedBaseOnly(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // 3 co-created modules → pool 3 ≤ IncludedModules(5): all "included", no overage. + registerMirror(t, svc, user, appID, scenarioCreatedAt, 3) + + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro.Charged) + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "3 modules are all included — no overage") + + // EXACTLY one invoice, ONE line item: the prorated FLAT base only. + require.Len(t, sc.invoiceCalls, 1, "base-only creation invoice (scenario 2)") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 1000, sc.itemCalls[0].amountCfg, "$20 × 15/30 = $10.00") + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + + // The 3 co-created timers all resolved as included, none charged. + for _, tm := range store.timers { + require.True(t, tm.graceResolved) + require.False(t, tm.graceCharged) + } +} + +// --- Scenario 3: pool > 5 from day 0 → ONE combined invoice (base + overage) --- + +func TestScenario3_PoolOverFromDayZeroOneCombinedInvoice(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // 7 co-created modules → pool 7 > IncludedModules(5): 2 are "over" from day 0 + // (installed AT created_at). Their grace elapses at the SAME instant as the + // app's creation grace, so they ride ONE combined invoice with the base. + registerMirror(t, svc, user, appID, scenarioCreatedAt, 7) + + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro.Charged) + // The overage sweep AFTER proration finds the 2 over-modules already resolved + // (charged on the combined invoice) and the 5 included ones resolvable with no + // charge — it adds NO second invoice. + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "co-created over-modules already billed on the combined invoice") + require.Equal(t, 5, over.Included, "the 5 included modules resolve with no charge") + + // EXACTLY ONE Stripe invoice (not two), with THREE line items: base + 2 overage. + require.Len(t, sc.invoiceCalls, 1, "scenario 3 is ONE combined invoice, never two") + require.Len(t, sc.itemCalls, 3) + require.EqualValues(t, 1000, sc.itemCalls[0].amountCfg, "base: $20 × 15/30 = $10.00") + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + require.EqualValues(t, 150, sc.itemCalls[1].amountCfg, "overage: $3 × 15/30 = $1.50") + require.EqualValues(t, 150, sc.itemCalls[2].amountCfg) + // Overage line items use the SAME per-timer idem keys Leg 1 would use, so a + // racing sweep can never double-charge them. + require.Contains(t, sc.itemCalls[1].idemKey, "mod-overage-ii-") + require.Contains(t, sc.itemCalls[2].idemKey, "mod-overage-ii-") + + // Exactly 2 timers marked charged (the over ones); all 7 resolved. + charged, resolved := 0, 0 + for _, tm := range store.timers { + if tm.graceCharged { + charged++ + require.Contains(t, tm.graceInvoiceItemID, "ii_test_", "the REAL Stripe item id, not the idem key") + } + if tm.graceResolved { + resolved++ + } + } + require.Equal(t, 2, charged, "exactly the 2 over-modules were charged") + require.Equal(t, 7, resolved, "all 7 co-created timers reached a terminal verdict") +} + +// --- Scenario 4: pool crosses 5 later → two independent prorated charges ------- + +func TestScenario4_PoolCrossesFiveLaterPerModuleTimers(t *testing.T) { + // Two modules installed a day apart, each pushing the account-wide pool over 5, + // get their OWN independently-anchored 3-day grace and two DIFFERENT prorated + // charges on two DIFFERENT days (install-anchored to [install, period end)): + // * module A installed Jun 10 → grace ends Jun 13 → $3 × 24/30 = $2.40 (240¢) + // * module B installed Jun 11 → grace ends Jun 14 → $3 × 23/30 = $2.30 (230¢) + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + // 5 pre-existing included modules → the two newcomers land in the "over" bucket. + seedIncluded(store, acct, uuid.New(), timeUTC(2026, 5, 4, 0), 5) + app := uuid.New() + timerA := seedTimer(store, acct, app, timeUTC(2026, 6, 10, 0)) + timerB := seedTimer(store, acct, app, timeUTC(2026, 6, 11, 0)) + + // Sweep Jun 13: only A is past its own grace → one charge, $2.40. + resA, err := svc.SweepModuleOverage(ctx, timeUTC(2026, 6, 13, 9)) + require.NoError(t, err) + require.Equal(t, 1, resA.Charged) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg, "A: $3 × 24/30 = $2.40") + require.Equal(t, "mod-overage-ii-"+timerA.String(), sc.itemCalls[0].idemKey) + require.False(t, store.timers[timerB].graceResolved, "B is still in its own grace") + + // Sweep Jun 14: B's grace now elapsed → a DIFFERENT amount on a DIFFERENT day; + // A is already resolved and never re-charged. + resB, err := svc.SweepModuleOverage(ctx, timeUTC(2026, 6, 14, 9)) + require.NoError(t, err) + require.Equal(t, 1, resB.Charged) + require.Len(t, sc.itemCalls, 2, "A must not be charged again") + require.EqualValues(t, 230, sc.itemCalls[1].amountCfg, "B: $3 × 23/30 = $2.30") + require.Equal(t, "mod-overage-ii-"+timerB.String(), sc.itemCalls[1].idemKey) + require.True(t, store.timers[timerA].graceCharged) + require.True(t, store.timers[timerB].graceCharged) +} + +// --- Scenario 5: the shared auto-collect helper fires at EVERY charge site ----- + +func TestScenario5_LargeAutoCollectFlagAtEveryChargeSite(t *testing.T) { + // The SAME flagLargeAutoCollect helper (migration 034) sets is_large_auto_collect + // on the mirror row of EVERY off-session charge — the creation/combined leg, the + // per-module grace leg (Leg 1), and the boundary leg (Leg 2) — resolved AT CHARGE + // TIME against the account's threshold. A per-account override BELOW the charged + // amount flags it; the default $100 (nil override) does not, at every site. + onlyMirror := func(store *fakeStore) cycle.InvoiceMirror { + require.Len(t, store.invoices, 1) + for _, m := range store.invoices { + return m + } + return cycle.InvoiceMirror{} + } + + t.Run("creation/combined leg", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + user, _ := registeredAccount(store) + store.collection.AutoCollectThresholdMicros = threshold + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, scenarioCreatedAt, 0) // $10 base charge + _, err := svc.SweepCreationProrations(context.Background(), scenarioSweepAt) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(5_000_000)).IsLargeAutoCollect, "$10 base > $5 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$10 base < $100 default → not flagged") + }) + + t.Run("per-module grace leg (Leg 1)", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + _, acct := registeredAccount(store) + store.collection.AutoCollectThresholdMicros = threshold + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + seedIncluded(store, acct, uuid.New(), timeUTC(2026, 5, 4, 0), 5) + seedTimer(store, acct, uuid.New(), timeUTC(2026, 6, 19, 0)) // over, $1.50 + _, err := svc.SweepModuleOverage(context.Background(), timeUTC(2026, 6, 25, 9)) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(1_000_000)).IsLargeAutoCollect, "$1.50 overage > $1 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$1.50 overage < $100 default → not flagged") + }) + + t.Run("boundary leg (Leg 2)", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_s5" + store.collection.AutoCollectThresholdMicros = threshold + seedApp(store, chargeAccount, 0, false) // $20 advance base + sc := newFakeStripe() + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(5_000_000)).IsLargeAutoCollect, "$20 boundary > $5 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$20 boundary < $100 default → not flagged") + }) +} + +// --- Scenario 6: boundary = arrears + base + ongoing-over-module overage ------- + +func TestScenario6_BoundaryPrechargesOngoingOverModulesOnly(t *testing.T) { + // At the period boundary the ONE invoice = closed period's usage arrears + + // the new period's FLAT base (per live pre-existing app) + a FULL $3 precharge + // for every ONGOING over-module (a live "over" timer already charged at least + // once). A timer still inside its OWN grace (never charged) is NOT double-counted. + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 usage arrears + store.hasPM = true + store.stripeCustomer = "cus_s6" + app := seedApp(store, chargeAccount, 0, false) // one live pre-existing app → $20 base + + // 5 included (ranks 0-4) + two ONGOING over-modules already charged in an + // earlier period (ranks 5-6) + one over-module STILL in its own grace (rank 7, + // never charged). Only the two ongoing ones are precharged for the new period. + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + ongoing1 := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 10, 0)) + ongoing2 := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 11, 0)) + // One over-module STILL inside its own grace (never charged) — excluded below. + seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 28, 0)) + for _, id := range []uuid.UUID{ongoing1, ongoing2} { + store.timers[id].graceResolved = true + store.timers[id].graceCharged = true // charged in a prior period → ongoing + } + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 1_000_000, resp.ArrearsMicros) + require.EqualValues(t, usage.BaseFeeMicros, resp.AdvanceBaseMicros, "one live app → $20 base") + require.EqualValues(t, 2*usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "only the 2 ONGOING over-modules are precharged; the in-grace one is excluded") + + // One invoice, ONE pooled line: $1 arrears + $20 base + 2 × $3 overage = $27 → 2700¢. + require.Len(t, sc.invoiceCalls, 1) + require.Len(t, sc.itemCalls, 1, "arrears + base + overage pool into ONE line") + require.EqualValues(t, 2_700, resp.ChargedCents) + require.EqualValues(t, 2_700, sc.itemCalls[0].amountCfg) +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index a26b3fe..348d8fa 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -125,6 +125,8 @@ type fakeStore struct { errTimerRank error // LiveModuleTimerRankBefore errMarkIncluded error // MarkModuleTimerIncluded errMarkTimerCharged error // MarkModuleTimerCharged + errCountOngoingOver error // CountOngoingOverModuleTimers + errCoCreatedOver error // CoCreatedOverModuleTimers } // fakeTimer models one ms_billing.app_module_overage_timers row (migration 033). @@ -538,6 +540,18 @@ func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, ch f.baseSnapshots[snapKey{pc.Snapshot.AppID, pc.Snapshot.PeriodStart}] = fakeBaseSnapshot{snap: pc.Snapshot, source: "proration"} app.ProrationInvoiceID = pc.InvoiceID // first-charge-wins, like WHERE … IS NULL under the lock f.apps[appID] = app + // Scenario 3 — mark the co-created over-module timers billed on this combined + // invoice terminally charged, in the SAME "transaction" (first-write-wins on + // grace_resolved, like the real MarkModuleTimerCharged WHERE grace_resolved = false). + for _, tc := range pc.TimerCharges { + if t, ok := f.timers[tc.TimerID]; ok && !t.graceResolved { + t.graceResolved = true + t.graceCharged = true + t.graceChargedAt = tc.ChargedAt + t.graceInvoiceID = tc.InvoiceID + t.graceInvoiceItemID = tc.InvoiceItemID + } + } return cycle.ProrationLockedCharged, pc.InvoiceID, nil } @@ -730,6 +744,52 @@ func (f *fakeStore) MarkModuleTimerCharged(_ context.Context, timerID uuid.UUID, return nil } +// liveTimersForAccountFIFO returns the account's live timers ordered (installed_at +// ASC, id ASC) — the FIFO order the rank/over predicates read. +func (f *fakeStore) liveTimersForAccountFIFO(accountID uuid.UUID) []*fakeTimer { + var out []*fakeTimer + for _, t := range f.timers { + if t.accountID == accountID && !t.removed { + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { + if !out[i].installedAt.Equal(out[j].installedAt) { + return out[i].installedAt.Before(out[j].installedAt) + } + return out[i].id.String() < out[j].id.String() + }) + return out +} + +func (f *fakeStore) CountOngoingOverModuleTimers(_ context.Context, accountID uuid.UUID, includedModules int) (int, error) { + if f.errCountOngoingOver != nil { + return 0, f.errCountOngoingOver + } + // Live FIFO: the first includedModules are "included"; the rest are "over". + // Count the "over" tail that has already been charged at least once. + n := 0 + for rank, t := range f.liveTimersForAccountFIFO(accountID) { + if rank >= includedModules && t.graceCharged { + n++ + } + } + return n, nil +} + +func (f *fakeStore) CoCreatedOverModuleTimers(_ context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) { + if f.errCoCreatedOver != nil { + return nil, f.errCoCreatedOver + } + var out []uuid.UUID + for rank, t := range f.liveTimersForAccountFIFO(accountID) { + if rank >= includedModules && t.appID == appID && !t.graceResolved && t.installedAt.Equal(createdAt) { + out = append(out, t.id) + } + } + return out, nil +} + // --- helpers -------------------------------------------------------------- func requireCode(t *testing.T, err error, want billing.Code) { diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 64700a3..135cdea 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -306,6 +306,20 @@ type Store interface { // the GENUINE Stripe invoice / invoice-item ids (never idempotency-key // strings). WHERE grace_resolved IS false keeps a crash-retry idempotent. MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error + + // CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario + // 6): the count of the account's live timers that are BOTH "over" (live-FIFO + // rank >= includedModules) AND already charged at least once (an ongoing + // over-module continuing into the new period). A timer still in its own grace + // (never charged) is excluded — it stays on Leg 1's timer, never double-counted. + CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int) (int, error) + + // CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the + // ids of an app's live, unresolved install timers whose install instant equals + // the app's createdAt (co-created at app creation) AND that are "over" (live-FIFO + // rank >= includedModules) — the co-created over-modules folded onto the app's + // own creation-proration invoice, priced from the same day-0 window. + CoCreatedOverModuleTimers(ctx context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) } // ModuleOverageCandidate is one per-module-instance install timer the Leg 1 @@ -1108,6 +1122,22 @@ func (s *pgxStore) persistProrationCharge(ctx context.Context, appID uuid.UUID, return 0, "", err } + // Scenario 3 — stamp the co-created over-module timers billed on this SAME + // combined invoice as terminally charged, in the SAME transaction as the guard + // arm (all-or-nothing: an over-module and the app base are marked together). + // WHERE grace_resolved = false is first-write-wins — a Leg 1 sweep that already + // resolved a timer (its own invoice) affects 0 rows here, keeping the winner's ids. + for _, tc := range pc.TimerCharges { + if err := qtx.MarkModuleTimerCharged(ctx, db.MarkModuleTimerChargedParams{ + TimerID: tc.TimerID.String(), + GraceChargedAt: tc.ChargedAt, + GraceInvoiceID: pgtype.Text{String: tc.InvoiceID, Valid: tc.InvoiceID != ""}, + GraceInvoiceItemID: pgtype.Text{String: tc.InvoiceItemID, Valid: tc.InvoiceItemID != ""}, + }); err != nil { + return 0, "", err + } + } + if err := tx.Commit(ctx); err != nil { return 0, "", err } @@ -1316,6 +1346,30 @@ func (s *pgxStore) MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID }) } +func (s *pgxStore) CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int) (int, error) { + n, err := s.q.CountOngoingOverModuleTimers(ctx, db.CountOngoingOverModuleTimersParams{ + AccountID: accountID.String(), + IncludedModules: int32(includedModules), //nolint:gosec // includedModules is the small IncludedModules const (5) + }) + if err != nil { + return 0, err + } + return int(n), nil +} + +func (s *pgxStore) CoCreatedOverModuleTimers(ctx context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) { + ids, err := s.q.CoCreatedOverModuleTimers(ctx, db.CoCreatedOverModuleTimersParams{ + AccountID: accountID.String(), + AppID: appID.String(), + CreatedAt: createdAt, + IncludedModules: int32(includedModules), //nolint:gosec // includedModules is the small IncludedModules const (5) + }) + if err != nil { + return nil, err + } + return parseUUIDs(ids) +} + // parseUUIDs parses a slice of UUID-as-string account ids (the form the sqlc // NOT NULL uuid → string override yields) into uuid.UUID. func parseUUIDs(ids []string) ([]uuid.UUID, error) { diff --git a/internal/account/cycle/types.go b/internal/account/cycle/types.go index 0a77310..1e2ed91 100644 --- a/internal/account/cycle/types.go +++ b/internal/account/cycle/types.go @@ -206,15 +206,22 @@ type ChargeSummary struct { ArrearsMicros int64 // AdvanceBaseMicros is the NEW period's advance base fee: Σ over the - // account's live apps of the FLAT BaseFeeMicros. Module overage is NO longer - // billed at the boundary in this leg — it rides per-module-instance grace - // timers (migration 033, Leg 1 in cycle/overage.go); the boundary - // per-module overage precharge (scenario 6) is a Stage B follow-up. 0 for a - // pre-backfill account (no mirror rows). + // account's live apps of the FLAT BaseFeeMicros. 0 for a pre-backfill + // account (no mirror rows). Module overage is billed SEPARATELY as + // AdvanceOverageMicros, not folded into an app's base. AdvanceBaseMicros int64 + // AdvanceOverageMicros is the NEW period's advance overage precharge + // (scenario 6, Leg 2): ModuleOverageFeeMicros × the count of ongoing + // over-modules (live install timers that are "over" per the live FIFO and + // already charged at least once). Billed FULL (not prorated — the module + // exists for the whole new period) on the same boundary invoice. 0 when no + // ongoing over-module continues into the new period. + AdvanceOverageMicros int64 + // ChargedCents is the whole-cent amount sent to Stripe (micros → cents - // round-half-up over arrears + advance base). 0 when no charge happened. + // round-half-up over arrears + advance base + advance overage). 0 when no + // charge happened. ChargedCents int64 // StripeInvoiceID is the created Stripe invoice id, empty when no charge diff --git a/internal/account/db/module_timers.sql.go b/internal/account/db/module_timers.sql.go index 801d66c..358eb36 100644 --- a/internal/account/db/module_timers.sql.go +++ b/internal/account/db/module_timers.sql.go @@ -12,6 +12,121 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const coCreatedOverModuleTimers = `-- name: CoCreatedOverModuleTimers :many +SELECT id +FROM ( + SELECT id, app_id, installed_at, grace_resolved, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = $1::uuid + AND removed_at IS NULL +) ranked +WHERE app_id = $2::uuid + AND installed_at = $3::timestamptz + AND grace_resolved = false + AND rn > $4::int +ORDER BY installed_at, id +` + +type CoCreatedOverModuleTimersParams struct { + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + CreatedAt time.Time `json:"created_at"` + IncludedModules int32 `json:"included_modules"` +} + +// CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the +// ids of the app's live, unresolved install timers whose install instant IS the +// app's created_at (co-created at app creation) AND that are "over" (live-FIFO +// rank >= included). Their grace elapses at the SAME instant as the app's own +// creation grace, so the creation-proration charge folds them onto ONE invoice. +// The rank window spans ALL the account's live timers (an included module still +// occupies a FIFO slot), so rn > @included_modules is the 0-based rank >= included +// "over" predicate; the outer filter keeps only this app's co-created, still- +// unresolved rows. Ordered (installed_at, id) for a deterministic charge order. +func (q *Queries) CoCreatedOverModuleTimers(ctx context.Context, arg CoCreatedOverModuleTimersParams) ([]string, error) { + rows, err := q.db.Query(ctx, coCreatedOverModuleTimers, + arg.AccountID, + arg.AppID, + arg.CreatedAt, + arg.IncludedModules, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const countLiveModuleTimersForAccount = `-- name: CountLiveModuleTimersForAccount :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1 + AND removed_at IS NULL +` + +// CountLiveModuleTimersForAccount returns the account's currently-live +// (removed_at IS NULL) install-timer count — the DISPLAY read behind +// GetAccountBill's account-overage line under the per-module-instance model +// (migration 033). The steady-state estimate $3 × max(0, live − included) counts +// the live "over" rows (the FIFO tail past the included 5); reading the timer +// table (the overage model's source of truth) rather than SUM(apps.module_count) +// keeps the shown overage tied to the rows the charge legs actually tier on. +// ::bigint keeps the aggregate a non-nullable scalar. +func (q *Queries) CountLiveModuleTimersForAccount(ctx context.Context, accountID string) (int64, error) { + row := q.db.QueryRow(ctx, countLiveModuleTimersForAccount, accountID) + var live_count int64 + err := row.Scan(&live_count) + return live_count, err +} + +const countOngoingOverModuleTimers = `-- name: CountOngoingOverModuleTimers :one +SELECT COALESCE(count(*), 0)::bigint AS over_count +FROM ( + SELECT grace_charged_at, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = $1::uuid + AND removed_at IS NULL +) ranked +WHERE rn > $2::int + AND grace_charged_at IS NOT NULL +` + +type CountOngoingOverModuleTimersParams struct { + AccountID string `json:"account_id"` + IncludedModules int32 `json:"included_modules"` +} + +// CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): +// the count of the account's currently-live install timers that are BOTH "over" +// (live-FIFO rank >= included) AND already charged at least once (grace_charged_at +// IS NOT NULL) — i.e. ongoing over-modules continuing into the new period, each +// owed a FULL $3 precharge on the boundary invoice. row_number() over the whole +// live set gives every live timer its 1-based FIFO rank; rn > @included_modules is +// exactly the 0-based rank >= included ("over") predicate. A timer whose grace has +// not elapsed yet (grace_charged_at IS NULL) is EXCLUDED — it stays on Leg 1's own +// timer and is never double-counted here. "over" is re-derived LIVE, so a charged +// timer that has since flipped to "included" (an earlier install removed) is not +// counted. +func (q *Queries) CountOngoingOverModuleTimers(ctx context.Context, arg CountOngoingOverModuleTimersParams) (int64, error) { + row := q.db.QueryRow(ctx, countOngoingOverModuleTimers, arg.AccountID, arg.IncludedModules) + var over_count int64 + err := row.Scan(&over_count) + return over_count, err +} + const insertModuleOverageTimers = `-- name: InsertModuleOverageTimers :exec INSERT INTO ms_billing.app_module_overage_timers (account_id, app_id, installed_at, grace_expires_at) diff --git a/internal/account/db/overage.sql.go b/internal/account/db/overage.sql.go deleted file mode 100644 index 6a4dcad..0000000 --- a/internal/account/db/overage.sql.go +++ /dev/null @@ -1,37 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.30.0 -// source: overage.sql - -package db - -import ( - "context" -) - -const sumLiveModuleCount = `-- name: SumLiveModuleCount :one - -SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count -FROM ms_billing.apps -WHERE account_id = $1 - AND deleted_at IS NULL -` - -// Queries backing the account-wide module-count SUM (migration 027/030). Under -// the per-module-instance overage model (migration 033) this is no longer a -// charge input — the charge legs tier on per-install timers (module_timers.sql). -// It survives only as the DISPLAY read: GetAccountBill's account-overage line -// shows the steady-state monthly estimate $3 × max(0, pooled − IncludedModules) -// (usage.AccountOverageMicros) from the CURRENT live pool. Money never lives on -// the apps mirror; the pool is derived on read. -// SumLiveModuleCount returns the account-wide installed-module count: -// SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps — -// the display's steady-state overage estimate input. COALESCE to 0 so an -// account with no live apps sums to 0. ::bigint keeps the aggregate a -// non-nullable scalar. -func (q *Queries) SumLiveModuleCount(ctx context.Context, accountID string) (int64, error) { - row := q.db.QueryRow(ctx, sumLiveModuleCount, accountID) - var pooled_count int64 - err := row.Scan(&pooled_count) - return pooled_count, err -} diff --git a/internal/account/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql index 6de0fcd..0721c65 100644 --- a/internal/account/db/queries/module_timers.sql +++ b/internal/account/db/queries/module_timers.sql @@ -106,3 +106,64 @@ SET grace_resolved = true, grace_invoice_item_id = @grace_invoice_item_id WHERE id = @timer_id::uuid AND grace_resolved = false; + +-- CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): +-- the count of the account's currently-live install timers that are BOTH "over" +-- (live-FIFO rank >= included) AND already charged at least once (grace_charged_at +-- IS NOT NULL) — i.e. ongoing over-modules continuing into the new period, each +-- owed a FULL $3 precharge on the boundary invoice. row_number() over the whole +-- live set gives every live timer its 1-based FIFO rank; rn > @included_modules is +-- exactly the 0-based rank >= included ("over") predicate. A timer whose grace has +-- not elapsed yet (grace_charged_at IS NULL) is EXCLUDED — it stays on Leg 1's own +-- timer and is never double-counted here. "over" is re-derived LIVE, so a charged +-- timer that has since flipped to "included" (an earlier install removed) is not +-- counted. +-- name: CountOngoingOverModuleTimers :one +SELECT COALESCE(count(*), 0)::bigint AS over_count +FROM ( + SELECT grace_charged_at, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = @account_id::uuid + AND removed_at IS NULL +) ranked +WHERE rn > @included_modules::int + AND grace_charged_at IS NOT NULL; + +-- CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the +-- ids of the app's live, unresolved install timers whose install instant IS the +-- app's created_at (co-created at app creation) AND that are "over" (live-FIFO +-- rank >= included). Their grace elapses at the SAME instant as the app's own +-- creation grace, so the creation-proration charge folds them onto ONE invoice. +-- The rank window spans ALL the account's live timers (an included module still +-- occupies a FIFO slot), so rn > @included_modules is the 0-based rank >= included +-- "over" predicate; the outer filter keeps only this app's co-created, still- +-- unresolved rows. Ordered (installed_at, id) for a deterministic charge order. +-- name: CoCreatedOverModuleTimers :many +SELECT id +FROM ( + SELECT id, app_id, installed_at, grace_resolved, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = @account_id::uuid + AND removed_at IS NULL +) ranked +WHERE app_id = @app_id::uuid + AND installed_at = @created_at::timestamptz + AND grace_resolved = false + AND rn > @included_modules::int +ORDER BY installed_at, id; + +-- CountLiveModuleTimersForAccount returns the account's currently-live +-- (removed_at IS NULL) install-timer count — the DISPLAY read behind +-- GetAccountBill's account-overage line under the per-module-instance model +-- (migration 033). The steady-state estimate $3 × max(0, live − included) counts +-- the live "over" rows (the FIFO tail past the included 5); reading the timer +-- table (the overage model's source of truth) rather than SUM(apps.module_count) +-- keeps the shown overage tied to the rows the charge legs actually tier on. +-- ::bigint keeps the aggregate a non-nullable scalar. +-- name: CountLiveModuleTimersForAccount :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1 + AND removed_at IS NULL; diff --git a/internal/account/db/queries/overage.sql b/internal/account/db/queries/overage.sql deleted file mode 100644 index 28ad37b..0000000 --- a/internal/account/db/queries/overage.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Queries backing the account-wide module-count SUM (migration 027/030). Under --- the per-module-instance overage model (migration 033) this is no longer a --- charge input — the charge legs tier on per-install timers (module_timers.sql). --- It survives only as the DISPLAY read: GetAccountBill's account-overage line --- shows the steady-state monthly estimate $3 × max(0, pooled − IncludedModules) --- (usage.AccountOverageMicros) from the CURRENT live pool. Money never lives on --- the apps mirror; the pool is derived on read. - --- SumLiveModuleCount returns the account-wide installed-module count: --- SUM(module_count) over the account's LIVE (deleted_at IS NULL) apps — --- the display's steady-state overage estimate input. COALESCE to 0 so an --- account with no live apps sums to 0. ::bigint keeps the aggregate a --- non-nullable scalar. --- name: SumLiveModuleCount :one -SELECT COALESCE(SUM(module_count), 0)::bigint AS pooled_count -FROM ms_billing.apps -WHERE account_id = $1 - AND deleted_at IS NULL; diff --git a/internal/account/usage/accountbill.go b/internal/account/usage/accountbill.go index e783b82..dd2544d 100644 --- a/internal/account/usage/accountbill.go +++ b/internal/account/usage/accountbill.go @@ -5,7 +5,6 @@ import ( "context" "slices" "sort" - "time" "github.com/google/uuid" @@ -173,14 +172,16 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) } // Account overage line (migration 033): the steady-state monthly estimate - // from the CURRENT live pool — $3 × max(0, pooled − IncludedModules). Under - // the per-module-instance model overage is billed per install on its own - // grace timer (Leg 1); the display shows the live steady-state figure (the - // display doesn't prorate; the charge legs own proration) rather than a - // per-period frozen snapshot (a faithful per-period display from the timer - // rows is a Stage B follow-up). It is an ACCOUNT line, never allocated back - // per app. - accountOverage, err := s.accountOverageMicros(ctx, accountID, periodStart) + // from the CURRENT live install timers — $3 × max(0, live − IncludedModules). + // Under the per-module-instance model overage is billed per install on its own + // grace timer (Leg 1) / at the boundary (Leg 2); the display sums the live + // "over" timer rows and prices them at the steady-state $3 each — the timer + // table is the overage model's source of truth. The display does not prorate + // (the charge legs own proration), so this is an estimate of the period's + // overage, not a per-line replay of the exact prorated amounts invoiced (that + // would need a per-timer charged-micros column — a follow-up). It is an + // ACCOUNT line, never allocated back per app. + accountOverage, err := s.accountOverageMicros(ctx, accountID) if err != nil { return nil, err } @@ -209,19 +210,19 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) } // accountOverageMicros resolves the account overage shown on the bill: the -// steady-state monthly estimate AccountOverageMicros(current pooled sum) = $3 × -// max(0, pooled − IncludedModules). Under the per-module-instance overage model -// (migration 033) overage is billed per install on its own grace timer (Leg 1, -// cycle/overage.go); the display shows the live steady-state figure rather than a -// per-period frozen snapshot (a faithful per-period display from the timer rows -// is a Stage B follow-up). The PaaS credit never offsets it (overage rides ON -// TOP, like the base fee), so it is resolved outside the credit math. -func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID, _ time.Time) (int64, error) { - pooled, err := s.store.PooledModuleCount(ctx, accountID) +// steady-state monthly estimate AccountOverageMicros(live timer count) = $3 × +// max(0, live − IncludedModules), where `live` is the count of the account's +// currently-live install timers (migration 033) — the overage model's source of +// truth. The first IncludedModules live timers (by FIFO) are "included"; the rest +// are "over", so max(0, live − included) is exactly the live over-count the charge +// legs tier on. The PaaS credit never offsets it (overage rides ON TOP, like the +// base fee), so it is resolved outside the credit math. +func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID) (int64, error) { + live, err := s.store.LiveModuleTimerCountForAccount(ctx, accountID) if err != nil { - return 0, billing.Internal("pooled module count lookup failed", err) + return 0, billing.Internal("live module timer count lookup failed", err) } - return AccountOverageMicros(pooled), nil + return AccountOverageMicros(live), nil } // accountPaasCreditMicros is the ACCOUNT-level PaaS credit: the same diff --git a/internal/account/usage/basefee.go b/internal/account/usage/basefee.go index b7cbdbd..c1e8c45 100644 --- a/internal/account/usage/basefee.go +++ b/internal/account/usage/basefee.go @@ -2,52 +2,40 @@ package usage import "time" -// This file is the SINGLE home of the base-fee + pooled-overage math (owner -// spec 2026-07-05, DESIGN.md "Base fee — v1 spec"; account-wide overage -// reversal, migration 032). Both consumers — the display read (GetAppBill / -// GetAccountBill, this package) and the charge spine (cycle: the RegisterApp -// creation-proration charge, the boundary advance leg, the mid-period grace -// sweep) — compute the per-app FLAT base and the account-wide pooled overage -// through these functions, so the bill page, the invoice, and the mirror can -// never disagree by construction. All money is integer micro-dollars; the -// arithmetic here is pure int64 (no big.Rat needed: the operands are bounded — -// see ProratedBaseMicros). +// This file is the SINGLE home of the base-fee + overage display math (owner +// spec 2026-07-05, DESIGN.md "Base fee — v2: creation grace + per-module overage +// timers"). Both consumers — the display read (GetAppBill / GetAccountBill, this +// package) and the charge spine (cycle: the creation/combined charge, the +// boundary advance leg, the per-module grace sweep) — compute the per-app FLAT +// base and the account overage through these functions, so the bill page, the +// invoice, and the mirror can never disagree by construction. All money is +// integer micro-dollars; the arithmetic here is pure int64 (no big.Rat needed: +// the operands are bounded — see ProratedBaseMicros). // -// Overage moved from PER-APP to ACCOUNT-WIDE POOLED (migration 032): the flat -// $20/app base is per-app and unchanged, but the $3/module surcharge now -// applies ONCE per account to max(0, Σ live-app module_count − IncludedModules) -// — see AccountOverageMicros. There is deliberately NO per-app overage helper -// anymore: an app's base is just the flat (plan-resolved) fee. +// The flat $20/app base is per-app; the $3/module surcharge applies to the +// account's over-count, max(0, live module count − IncludedModules). Under the +// per-module-instance model (migration 033) the charge legs tier per install +// TIMER (each on its own grace), while the DISPLAY reads the live timer count +// through AccountOverageMicros. There is deliberately NO per-app overage helper: +// an app's base is just the flat (plan-resolved) fee. -// AccountOverageMicros is the account-wide POOLED module overage for one -// period: +// AccountOverageMicros is the account's module overage shown for one period: // -// ModuleOverageFeeMicros × max(0, pooledModuleCount − IncludedModules) +// ModuleOverageFeeMicros × max(0, liveModuleCount − IncludedModules) // -// pooledModuleCount is SUM(module_count) over the account's LIVE apps (one pool -// of IncludedModules for the WHOLE account, not per app). A pooledModuleCount -// ≤ IncludedModules yields 0 (the max(0, …) clamp makes the function total; -// a negative count cannot occur — the sum of non-negative DB-CHECKed counts). -func AccountOverageMicros(pooledModuleCount int) int64 { - if extra := pooledModuleCount - IncludedModules; extra > 0 { +// liveModuleCount is the account's live installed-module count (the count of live +// install timers, migration 033 — one pool of IncludedModules for the WHOLE +// account, not per app). The first IncludedModules live installs (by FIFO) are +// "included"; the rest are "over", so max(0, live − included) is exactly the live +// over-count. A liveModuleCount ≤ IncludedModules yields 0 (the max(0, …) clamp +// makes the function total; a negative count cannot occur — a live-row count). +func AccountOverageMicros(liveModuleCount int) int64 { + if extra := liveModuleCount - IncludedModules; extra > 0 { return ModuleOverageFeeMicros * int64(extra) } return 0 } -// ProratedOverageMicros prorates an account-wide pooled overage amount for the -// period [periodStart, periodEnd), covering [grace-end day, periodEnd): the -// SAME day-count round-half-up math as ProratedBaseMicros (the overage amount -// is prorated exactly like a base amount), but ANCHORED on the grace-end -// instant instead of an app's creation instant. graceEnd on/before periodStart -// → the FULL overage (the account was over for the whole period); graceEnd -// on/after periodEnd → 0 (grace ends after this period — nothing to charge -// yet). Kept as a named wrapper so the semantic ("prorate FROM grace-end") is -// legible at the mid-period grace sweep's call site. -func ProratedOverageMicros(overageMicros int64, graceEnd, periodStart, periodEnd time.Time) int64 { - return ProratedBaseMicros(overageMicros, graceEnd, periodStart, periodEnd) -} - // ProratedBaseMicros prorates an app's per-period base fee for the period // [periodStart, periodEnd) given the app's creation instant: // diff --git a/internal/account/usage/basefee_test.go b/internal/account/usage/basefee_test.go index f611920..aa5c41f 100644 --- a/internal/account/usage/basefee_test.go +++ b/internal/account/usage/basefee_test.go @@ -126,21 +126,3 @@ func TestProratedBaseMicros_ClampedAnchorMonth(t *testing.T) { got = usage.ProratedBaseMicros(usage.BaseFeeMicros, day(2026, 3, 1), day(2026, 2, 28), day(2026, 3, 31)) require.EqualValues(t, 19_354_839, got) } - -func TestProratedOverageMicros_ProratesPooledOverageFromGraceEnd(t *testing.T) { - // Migration 032: the mid-period sweep prorates the account-wide POOLED - // overage from grace-end to the period end with the SAME day-count math as - // ProratedBaseMicros. Pool of 7 → 2 over → $6/period; grace ends mid-period - // (Jun 19 → 15 of a 30-day [Jun 4, Jul 4) period) → half → $3. - overage := usage.AccountOverageMicros(7) - require.EqualValues(t, 6_000_000, overage) - got := usage.ProratedOverageMicros(overage, day(2026, 6, 19), day(2026, 6, 4), day(2026, 7, 4)) - require.EqualValues(t, 3_000_000, got) - - // grace-end on/before the period start → the FULL pooled overage (over the - // whole period). - require.EqualValues(t, overage, usage.ProratedOverageMicros(overage, day(2026, 6, 4), day(2026, 6, 4), day(2026, 7, 4))) - - // grace-end on/after the period end → 0 (grace ends after this period). - require.EqualValues(t, 0, usage.ProratedOverageMicros(overage, day(2026, 7, 4), day(2026, 6, 4), day(2026, 7, 4))) -} diff --git a/internal/account/usage/service_test.go b/internal/account/usage/service_test.go index 709fd74..04b3a48 100644 --- a/internal/account/usage/service_test.go +++ b/internal/account/usage/service_test.go @@ -40,10 +40,12 @@ type fakeStore struct { appMirrors map[uuid.UUID]usage.AppMirrorInfo // app_id → ms_billing.apps roster row (migration 027) baseSnapshots map[string]usage.AppBaseSnapshotInfo // app_id/period_start → charged-base snapshot (migration 028) - // per-module overage display (migration 033): PooledModuleCount sums the - // appMirrors' module_count (like the SQL over ms_billing.apps) — the sole - // input to GetAccountBill's steady-state account-overage estimate. - errPooledCount error + // per-module overage display (migration 033): LiveModuleTimerCountForAccount + // counts the account's live install timers — the sole input to + // GetAccountBill's steady-state account-overage estimate. The single-account + // usage fake models it as Σ module_count over its live appMirrors (one live + // timer per installed module), so display tests set counts on appMirrors. + errLiveTimerCount error // usageAppIDs is what AppIDsWithUsage enumerates (the usage half of // GetAccountBill's roster); the mirror half is DERIVED from appMirrors with @@ -188,14 +190,14 @@ func (f *fakeStore) AppBaseSnapshot(_ context.Context, appID uuid.UUID, periodSt return s, ok, nil } -// PooledModuleCount returns the account's live pooled module count: Σ -// module_count over the non-deleted appMirrors, mirroring the SQL over -// ms_billing.apps — the sole input to GetAccountBill's steady-state -// account-overage estimate (migration 033). The single-account usage fake holds -// one account's roster, so it sums every live mirror row. -func (f *fakeStore) PooledModuleCount(_ context.Context, _ uuid.UUID) (int, error) { - if f.errPooledCount != nil { - return 0, f.errPooledCount +// LiveModuleTimerCountForAccount returns the account's live install-timer count +// — the sole input to GetAccountBill's steady-state account-overage estimate +// (migration 033). The single-account usage fake models one live timer per +// installed module, so it sums module_count over the non-deleted appMirrors +// (numerically identical to counting live timer rows). +func (f *fakeStore) LiveModuleTimerCountForAccount(_ context.Context, _ uuid.UUID) (int, error) { + if f.errLiveTimerCount != nil { + return 0, f.errLiveTimerCount } sum := 0 for _, m := range f.appMirrors { diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index 4fe6904..452d3c8 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -209,12 +209,14 @@ type Store interface { // residual ledger rows still enumerate through AppIDsWithUsage). MirroredAppIDs(ctx context.Context, accountID uuid.UUID, periodStart, periodEnd time.Time) ([]uuid.UUID, error) - // PooledModuleCount returns the account-wide installed-module count: - // SUM(module_count) over the account's LIVE apps. Under the per-module- - // instance overage model (migration 033) it is the sole input to - // GetAccountBill's account-overage line, shown as the steady-state estimate - // $3 × max(0, pooled − IncludedModules) (usage.AccountOverageMicros). - PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) + // LiveModuleTimerCountForAccount returns the account's currently-live install- + // timer count (removed_at IS NULL) — the DISPLAY input to GetAccountBill's + // account-overage line under the per-module-instance overage model (migration + // 033), shown as the steady-state estimate $3 × max(0, live − IncludedModules) + // (usage.AccountOverageMicros). Reads the timer table (the overage model's + // source of truth) rather than SUM(apps.module_count), so the shown overage + // stays tied to the exact rows the charge legs tier on. + LiveModuleTimerCountForAccount(ctx context.Context, accountID uuid.UUID) (int, error) } // AppBaseSnapshotInfo is the display-read projection of a @@ -617,17 +619,18 @@ func (s *pgxStore) MirroredAppIDs(ctx context.Context, accountID uuid.UUID, peri return parseAppIDs(rows) } -// PooledModuleCount sums module_count over the account's live apps — the live -// input to GetAccountBill's steady-state account-overage estimate ($3 × max(0, -// pooled − IncludedModules)). Under the per-module-instance overage model -// (migration 033) the display no longer reads a per-period frozen snapshot; the -// live pooled estimate IS the shown account-overage line. -func (s *pgxStore) PooledModuleCount(ctx context.Context, accountID uuid.UUID) (int, error) { - sum, err := s.q.SumLiveModuleCount(ctx, accountID.String()) +// LiveModuleTimerCountForAccount counts the account's currently-live install +// timers (removed_at IS NULL) — the live input to GetAccountBill's steady-state +// account-overage estimate ($3 × max(0, live − IncludedModules)). Under the +// per-module-instance overage model (migration 033) the display reads the timer +// table (the model's source of truth) instead of SUM(apps.module_count), so the +// shown overage stays tied to the exact rows the charge legs tier on. +func (s *pgxStore) LiveModuleTimerCountForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { + n, err := s.q.CountLiveModuleTimersForAccount(ctx, accountID.String()) if err != nil { return 0, err } - return int(sum), nil + return int(n), nil } // parseAppIDs decodes a generated query's text app_id column into uuid.UUIDs, From 11472c98c5067e5bc1bb3f294a30b0931e4ab7b7 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 10:26:21 +0800 Subject: [PATCH 10/30] fix: three per-module overage rebuild review findings (D1d catch-up, combined-invoice ownership, boundary reclaim freeze) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial 3-lens review against docs-temp/account-billing-read/DESIGN.md surfaced three confirmed defects in the per-module-instance overage rebuild (migration 033). Each is fixed at root cause with a regression test that reproduces the exact failure scenario (all three fail without their fix). 1. [critical] Leg 1 module-overage sweep had no retroactive-catch-up guard (D1d). ChargeModuleOverage charged any past-grace "over" timer the instant the account activated, even when the timer's install-anchored period had already closed months earlier — exactly the retroactive base-fee catch-up D1d forbids and the sibling ChargeCreationProration already guards. Ported the same period-closed check (compare ActivatedAt against the install period's end): resolve the timer terminally with NO charge instead of minting a historical, never-chargeable invoice. New status period_closed. 2. [high] Scenario 3's one-invoice guarantee collapsed when the combined creation charge's persist phase failed AFTER its Stripe calls succeeded: the co-created over-module timers stayed unresolved, and the same-process overage sweep then minted a SECOND invoice for them under a disjoint idem key (mod-overage-inv-), mis-attributing money Stripe already pooled onto the combined invoice. Leg 1 now DEFERS a co-created over-module whose app's creation proration is still unresolved (the combined-invoice path owns it); the proration sweep's next retry converges on the same combined invoice. New status deferred_to_combined. 3. [high] The boundary advance-base/overage were recomputed live on every billing_run reclaim with no frozen snapshot — reintroducing the retry livelock ee5043c fixed once for the account-wide model (whose account_overage_snapshots freeze migration 033 dropped). A reclaimed run keeps the same deterministic idem keys (ii-/inv-), so live-state drift between a crash and the retry made it re-send those keys with a different amount — a Stripe idempotency conflict that stalls the run forever. Migration 035 adds frozen_charge_cents/with_base to billing_runs, frozen BEFORE the first Stripe call and REUSED verbatim on reclaim, so every attempt sends a byte-identical request under the stable key. Full suite green (go test ./...); sqlc regenerated for migration 035. Co-Authored-By: Claude Opus 4.8 --- internal/account/cycle/charge.go | 36 +++++++- internal/account/cycle/charge_test.go | 67 +++++++++++++++ internal/account/cycle/overage.go | 60 +++++++++++++ internal/account/cycle/overage_test.go | 80 +++++++++++++++++ internal/account/cycle/scenarios_test.go | 85 +++++++++++++++++++ internal/account/cycle/service_test.go | 46 +++++++++- internal/account/cycle/store.go | 51 +++++++++++ internal/account/db/cycle.sql.go | 50 +++++++++++ internal/account/db/models.go | 18 ++-- internal/account/db/queries/cycle.sql | 25 ++++++ .../035_billing_run_frozen_charge.down.sql | 8 ++ .../035_billing_run_frozen_charge.up.sql | 32 +++++++ 12 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 migrations/billing/035_billing_run_frozen_charge.down.sql create mode 100644 migrations/billing/035_billing_run_frozen_charge.up.sql diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index b83b693..4c11e97 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -301,20 +301,52 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } + withBase := advanceBase+advanceOverage > 0 + + // FREEZE-OR-REUSE the boundary Stripe request (crash-safe idempotency, + // migration 035). The idem keys ii-/inv- are STABLE across a reclaim + // of this run, so the request sent under them must be stable too. A prior + // attempt that already reached Stripe froze its computed (cents, withBase); + // REUSE those frozen values rather than the ones just recomputed from LIVE + // state — drift between the crash and this retry (a module uninstalled flipping + // an over-module to included, an app deleted) could have moved the live total, + // and re-sending the same idem key with a different amount/description is the + // permanent Stripe idempotency-conflict stall this guards against (the bug + // ee5043c fixed once for the account-wide model, whose freeze migration 033 + // dropped). Reconciled BEFORE the cents==0 short-circuit so a retry whose live + // total collapsed to 0 still re-charges — and records — the non-zero amount the + // crashed attempt already put through Stripe. + if frozen, ok, err := s.store.BillingRunFrozenCharge(ctx, runID); err != nil { + return nil, billing.Internal("frozen boundary charge lookup failed", err) + } else if ok { + cents = frozen.Cents + withBase = frozen.WithBase + } + if cents == 0 { // A sub-half-cent arrears total (an advance base/overage, when present, is - // always ≥ $3 and can never round to 0) — never call Stripe for $0. + // always ≥ $3 and can never round to 0) — never call Stripe for $0. The + // reuse above found no prior frozen charge (it would have set cents > 0), so + // nothing was ever put through Stripe for this run. if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero cents) failed", err) } summary.Status = RunStatusInvoiced return summary, nil } + + // Freeze the amount + description this run will charge BEFORE the first Stripe + // call. First-write-wins (a reclaim that already froze is a no-op — the reuse + // above already adopted its values), so a crash after Stripe succeeds but before + // MarkBillingRun commits leaves the frozen request durable for the retry. + if err := s.store.FreezeBillingRunCharge(ctx, runID, FrozenBoundaryCharge{Cents: cents, WithBase: withBase}); err != nil { + return nil, billing.Internal("freeze boundary charge failed", err) + } summary.ChargedCents = cents // Charge. A failure after the PM gate marks the run 'failed' (auditable) and // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, advanceBase+advanceOverage > 0) + inv, err := s.charge(ctx, runID, custID, cents, withBase) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 077f390..52b1f07 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -164,6 +164,73 @@ func TestRunBillingCycle_ChargesArrears(t *testing.T) { } } +// --- FINDING 3: a reclaimed boundary run reuses its FROZEN charge amount, never +// a freshly-recomputed live total, so the stable Stripe idem key never conflicts - + +func TestRunBillingCycle_ReclaimReusesFrozenBoundaryChargeAmount(t *testing.T) { + // Reproduces the exact failure scenario. Account X's boundary run computes + // arrears $1 + advance base $40 (2 apps) + advance overage $6 (2 ongoing + // over-modules) = $47 (4700¢), calls Stripe under ii-/inv- (the money + // moves), but crashes before MarkBillingRun commits — the run stays 'pending'. + // Before the retry a customer uninstalls one over-module, so a LIVE recompute + // would now yield only $44 (4400¢). InsertBillingRun RECLAIMS the SAME run id + // (same idem keys). Pre-fix, the retry re-sent those keys with the recomputed + // $44 — a mismatched body under a used idem key — which Stripe rejects, + // permanently stalling the run. Fixed: the retry REUSES the frozen $47 under the + // same keys and completes. + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 usage arrears + store.hasPM = true + store.stripeCustomer = "cus_f3" + + // 2 live apps created before the new period → $40 advance base. + seedApp(store, chargeAccount, 0, false) + app2 := seedApp(store, chargeAccount, 0, false) + // 5 included (ranks 0-4) + 2 ongoing over-modules already charged in a prior + // period (ranks 5-6) → overCount 2 → $6 advance overage. + seedIncluded(store, chargeAccount, app2, timeUTC(2026, 5, 1, 0), 5) + o1 := seedTimer(store, chargeAccount, app2, timeUTC(2026, 5, 10, 0)) + o2 := seedTimer(store, chargeAccount, app2, timeUTC(2026, 5, 11, 0)) + for _, id := range []uuid.UUID{o1, o2} { + store.timers[id].graceResolved = true + store.timers[id].graceCharged = true // charged in a prior period → ongoing + } + + sc := newFakeStripe() + + // FIRST attempt: Stripe charges $47, but MarkBillingRun fails (Lambda timed out + // before commit) → the run stays 'pending', the frozen $47 is durable. + store.errMarkRun = errors.New("lambda timeout before MarkBillingRun commit") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err, "the mark failed → the run is left pending, resumable") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 4700, sc.itemCalls[0].amountCfg, "$1 + $40 + 2×$3 = $47") + firstIdem := sc.itemCalls[0].idemKey + + // Between attempts: a customer uninstalls one over-module → a LIVE recompute + // would now yield overCount 1 → $44, NOT $47. + store.timers[o2].removed = true + store.timers[o2].removedAt = timeUTC(2026, 6, 15, 0) + store.errMarkRun = nil // the retry's mark succeeds + + // RETRY: reclaims the SAME run id (same idem keys). It must charge the FROZEN + // $47, never the recomputed $44 — otherwise Stripe rejects the reused key. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.True(t, resp.FirstRun, "a reclaimed non-terminal run is a fresh charge attempt") + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.Len(t, sc.itemCalls, 2) + require.Equal(t, firstIdem, sc.itemCalls[1].idemKey, + "the reclaim reuses the same run id → the same Stripe idem key") + require.EqualValues(t, 4700, sc.itemCalls[1].amountCfg, + "so the amount under that key must be the frozen $47, not the recomputed $44") + require.EqualValues(t, 4700, resp.ChargedCents) + for _, m := range store.markedRuns { + require.Equal(t, cycle.RunStatusInvoiced, m.status) + require.EqualValues(t, 4700, m.totalCents) + } +} + func TestRunBillingCycle_CentsRoundHalfUp(t *testing.T) { // 5_000 micros = 0.5 cents → round-half-up → 1 cent. store := newFakeStore() diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 65e6662..170076a 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -74,6 +74,16 @@ const ( // cents (unreachable for a real ≥1-day over module at $3) — resolved with no // charge so it never re-sweeps forever. ModuleOverageSkippedZeroCents ModuleOverageStatus = "zero_cents" + // ModuleOveragePeriodClosed: "over" but the account only activated AT OR AFTER + // this install's anchored period had already closed — charging now would be a + // retroactive catch-up (D1d), so it is resolved terminally WITH NO charge (the + // per-module analogue of ProrationStatusPeriodClosed on the creation leg). + ModuleOveragePeriodClosed ModuleOverageStatus = "period_closed" + // ModuleOverageDeferredToCombined: "over" AND co-created with an app whose + // creation proration has not yet resolved — this timer's overage belongs on the + // app's ONE combined creation invoice (scenario 3), not a separate Leg 1 one, so + // it is DEFERRED (left unresolved) for the proration sweep to charge and mark. + ModuleOverageDeferredToCombined ModuleOverageStatus = "deferred_to_combined" ) // ModuleOverageResult reports what one ChargeModuleOverage call did. @@ -130,6 +140,56 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // precharge's job (scenario 6, Stage B), which would otherwise double-bill it. anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(cand.InstalledAt.UTC(), anchorDay) + + // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration + // enforces on the creation leg, proration.go). RegisterApp synthesizes an app's + // timers at install time regardless of whether the owning account has activated + // yet, and the work-list only gates on activation at CHARGE time — so a timer + // can sit installed + past-grace for arbitrarily long while unactivated, then + // get swept the instant the account finally binds a card. If the account only + // activated AT OR AFTER this install's anchored period had already closed, the + // account was never chargeable for that whole period; charging its overage now + // — however late the sweep runs — is exactly the retroactive catch-up D1d + // forbids. Resolve the timer terminally WITHOUT charging (grace_resolved, + // first-write-wins via MarkModuleTimerIncluded) so it never resurfaces, rather + // than minting a historical, never-chargeable invoice. Compared against + // ActivatedAt, NOT `at`: an ordinary late sweep on a HEALTHY already-activated + // account (grace pushing the charge a few days past periodEnd) still charges, + // exactly like the creation leg's ActivatedBeforePeriodCloses case. + if !cand.ActivatedAt.Before(periodEnd) { + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer resolved (period closed) failed", err) + } + res.Status = ModuleOveragePeriodClosed + return res, nil + } + + // Scenario 3 combined-invoice ownership guard. A co-created over-module timer + // (installed AT the app's created_at) whose app's creation proration is still + // UNRESOLVED is the COMBINED-invoice path's responsibility (proration.go), NOT + // Leg 1's: the creation-proration charge folds this timer's overage onto the + // app's ONE creation invoice (app-inv-) using the SHARED per-timer item + // key. cmd/billing-cycle runs the proration sweep BEFORE this one so the happy + // path resolves these timers first (they never reach here). But if that sweep's + // persist phase FAILED after its Stripe calls already landed the overage item on + // the combined invoice, the timer is still unresolved when this sweep runs in + // the SAME process — and minting our OWN invoice (mod-overage-inv-) + // here would mis-attribute money Stripe already pooled onto the combined invoice + // to a second, stray invoice (or conflict outright). So DEFER (skip WITHOUT + // resolving): the proration sweep retries every cycle and converges on the SAME + // combined invoice via the deterministic keys, then marks this timer resolved, + // dropping it from this work list. A LATER install (installed_at != created_at) + // is never co-created, so it charges here normally. + app, found, err := s.store.AppMirror(ctx, cand.AppID) + if err != nil { + return nil, billing.Internal("app mirror lookup failed", err) + } + if found && cand.InstalledAt.Equal(app.CreatedAt) && + app.ProrationInvoiceID == "" && !app.ProrationSkipped && !app.Deleted { + res.Status = ModuleOverageDeferredToCombined + return res, nil + } + proratedMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) cents, err := centsFromMicros(proratedMicros) if err != nil { diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 9773423..44ddd46 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -230,6 +230,86 @@ func TestModuleOverage_UnactivatedAccountNeverSwept(t *testing.T) { require.False(t, store.timers[over].graceResolved) } +// --- FINDING 1: no retroactive catch-up (D1d) when an account only activates +// after an over-module's anchored install period already closed --------------- + +func TestModuleOverage_NoRetroactiveCatchUpWhenActivatedAfterPeriodClosed(t *testing.T) { + // Reproduces the exact failure scenario. An account installs 8 modules on an + // app while UNACTIVATED (RegisterApp synthesizes the timers regardless of + // activation). They sit installed + past-grace for months. Then the owner + // finally binds a card — with anchor day 1 (activated Apr 1), the timers' + // install-anchored period [Jan 1, Feb 1) is long closed. Pre-fix, the very next + // sweep charged the 3 "over" timers (ranks 5-7) a REAL Stripe invoice for that + // historical, never-chargeable January period — exactly the retroactive + // catch-up D1d forbids. Fixed: the over timers are resolved terminally WITH NO + // charge (period_closed), never resurface, and Stripe is never called; the 5 + // included ones resolve as included as usual. + store := newFakeStore() + _, acct := registeredAccount(store) + delete(store.activation, acct) // unactivated at install time + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + installed := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + var ids []uuid.UUID + for i := 0; i < 8; i++ { + ids = append(ids, seedTimer(store, acct, app, installed)) + } + + // While unactivated the sweep never even lists them (activated_at IS NULL gate). + res0, err := svc.SweepModuleOverage(ctx, time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res0.Pending, "unactivated → excluded from the work list") + + // MONTHS later: the owner binds a card (anchor day 1, Apr 1) + a PM. The 8 + // timers' install period [Jan 1, Feb 1) closed long before Apr 1. + store.activation[acct] = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 8, res.Pending, "now activated, all 8 past-grace timers are listed") + require.Equal(t, 0, res.Charged, "no retroactive catch-up charge for a closed period (D1d)") + require.Equal(t, 5, res.Included, "the 5 earliest installs resolve as included") + require.Equal(t, 3, res.Skipped, "the 3 over installs resolve period_closed (counted as skipped)") + require.Empty(t, sc.itemCalls, "no Stripe call for a period the account was never chargeable in") + for _, id := range ids { + require.True(t, store.timers[id].graceResolved, "every timer reached a terminal verdict") + require.False(t, store.timers[id].graceCharged, "none charged") + } + + // A later sweep never resurfaces them (permanently resolved, never re-swept). + res2, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res2.Pending, "resolved timers never resurface") + require.Empty(t, sc.itemCalls) +} + +func TestModuleOverage_ActivatedBeforePeriodClosesStillCharges(t *testing.T) { + // Guard against an over-broad fix: an over-module whose account activated + // BEFORE its install-anchored period closes must still charge normally. The + // period-closed check compares against ActivatedAt (not the sweep instant), so + // an account activated well before the install (registeredAccount: May 4) that + // is swept a few days late is NOT treated as a retroactive catch-up. + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04, anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + // 5 included + one over-module installed Jun 10 → period [Jun 4, Jul 4), which + // opened AFTER activation, so the account was chargeable for the whole window. + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "an over-module whose period opened after activation charges normally") + require.Len(t, sc.itemCalls, 1) + require.True(t, store.timers[over].graceCharged) +} + // --- SyncAppModules timer synthesis (grow + LIFO shrink + delete) ------------- func TestSyncAppModules_GrowsAndLIFOShrinksTimers(t *testing.T) { diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go index ae4f967..b2c4c32 100644 --- a/internal/account/cycle/scenarios_test.go +++ b/internal/account/cycle/scenarios_test.go @@ -18,6 +18,7 @@ package cycle_test import ( "context" + "errors" "testing" "time" @@ -159,6 +160,90 @@ func TestScenario3_PoolOverFromDayZeroOneCombinedInvoice(t *testing.T) { require.Equal(t, 7, resolved, "all 7 co-created timers reached a terminal verdict") } +// --- FINDING 2: a combined-invoice Phase-3 failure must not let Leg 1 mint a +// SECOND overage invoice for the co-created over-modules ------------------------ + +func TestScenario3_ProrationPhase3FailureDoesNotMintSecondOverageInvoice(t *testing.T) { + // Reproduces the exact failure scenario. App A is created with 7 co-created + // modules (5 included + 2 over). The combined creation charge's Stripe calls + // succeed (base + 2 overage items on ONE invoice), but its persist phase + // (Phase 3 — arm the guard + mark the co-created timers resolved) FAILS + // (deadlock / transient tx error) even though the money already moved. cmd/ + // billing-cycle then runs the overage sweep in the SAME process. Pre-fix, Leg 1 + // found the 2 still-unresolved over timers and minted a SECOND invoice for them + // (a fresh mod-overage-inv- key), mis-attributing money Stripe already + // pooled onto the combined invoice. Fixed: Leg 1 DEFERS them (they belong to the + // combined invoice), so the proration sweep's next retry converges on the SAME + // combined invoice and marks them — exactly ONE invoice, ever. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + registerMirror(t, svc, user, appID, scenarioCreatedAt, 7) + + // The combined charge's Stripe calls land; Phase 3 fails after they succeed. + store.errPersistAfterStripe = errors.New("serialization failure (deadlock)") + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err, "a per-app charge failure never aborts the sweep") + require.Equal(t, 1, pro.Failed, "App A's combined charge failed at Phase 3") + require.Equal(t, 0, pro.Charged) + require.Empty(t, store.apps[appID].ProrationInvoiceID, "guard unarmed after Phase 3 failure") + + // Stripe DID fire: base + 2 overage items on ONE combined invoice. + require.Len(t, sc.itemCalls, 3, "base + 2 co-created overage items") + require.Len(t, sc.invoiceCalls, 1) + combinedInvoiceIdem := sc.invoiceCalls[0].idemKey + require.Equal(t, "app-inv-"+appID.String(), combinedInvoiceIdem) + // Every timer is still unresolved — Phase 3 rolled back its marks. + for _, tm := range store.timers { + require.False(t, tm.graceResolved, "no timer resolved — Phase 3 rolled back") + } + + // The SAME-process overage sweep must NOT mint a second overage invoice for the + // co-created over-modules. It resolves the 5 included (harmless) and DEFERS the + // 2 over ones back to the combined-invoice path. + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "co-created over-modules are NOT re-charged by Leg 1") + require.Equal(t, 5, over.Included, "the 5 included co-created timers resolve with no charge") + require.Equal(t, 2, over.Skipped, "the 2 over co-created timers are deferred to the combined invoice") + for _, ic := range sc.invoiceCalls { + require.NotContains(t, ic.idemKey, "mod-overage-inv-", + "Leg 1 must never mint a separate invoice for a co-created over-module") + } + // The 2 over timers stay unresolved, awaiting the proration retry. + unresolvedOver := 0 + for _, tm := range store.timers { + if !tm.graceResolved { + unresolvedOver++ + } + } + require.Equal(t, 2, unresolvedOver, "the 2 over timers wait for the combined-invoice retry") + + // Clear the transient failure: the proration sweep's retry converges on the + // SAME combined invoice (deterministic idem keys) and marks the 2 over timers. + store.errPersistAfterStripe = nil + pro2, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro2.Charged, "the retry finally lands the combined charge") + armed := store.apps[appID].ProrationInvoiceID + require.NotEmpty(t, armed) + + charged := 0 + for _, tm := range store.timers { + if tm.graceCharged { + charged++ + require.Equal(t, armed, tm.graceInvoiceID, + "overage landed on the combined creation invoice, not a stray second one") + } + require.True(t, tm.graceResolved, "all 7 timers now terminally resolved") + } + require.Equal(t, 2, charged, "exactly the 2 over-modules were charged") + require.Len(t, store.invoices, 1, "exactly ONE invoice ever — the combined creation invoice") +} + // --- Scenario 4: pool crosses 5 later → two independent prorated charges ------- func TestScenario4_PoolCrossesFiveLaterPerModuleTimers(t *testing.T) { diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 348d8fa..f834253 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -77,10 +77,11 @@ type fakeStore struct { timers map[uuid.UUID]*fakeTimer // captured charge writes - insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) - runStatus map[uuid.UUID]cycle.BillingRunStatus // run id → current status (models the DB row's terminal state) - markedRuns map[uuid.UUID]markedRun // run id → terminal mark - invoices map[string]cycle.InvoiceMirror // stripe_invoice_id → mirror + insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) + runStatus map[uuid.UUID]cycle.BillingRunStatus // run id → current status (models the DB row's terminal state) + markedRuns map[uuid.UUID]markedRun // run id → terminal mark + invoices map[string]cycle.InvoiceMirror // stripe_invoice_id → mirror + frozenCharges map[uuid.UUID]cycle.FrozenBoundaryCharge // run id → frozen boundary charge (migration 035); survives a reclaim // injected errors errOpen error @@ -116,6 +117,13 @@ type fakeStore struct { errAdvanceSnap error // InsertAdvanceBaseSnapshot errPendingProration error // AppsPendingProration errChargeLocked error // ChargeProrationLocked + // errPersistAfterStripe fails ChargeProrationLocked's persist phase (Phase 3) + // AFTER the charge callback's Stripe calls already succeeded — modeling a + // combined-invoice charge whose guard/timer marks fail to commit (deadlock / + // transient tx error) even though Stripe already moved the money. + errPersistAfterStripe error + errFreezeCharge error // FreezeBillingRunCharge + errFrozenCharge error // BillingRunFrozenCharge errLiveTimerCount error // LiveModuleTimerCountForApp errInsertTimers error // InsertModuleOverageTimers @@ -178,6 +186,7 @@ func newFakeStore() *fakeStore { runStatus: map[uuid.UUID]cycle.BillingRunStatus{}, markedRuns: map[uuid.UUID]markedRun{}, invoices: map[string]cycle.InvoiceMirror{}, + frozenCharges: map[uuid.UUID]cycle.FrozenBoundaryCharge{}, apps: map[uuid.UUID]cycle.AppMirror{}, accountsByUser: map[uuid.UUID]uuid.UUID{}, activation: map[uuid.UUID]time.Time{}, @@ -372,6 +381,28 @@ func (f *fakeStore) MarkBillingRun(_ context.Context, runID uuid.UUID, status cy return nil } +// FreezeBillingRunCharge records the run's frozen boundary charge (migration 035). +// First-write-wins, mirroring the SQL's WHERE frozen_charge_cents IS NULL: a +// reclaim that already froze keeps the ORIGINAL values, so a retry can never +// overwrite the amount a crashed attempt already put through Stripe. +func (f *fakeStore) FreezeBillingRunCharge(_ context.Context, runID uuid.UUID, charge cycle.FrozenBoundaryCharge) error { + if f.errFreezeCharge != nil { + return f.errFreezeCharge + } + if _, exists := f.frozenCharges[runID]; !exists { + f.frozenCharges[runID] = charge + } + return nil +} + +func (f *fakeStore) BillingRunFrozenCharge(_ context.Context, runID uuid.UUID) (cycle.FrozenBoundaryCharge, bool, error) { + if f.errFrozenCharge != nil { + return cycle.FrozenBoundaryCharge{}, false, f.errFrozenCharge + } + c, ok := f.frozenCharges[runID] + return c, ok, nil +} + func (f *fakeStore) AccountsWithUsageEvents(_ context.Context, _, _ time.Time) ([]uuid.UUID, error) { if f.errUsageEvents != nil { return nil, f.errUsageEvents @@ -536,6 +567,13 @@ func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, ch if pc == nil { return cycle.ProrationLockedNoCharge, "", nil } + // Phase 3 failure AFTER the callback's Stripe calls already succeeded: the + // money moved, but the guard-arm + timer marks never commit. Models a + // deadlock/transient tx error so a test can prove the co-created over-module + // timers stay unresolved (and are NOT independently re-invoiced by Leg 1). + if f.errPersistAfterStripe != nil { + return 0, "", f.errPersistAfterStripe + } f.invoices[pc.Invoice.StripeInvoiceID] = pc.Invoice f.baseSnapshots[snapKey{pc.Snapshot.AppID, pc.Snapshot.PeriodStart}] = fakeBaseSnapshot{snap: pc.Snapshot, source: "proration"} app.ProrationInvoiceID = pc.InvoiceID // first-charge-wins, like WHERE … IS NULL under the lock diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 135cdea..3b7294f 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -141,6 +141,20 @@ type Store interface { // (empty → NULL), and the charged total in whole cents. MarkBillingRun(ctx context.Context, runID uuid.UUID, status BillingRunStatus, stripeInvoiceID string, totalCents int64) error + // FreezeBillingRunCharge records — BEFORE the boundary run's first Stripe + // charge — the exact amount + base/overage description determinant it will send + // under the deterministic idem keys ii-/inv- (migration 035). + // First-write-wins: a reclaimed run that already froze keeps the ORIGINAL + // values, so a retry recomputing a different LIVE total never sends Stripe a + // mismatched request under the same idem key (the boundary analogue of the + // account_overage_snapshots freeze migration 033 dropped). + FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) error + + // BillingRunFrozenCharge reads a run's frozen boundary charge; ok=false when no + // prior attempt reached the Stripe call (a fresh run). On a reclaim it is the + // amount already charged, which the retry REUSES verbatim. + BillingRunFrozenCharge(ctx context.Context, runID uuid.UUID) (charge FrozenBoundaryCharge, ok bool, err error) + // AccountsWithUnbilledUsage returns the accounts that have usage_aggregates // in a closed period window [periodStart, periodEnd) with no billing_run yet // — the work list for cmd/billing-cycle. @@ -392,6 +406,17 @@ type AccountAnchor struct { ActivatedAt time.Time } +// FrozenBoundaryCharge is the boundary run's Stripe request FROZEN before its +// first charge (migration 035): the whole-cent amount and whether the line +// includes advance base/overage (the description determinant). Both feed the +// deterministic idem keys ii-/inv-, so a reclaimed run reuses this +// frozen tuple verbatim rather than re-deriving a possibly-drifted live total — +// keeping every retry's Stripe request byte-identical under the stable key. +type FrozenBoundaryCharge struct { + Cents int64 + WithBase bool +} + // ErrAccountNotFound is returned by UpdateAccountCollection when no accounts row // matches the id (the UPDATE affected zero rows). var ErrAccountNotFound = errors.New("billing account not found") @@ -850,6 +875,32 @@ func (s *pgxStore) MarkBillingRun(ctx context.Context, runID uuid.UUID, status B }) } +func (s *pgxStore) FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) error { + // WHERE frozen_charge_cents IS NULL (in the query) makes this first-write-wins: + // a reclaimed run that already froze affects 0 rows and keeps its ORIGINAL + // frozen amount, which the caller reads back via BillingRunFrozenCharge first + // and reuses — so this only ever writes on the genuine first attempt. + return s.q.FreezeBillingRunCharge(ctx, db.FreezeBillingRunChargeParams{ + ID: runID.String(), + FrozenChargeCents: pgtype.Int8{Int64: charge.Cents, Valid: true}, + FrozenChargeWithBase: pgtype.Bool{Bool: charge.WithBase, Valid: true}, + }) +} + +func (s *pgxStore) BillingRunFrozenCharge(ctx context.Context, runID uuid.UUID) (FrozenBoundaryCharge, bool, error) { + row, err := s.q.BillingRunFrozenCharge(ctx, runID.String()) + if err != nil { + return FrozenBoundaryCharge{}, false, err + } + if !row.FrozenChargeCents.Valid { + return FrozenBoundaryCharge{}, false, nil // fresh run — no prior attempt froze + } + return FrozenBoundaryCharge{ + Cents: row.FrozenChargeCents.Int64, + WithBase: row.FrozenChargeWithBase.Bool, + }, true, nil +} + func (s *pgxStore) AccountsWithUsageEvents(ctx context.Context, periodStart, periodEnd time.Time) ([]uuid.UUID, error) { rows, err := s.q.AccountsWithUsageEvents(ctx, db.AccountsWithUsageEventsParams{ RecordedAt: periodStart, diff --git a/internal/account/db/cycle.sql.go b/internal/account/db/cycle.sql.go index 73ec675..d4f5202 100644 --- a/internal/account/db/cycle.sql.go +++ b/internal/account/db/cycle.sql.go @@ -252,6 +252,56 @@ func (q *Queries) ActivatedAccounts(ctx context.Context) ([]ActivatedAccountsRow return items, nil } +const billingRunFrozenCharge = `-- name: BillingRunFrozenCharge :one +SELECT frozen_charge_cents, frozen_charge_with_base +FROM ms_billing.billing_runs +WHERE id = $1 +` + +type BillingRunFrozenChargeRow struct { + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` +} + +// BillingRunFrozenCharge reads a run's frozen boundary-charge amount + description +// determinant (set by a prior attempt's FreezeBillingRunCharge). Both are NULL +// when no prior attempt reached the Stripe charge (a fresh run), so the caller +// freezes the freshly-computed values and charges them; on a reclaim they are the +// amount already charged, which the retry REUSES verbatim. +func (q *Queries) BillingRunFrozenCharge(ctx context.Context, id string) (BillingRunFrozenChargeRow, error) { + row := q.db.QueryRow(ctx, billingRunFrozenCharge, id) + var i BillingRunFrozenChargeRow + err := row.Scan(&i.FrozenChargeCents, &i.FrozenChargeWithBase) + return i, err +} + +const freezeBillingRunCharge = `-- name: FreezeBillingRunCharge :exec +UPDATE ms_billing.billing_runs +SET frozen_charge_cents = $2, + frozen_charge_with_base = $3 +WHERE id = $1 + AND frozen_charge_cents IS NULL +` + +type FreezeBillingRunChargeParams struct { + ID string `json:"id"` + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` +} + +// FreezeBillingRunCharge records, BEFORE the boundary Stripe charge, the exact +// whole-cent amount AND the base/overage description determinant this run will +// send Stripe under the deterministic idem keys ii- / inv- (migration +// 035). First-write-wins (WHERE frozen_charge_cents IS NULL): a reclaimed run that +// already froze keeps its ORIGINAL values, so a retry that recomputed a different +// LIVE total can never send Stripe a mismatched request under the same idem key. +// InsertBillingRun's ON CONFLICT DO UPDATE deliberately leaves these columns +// untouched, so the freeze survives the reclaim. +func (q *Queries) FreezeBillingRunCharge(ctx context.Context, arg FreezeBillingRunChargeParams) error { + _, err := q.db.Exec(ctx, freezeBillingRunCharge, arg.ID, arg.FrozenChargeCents, arg.FrozenChargeWithBase) + return err +} + const hasUsableDefaultPM = `-- name: HasUsableDefaultPM :one SELECT EXISTS ( SELECT 1 diff --git a/internal/account/db/models.go b/internal/account/db/models.go index 5fde5a1..e3c1734 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -394,14 +394,16 @@ type MsBillingBillingPeriod struct { } type MsBillingBillingRun struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Status string `json:"status"` - StripeInvoiceID pgtype.Text `json:"stripe_invoice_id"` - TotalAmount pgtype.Numeric `json:"total_amount"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Status string `json:"status"` + StripeInvoiceID pgtype.Text `json:"stripe_invoice_id"` + TotalAmount pgtype.Numeric `json:"total_amount"` + CreatedAt time.Time `json:"created_at"` + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` } type MsBillingBudget struct { diff --git a/internal/account/db/queries/cycle.sql b/internal/account/db/queries/cycle.sql index b95ee4b..b811b4a 100644 --- a/internal/account/db/queries/cycle.sql +++ b/internal/account/db/queries/cycle.sql @@ -162,6 +162,31 @@ SET status = $2, total_amount = $4 WHERE id = $1; +-- FreezeBillingRunCharge records, BEFORE the boundary Stripe charge, the exact +-- whole-cent amount AND the base/overage description determinant this run will +-- send Stripe under the deterministic idem keys ii- / inv- (migration +-- 035). First-write-wins (WHERE frozen_charge_cents IS NULL): a reclaimed run that +-- already froze keeps its ORIGINAL values, so a retry that recomputed a different +-- LIVE total can never send Stripe a mismatched request under the same idem key. +-- InsertBillingRun's ON CONFLICT DO UPDATE deliberately leaves these columns +-- untouched, so the freeze survives the reclaim. +-- name: FreezeBillingRunCharge :exec +UPDATE ms_billing.billing_runs +SET frozen_charge_cents = $2, + frozen_charge_with_base = $3 +WHERE id = $1 + AND frozen_charge_cents IS NULL; + +-- BillingRunFrozenCharge reads a run's frozen boundary-charge amount + description +-- determinant (set by a prior attempt's FreezeBillingRunCharge). Both are NULL +-- when no prior attempt reached the Stripe charge (a fresh run), so the caller +-- freezes the freshly-computed values and charges them; on a reclaim they are the +-- amount already charged, which the retry REUSES verbatim. +-- name: BillingRunFrozenCharge :one +SELECT frozen_charge_cents, frozen_charge_with_base +FROM ms_billing.billing_runs +WHERE id = $1; + -- UpsertInvoice mirrors a Stripe invoice into ms_billing.invoices, keyed on the -- UNIQUE stripe_invoice_id so a re-run (deterministic Stripe Idempotency-Key -- returns the same invoice) upserts the same row rather than duplicating it. diff --git a/migrations/billing/035_billing_run_frozen_charge.down.sql b/migrations/billing/035_billing_run_frozen_charge.down.sql new file mode 100644 index 0000000..f54d046 --- /dev/null +++ b/migrations/billing/035_billing_run_frozen_charge.down.sql @@ -0,0 +1,8 @@ +-- Down migration 035 — drop the boundary-charge freeze columns. Both are pure +-- additive nullable columns (frozen per billing_run before its first Stripe +-- charge), so dropping them fully reverts the migration; up/down/up round-trips +-- cleanly. + +ALTER TABLE ms_billing.billing_runs + DROP COLUMN IF EXISTS frozen_charge_cents, + DROP COLUMN IF EXISTS frozen_charge_with_base; diff --git a/migrations/billing/035_billing_run_frozen_charge.up.sql b/migrations/billing/035_billing_run_frozen_charge.up.sql new file mode 100644 index 0000000..4df90d7 --- /dev/null +++ b/migrations/billing/035_billing_run_frozen_charge.up.sql @@ -0,0 +1,32 @@ +-- Migration 035 — freeze the boundary charge amount per billing_run (crash-safe +-- idempotency for the base-fee/overage boundary leg, DESIGN.md "Base fee — v2", +-- scenario 6 / Leg 2). +-- +-- The boundary charge (cycle/charge.go RunBillingCycle) computes its total from +-- LIVE state — Σ live apps' base + a live count of ongoing over-modules — and +-- charges it under two DETERMINISTIC Stripe Idempotency-Keys derived from the +-- billing_run id (ii- / inv-). InsertBillingRun RECLAIMS a non-terminal +-- ('pending'/'failed'/'skipped_*') run under the SAME id, so a re-fire keeps the +-- SAME idem keys. But if the live state shifts between a crash (Stripe already +-- charged, MarkBillingRun not yet committed) and the retry — a module uninstalled +-- flipping an over-module to included, an app deleted — the retry RECOMPUTES a +-- DIFFERENT amount and re-sends the SAME idem key with different parameters, which +-- Stripe REJECTS (idempotency-key reuse with a mismatched body), permanently +-- stalling the run ('failed' every cycle) even though the account was already +-- charged. +-- +-- This is the exact bug class the superseded account-wide model fixed once (commit +-- ee5043c) via account_overage_snapshots' pending/charged freeze — which migration +-- 033 DROPPED with no replacement for the boundary leg. This column is that +-- replacement, scoped to the boundary run: the amount (and the base/overage +-- description determinant) is FROZEN here BEFORE the first Stripe call, and a +-- reclaimed run REUSES the frozen values instead of the freshly-recomputed ones, +-- so every attempt sends Stripe a byte-identical request under the stable idem key. +-- +-- Both columns are NULL until a run reaches its first boundary Stripe charge; a +-- zero-charge / skipped run never freezes. InsertBillingRun's ON CONFLICT DO UPDATE +-- deliberately does NOT touch these columns, so the freeze SURVIVES a reclaim. + +ALTER TABLE ms_billing.billing_runs + ADD COLUMN IF NOT EXISTS frozen_charge_cents BIGINT NULL, + ADD COLUMN IF NOT EXISTS frozen_charge_with_base BOOLEAN NULL; From ff0abd37a4ed50a47ffb15040e88f82e08554e74 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 10:46:47 +0800 Subject: [PATCH 11/30] refactor: dedupe PM-gate/customer resolution and D1d period-closed check across charge legs Two duplication findings from the /simplify pass, each independently confirmed by two review agents (altitude + reuse/simplification): - resolveChargeableCustomer (service.go): the "no usable PM -> skip" + "resolve Stripe customer, empty id is an anomaly" sequence was copy-pasted verbatim across RunBillingCycle (charge.go), ChargeCreationProration (proration.go), and ChargeModuleOverage (overage.go). Extracted to one shared helper; each call site keeps its own skip-status semantics. - periodClosedByActivation (service.go): the D1d no-retroactive-catch-up decision (anchored period containing an instant, compared against the account's activation anchor) was independently reimplemented in both ChargeCreationProration and ChargeModuleOverage, with comments in each explicitly cross-referencing the other as "the same posture." Extracted the pure decision into one helper; each leg keeps its own what-to-persist-when-closed tail. No behavior change. go build/vet/test/gofmt all clean. Co-Authored-By: Claude Opus 4.8 --- internal/account/cycle/charge.go | 20 ++++---------- internal/account/cycle/overage.go | 20 ++++---------- internal/account/cycle/proration.go | 19 ++++--------- internal/account/cycle/service.go | 42 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 4c11e97..5d04c89 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -252,12 +252,13 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return summary, nil } - // No usable default PM: skip (usage RETAINED), re-attempt next cycle. - hasPM, err := s.store.HasUsableDefaultPM(ctx, accountID) + // No usable default PM (or the usable-PM-implies-Customer anomaly): skip + // (usage RETAINED), re-attempt next cycle. + custID, ok, err := s.resolveChargeableCustomer(ctx, accountID) if err != nil { - return nil, billing.Internal("usable PM check failed", err) + return nil, err } - if !hasPM { + if !ok { if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedNoPM, "", 0); err != nil { return nil, billing.Internal("mark billing run (skipped_no_pm) failed", err) } @@ -265,17 +266,6 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return summary, nil } - custID, err := s.store.AccountStripeCustomer(ctx, accountID) - if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) - } - if custID == "" { - // A usable PM implies a Stripe Customer (a card can't attach without - // one). An empty id here is an anomaly — surface it; never auto-create a - // Customer on the charge path. - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) - } - // Resolve the NEW period's window for the base snapshots BEFORE any Stripe // call (fail early on a lookup error). periodEnd is always the anchored // boundary (the straddle-clamp only ever moves the START), so the new diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 170076a..59795be 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -41,7 +41,6 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" - "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) // moduleOverageGraceWindow is the per-install grace window: a module's own timer @@ -138,8 +137,7 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // strictly within the install period, so a grace that straddles a period // boundary never charges the NEXT period here — that period is the boundary // precharge's job (scenario 6, Stage B), which would otherwise double-bill it. - anchorDay := billingperiod.AnchorDay(cand.ActivatedAt) - periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(cand.InstalledAt.UTC(), anchorDay) + periodStart, periodEnd, closed := periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration // enforces on the creation leg, proration.go). RegisterApp synthesizes an app's @@ -156,7 +154,7 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // ActivatedAt, NOT `at`: an ordinary late sweep on a HEALTHY already-activated // account (grace pushing the charge a few days past periodEnd) still charges, // exactly like the creation leg's ActivatedBeforePeriodCloses case. - if !cand.ActivatedAt.Before(periodEnd) { + if closed { if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { return nil, billing.Internal("mark module timer resolved (period closed) failed", err) } @@ -208,22 +206,14 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // PM gate (same posture as the proration leg): no usable PM → skip WITHOUT // resolving, re-attempted next sweep (the per-timer idem keys stay stable). - hasPM, err := s.store.HasUsableDefaultPM(ctx, cand.AccountID) + custID, ok, err := s.resolveChargeableCustomer(ctx, cand.AccountID) if err != nil { - return nil, billing.Internal("usable PM check failed", err) + return nil, err } - if !hasPM { + if !ok { res.Status = ModuleOverageSkippedNoPM return res, nil } - custID, err := s.store.AccountStripeCustomer(ctx, cand.AccountID) - if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) - } - if custID == "" { - // A usable PM implies a Customer (same anomaly posture as the spine). - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) - } // Charge via a per-timer invoice with deterministic idem keys derived from the // timer id (the stable charge identity — each install charges at most once, diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index d12bfbc..fbc0274 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -227,8 +227,7 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) // (an app created near its period boundary) — that is expected, intended // delayed billing (still the ONLY leg that ever bills this period), not a // retroactive catch-up, and must still charge normally. - _, periodEnd := billingperiod.AnchoredPeriodWindow(app.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) - if !activatedAt.Before(periodEnd) { + if _, _, closed := periodClosedByActivation(app.CreatedAt, activatedAt); closed { if err := s.store.SetAppProrationSkipped(ctx, appID); err != nil { return nil, billing.Internal("mark proration permanently skipped failed", err) } @@ -241,23 +240,15 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) // account was ever ineligible for this specific period, so it stays a // transient, retried skip rather than a permanent one — see the judgment // call noted in the PR description for the limits of this). - hasPM, err := s.store.HasUsableDefaultPM(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("usable PM check failed", err) - } - if !hasPM { - return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil - } if s.stripe == nil { return nil, billing.Internal("ChargeCreationProration requires a Stripe client", nil) } - custID, err := s.store.AccountStripeCustomer(ctx, app.AccountID) + custID, ok, err := s.resolveChargeableCustomer(ctx, app.AccountID) if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) + return nil, err } - if custID == "" { - // A usable PM implies a Customer (same anomaly posture as the spine). - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) + if !ok { + return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil } // The charge callback runs AFTER the row lock is released (ChargeProrationLocked, diff --git a/internal/account/cycle/service.go b/internal/account/cycle/service.go index 62d4710..ac67432 100644 --- a/internal/account/cycle/service.go +++ b/internal/account/cycle/service.go @@ -9,6 +9,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) @@ -33,6 +34,47 @@ func NewService(store Store, stripe billingstripe.Client) *Service { return &Service{store: store, stripe: stripe, nowFn: time.Now} } +// periodClosedByActivation is the D1d no-retroactive-catch-up decision shared +// by the creation-proration leg and the module-overage leg: given the anchored +// period CONTAINING anchorInstant (an app's created_at, or a module timer's +// installed_at) resolved against the account's activation anchor, report +// whether the account only activated AT OR AFTER that period's end — i.e. was +// never chargeable for the period's ENTIRE duration, so charging now (however +// late the sweep runs) would be retroactive catch-up. Compared against +// activatedAt, NOT "now": ordinary sweep/grace delay pushing a charge a few +// days past periodEnd for an already-activated account is expected, not +// retroactive, and must still charge normally — only the caller decides what +// to persist when closed=true (each leg's own permanent-skip marker). +func periodClosedByActivation(anchorInstant, activatedAt time.Time) (periodStart, periodEnd time.Time, closed bool) { + anchorDay := billingperiod.AnchorDay(activatedAt) + periodStart, periodEnd = billingperiod.AnchoredPeriodWindow(anchorInstant.UTC(), anchorDay) + return periodStart, periodEnd, !activatedAt.Before(periodEnd) +} + +// resolveChargeableCustomer is the PM-gate + Stripe-customer resolution shared +// by all three charge legs (RunBillingCycle, ChargeCreationProration, +// ChargeModuleOverage): no usable default PM -> ok=false (the caller's own +// skip status, transient/retried); a usable PM implies a Stripe Customer (a +// card can't attach without one) -> an empty custID here is an anomaly, +// surfaced as an error rather than silently auto-creating a Customer. +func (s *Service) resolveChargeableCustomer(ctx context.Context, accountID uuid.UUID) (custID string, ok bool, err error) { + hasPM, err := s.store.HasUsableDefaultPM(ctx, accountID) + if err != nil { + return "", false, billing.Internal("usable PM check failed", err) + } + if !hasPM { + return "", false, nil + } + custID, err = s.store.AccountStripeCustomer(ctx, accountID) + if err != nil { + return "", false, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return "", false, billing.Internal("account has a usable PM but no Stripe customer id", nil) + } + return custID, true, nil +} + // WithNow overrides the Service clock — deterministic-test hook only (mirrors // the usage.Service nowFn seam). Returns the Service for chaining. func (s *Service) WithNow(now func() time.Time) *Service { From 889fc93509256148d5bece10db50b76f9435b9cc Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:21:23 +0800 Subject: [PATCH 12/30] fix: persist IsLargeAutoCollect on the combined creation invoice persistProrationCharge built its UpsertInvoiceParams without the IsLargeAutoCollect field, so every scenario-2/3 creation invoice was written with is_large_auto_collect = false in production regardless of what the charge callback computed (the unit fakes carry the struct through verbatim, which is why scenario 5a stayed green). Carry the flag through, with an integration regression test against the real pgx persist path. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/store.go | 4 +++ .../store_prorationlock_integration_test.go | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3b7294f..61c9290 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -1151,6 +1151,10 @@ func (s *pgxStore) persistProrationCharge(ctx context.Context, appID uuid.UUID, Currency: pc.Invoice.Currency, PeriodStart: pgtype.Timestamptz{Time: pc.Invoice.PeriodStart, Valid: !pc.Invoice.PeriodStart.IsZero()}, PeriodEnd: pgtype.Timestamptz{Time: pc.Invoice.PeriodEnd, Valid: !pc.Invoice.PeriodEnd.IsZero()}, + // Scenario 5 — the disclosure flag the charge callback computed for the FULL + // combined debit (base + co-created overage lines). Dropping it here would + // silently write false for every creation/combined invoice. + IsLargeAutoCollect: pc.Invoice.IsLargeAutoCollect, }); err != nil { return 0, "", err } diff --git a/internal/account/cycle/store_prorationlock_integration_test.go b/internal/account/cycle/store_prorationlock_integration_test.go index 8c4a252..11a75cd 100644 --- a/internal/account/cycle/store_prorationlock_integration_test.go +++ b/internal/account/cycle/store_prorationlock_integration_test.go @@ -140,3 +140,36 @@ func TestChargeProrationLocked_Integration_ConcurrentDeleteDoesNotBlockOnLock(t require.True(t, app.Deleted, "the delete committed") require.Equal(t, "in_slow_del", app.ProrationInvoiceID, "the already-succeeded Stripe charge is still recorded, not silently dropped") } + +// Regression (review 2026-07-06): persistProrationCharge dropped the +// IsLargeAutoCollect field from its UpsertInvoiceParams, so the combined +// creation invoice (scenario 3/5a) was ALWAYS persisted with +// is_large_auto_collect = false in production even when the charge callback +// computed true — the unit suite never caught it because the fake store +// carries the InvoiceMirror struct through verbatim. Assert against the REAL +// pgx persist path. +func TestChargeProrationLocked_Integration_PersistsLargeAutoCollectFlag(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + 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 + + outcome, invID, err := store.ChargeProrationLocked(ctx, appID, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + return pc, nil + }) + require.NoError(t, err) + require.Equal(t, cycle.ProrationLockedCharged, outcome) + require.Equal(t, "in_large_flag", invID) + + var flagged bool + require.NoError(t, pool.QueryRow(ctx, + `SELECT is_large_auto_collect FROM ms_billing.invoices WHERE stripe_invoice_id = $1`, + "in_large_flag").Scan(&flagged)) + require.True(t, flagged, "the charge callback's IsLargeAutoCollect must survive the pgx persist path") +} From 966e03a987a1906f3bb202607e32b211963fe7c9 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:29:46 +0800 Subject: [PATCH 13/30] fix: rewrite the Leg-2 ongoing-over-module predicate (double-charge + never-billed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CountOngoingOverModuleTimers counted every live over-rank timer with grace_charged_at IS NOT NULL. Three defects in one predicate: - No installed_at < period_end cutoff (the advance-base leg has exactly this cutoff for exactly this reason): a module installed INSIDE the new period, already covered by its own Leg 1 grace charge, was precharged a second full $3 when a skipped_no_pm/failed boundary run was reclaimed after the grace charge fired — repro'd in review with a unit test against the PR's own fakes. - grace_charged_at as the 'ongoing' proxy permanently exempted every D1d period-closed-resolved over-module (installed pre-activation, resolved uncharged via MarkModuleTimerIncluded) from ALL boundary precharges — their $3/period overage was never billed, forever. - No grace_expires_at < period_end cutoff: prerequisite for the boundary-straddling-grace coverage rule (Leg 1 covers install through the end of the period its grace elapses into; the follow-up commit extends Leg 1 to actually charge that coverage). New predicate: live, over-rank, grace_resolved, installed_at < period_end, grace_expires_at < period_end. Unit regressions for all three + SQL-level integration coverage of each cutoff. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge.go | 15 +++- .../cycle/migration033_integration_test.go | 54 +++++++++++-- internal/account/cycle/scenarios_test.go | 77 +++++++++++++++++++ internal/account/cycle/service_test.go | 9 ++- internal/account/cycle/store.go | 16 ++-- internal/account/db/module_timers.sql.go | 48 ++++++++---- internal/account/db/queries/module_timers.sql | 41 +++++++--- 7 files changed, 212 insertions(+), 48 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 5d04c89..11f79d6 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -160,10 +160,17 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // continues into the new one. It is billed FULL (not prorated — the module // exists for the whole new period), on the SAME boundary invoice as arrears + // base, guarded by the SAME billing_run idempotency (keyed per-run, decided - // per-module-row now). A timer still inside its OWN grace (grace_charged_at - // NULL) is EXCLUDED here — it stays purely on Leg 1's independent timer and is - // never double-counted at the boundary. Empty/pre-backfill → 0. - overCount, err := s.store.CountOngoingOverModuleTimers(ctx, accountID, usage.IncludedModules) + // per-module-row now). The coverage contract with the grace legs (review + // 2026-07-06) — a timer counts iff installed_at < periodEnd (installed before + // the new period opened; the same cutoff the advance-base leg applies, without + // which a reclaimed skipped_no_pm/failed run double-bills a module whose own + // grace charge already covered the new period), grace_expires_at < periodEnd + // (a boundary-straddling grace's new period is Leg 1's coverage, never this + // precharge's), and grace_resolved (charged — or resolved-uncharged under the + // D1d period-closed posture, which forgives only the pre-activation install + // period, never the periods after; the old grace_charged_at proxy exempted + // those modules from ALL overage billing forever). Empty/pre-backfill → 0. + overCount, err := s.store.CountOngoingOverModuleTimers(ctx, accountID, usage.IncludedModules, periodEnd) if err != nil { return nil, billing.Internal("ongoing over-module timer count failed", err) } diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 5244210..bdd3da3 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -130,16 +130,19 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.NoError(t, err) require.Len(t, over, 2, "7 co-created → 2 over the included 5") - // Before any charge, none are "ongoing" (grace_charged_at all NULL). - ongoing, err := store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + // The boundary that opens the account's [Jul 4, Aug 4) period (anchor day 4). + newPeriodStart := mustTime(t, "2026-07-04T00:00:00Z") + + // Before any charge, none are "ongoing" (grace_resolved all false). + ongoing, err := store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) - require.Zero(t, ongoing, "nothing charged yet → no ongoing over-module") + require.Zero(t, ongoing, "nothing resolved yet → no ongoing over-module") // Charge the 2 co-created over-modules (scenario 3) → they become "ongoing". for _, id := range over { require.NoError(t, store.MarkModuleTimerCharged(ctx, id, created.AddDate(0, 0, 3), "in_x", "ii_x")) } - ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) require.Equal(t, 2, ongoing, "the 2 charged over-modules are ongoing") // And they're no longer co-created candidates (grace_resolved now true). @@ -147,14 +150,49 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.NoError(t, err) require.Empty(t, over) - // appB adds one LATER over-module (rank 7) still in its own grace (uncharged): - // live count rises to 8, but the ongoing count stays 2 (uncharged excluded). + // installed_at < period_end cutoff (review 2026-07-06, H1): at a RECLAIMED + // earlier boundary (new period opening Jun 4, BEFORE these Jun-19 installs) + // the charged over-modules must NOT be counted — their own grace charge + // covered the period they were installed into; precharging it again is the + // reclaimed skipped_no_pm/failed-run double-charge. + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, mustTime(t, "2026-06-04T00:00:00Z")) + require.NoError(t, err) + require.Zero(t, ongoing, "a module installed inside the new period is never precharged for it") + + // appB adds one LATER over-module (rank 7) still in its own grace (unresolved): + // live count rises to 8, but the ongoing count stays 2 (unresolved excluded). late := mustTime(t, "2026-06-28T00:00:00Z") require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, late, late.AddDate(0, 0, 3), 1)) liveN, err = usageStore.LiveModuleTimerCountForAccount(ctx, acct) require.NoError(t, err) require.Equal(t, 8, liveN) - ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 2, ongoing, "the in-grace (unresolved) over-module is NOT ongoing") + + // D1d resolved-WITHOUT-charge (review 2026-07-06, C1): resolving the rank-7 + // timer uncharged (the period-closed posture) must still make it "ongoing" + // from the next boundary on — the old grace_charged_at IS NOT NULL proxy + // exempted such modules from ALL overage billing forever. + lateCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-02T00:00:00Z")) + require.NoError(t, err) + require.Len(t, lateCands, 1, "only the rank-7 timer is still unresolved") + require.NoError(t, store.MarkModuleTimerIncluded(ctx, lateCands[0].ID)) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 3, ongoing, "a resolved-uncharged (D1d) over-module still owes every later period") + + // grace_expires_at < period_end cutoff (review 2026-07-06, M1): a charged + // over-module whose grace STRADDLES the boundary (installed Jul 2, expiry + // Jul 5 >= Jul 4) is excluded — its own Leg 1 charge covers the new period + // (coverage runs through the END of the period its grace elapses into). + straddle := mustTime(t, "2026-07-02T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, straddle, straddle.AddDate(0, 0, 3), 1)) + straddleCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-06T00:00:00Z")) + require.NoError(t, err) + require.Len(t, straddleCands, 1) + require.NoError(t, store.MarkModuleTimerCharged(ctx, straddleCands[0].ID, mustTime(t, "2026-07-05T00:00:00Z"), "in_straddle", "ii_straddle")) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) - require.Equal(t, 2, ongoing, "the in-grace (uncharged) over-module is NOT ongoing") + require.Equal(t, 3, ongoing, "a boundary-straddling grace is Leg 1's coverage, never the precharge's") } diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go index b2c4c32..6f043fa 100644 --- a/internal/account/cycle/scenarios_test.go +++ b/internal/account/cycle/scenarios_test.go @@ -393,3 +393,80 @@ func TestScenario6_BoundaryPrechargesOngoingOverModulesOnly(t *testing.T) { require.EqualValues(t, 2_700, resp.ChargedCents) require.EqualValues(t, 2_700, sc.itemCalls[0].amountCfg) } + +// Regression (review 2026-07-06, H1): a module installed INSIDE the new period +// whose own grace already elapsed and was charged by Leg 1 (install-anchored, +// covering that same new period) must NOT be precharged again by a late/reclaimed +// boundary run — the advance-overage leg needs the same installed_at < periodEnd +// cutoff the advance-base leg has always had. +func TestScenario6_ReclaimedBoundaryNeverPrechargesInsidePeriodModule(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jul 2 — INSIDE the new period [Jul 1, Aug 1) — grace elapsed Jul 5 + // and Leg 1 charged it (prorated Jul 2 → Aug 1, i.e. covering the new period). + inside := seedTimer(store, chargeAccount, app, timeUTC(2026, 7, 2, 0)) + store.timers[inside].graceResolved = true + store.timers[inside].graceCharged = true + + // The delayed/reclaimed [Jun 1, Jul 1) boundary run executes on Jul 6. + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceOverageMicros, + "a module installed inside the new period was already covered by its own grace charge — precharging it again double-bills the period") +} + +// Regression (review 2026-07-06, C1): an over-module resolved WITHOUT charge +// under the D1d period-closed posture (installed pre-activation, so its own +// install period is forgiven) still owes overage for every post-activation +// period. The old grace_charged_at IS NOT NULL predicate exempted such modules +// from ALL boundary precharges, forever. +func TestScenario6_D1dResolvedUnchargedOverModuleStillPrecharged(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_c1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Over-rank timer resolved terminally with NO charge (the D1d posture): + // grace_resolved = true, grace_charged_at never set. + d1d := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 10, 0)) + store.timers[d1d].graceResolved = true + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "a D1d resolved-uncharged over-module is ongoing — only its pre-activation install period is forgiven, not every period after") +} + +// Regression (review 2026-07-06, M1 boundary side): a charged over-module whose +// grace STRADDLES the boundary is excluded from the precharge — its own Leg 1 +// charge covers through the END of the period its grace elapses into, so the +// precharge counting it would double-bill, and Leg 1's coverage means skipping +// it leaves no gap. +func TestScenario6_StraddlingGraceExcludedFromPrecharge(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_m1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jun 29 → grace expires Jul 2, past the Jul 1 boundary. Already + // charged by (a delayed) Leg 1 covering install → end of the period the grace + // elapsed into. + straddle := seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 29, 0)) + store.timers[straddle].graceResolved = true + store.timers[straddle].graceCharged = true + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceOverageMicros, + "a boundary-straddling grace is Leg 1's coverage — the precharge must not double-bill the new period") +} diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index f834253..79132f4 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -800,15 +800,18 @@ func (f *fakeStore) liveTimersForAccountFIFO(accountID uuid.UUID) []*fakeTimer { return out } -func (f *fakeStore) CountOngoingOverModuleTimers(_ context.Context, accountID uuid.UUID, includedModules int) (int, error) { +func (f *fakeStore) CountOngoingOverModuleTimers(_ context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) { if f.errCountOngoingOver != nil { return 0, f.errCountOngoingOver } // Live FIFO: the first includedModules are "included"; the rest are "over". - // Count the "over" tail that has already been charged at least once. + // Count the "over" tail owed a precharge for the new period opening at + // periodEnd: installed before it, grace elapsed before it, verdict resolved + // (charged or D1d resolved-uncharged) — mirroring the SQL predicate. n := 0 for rank, t := range f.liveTimersForAccountFIFO(accountID) { - if rank >= includedModules && t.graceCharged { + if rank >= includedModules && t.graceResolved && + t.installedAt.Before(periodEnd) && t.graceExpiresAt.Before(periodEnd) { n++ } } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 61c9290..f4fcb14 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -322,11 +322,14 @@ type Store interface { MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error // CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario - // 6): the count of the account's live timers that are BOTH "over" (live-FIFO - // rank >= includedModules) AND already charged at least once (an ongoing - // over-module continuing into the new period). A timer still in its own grace - // (never charged) is excluded — it stays on Leg 1's timer, never double-counted. - CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int) (int, error) + // 6): the count of the account's live timers that are "over" (live-FIFO rank + // >= includedModules) AND owed a full precharge for the NEW period opening at + // periodEnd — installed before it, grace elapsed before it (a straddling + // grace's new period is Leg 1's coverage), and grace terminally resolved + // (charged, OR resolved-uncharged via the D1d period-closed posture — those + // still owe every post-activation period). See the query comment for the full + // coverage contract. + CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) // CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the // ids of an app's live, unresolved install timers whose install instant equals @@ -1401,10 +1404,11 @@ func (s *pgxStore) MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID }) } -func (s *pgxStore) CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int) (int, error) { +func (s *pgxStore) CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) { n, err := s.q.CountOngoingOverModuleTimers(ctx, db.CountOngoingOverModuleTimersParams{ AccountID: accountID.String(), IncludedModules: int32(includedModules), //nolint:gosec // includedModules is the small IncludedModules const (5) + PeriodEnd: periodEnd, }) if err != nil { return 0, err diff --git a/internal/account/db/module_timers.sql.go b/internal/account/db/module_timers.sql.go index 358eb36..24bf2c7 100644 --- a/internal/account/db/module_timers.sql.go +++ b/internal/account/db/module_timers.sql.go @@ -94,34 +94,52 @@ func (q *Queries) CountLiveModuleTimersForAccount(ctx context.Context, accountID const countOngoingOverModuleTimers = `-- name: CountOngoingOverModuleTimers :one SELECT COALESCE(count(*), 0)::bigint AS over_count FROM ( - SELECT grace_charged_at, + SELECT installed_at, grace_expires_at, grace_resolved, row_number() OVER (ORDER BY installed_at, id) AS rn FROM ms_billing.app_module_overage_timers WHERE account_id = $1::uuid AND removed_at IS NULL ) ranked WHERE rn > $2::int - AND grace_charged_at IS NOT NULL + AND grace_resolved = true + AND installed_at < $3::timestamptz + AND grace_expires_at < $3::timestamptz ` type CountOngoingOverModuleTimersParams struct { - AccountID string `json:"account_id"` - IncludedModules int32 `json:"included_modules"` + AccountID string `json:"account_id"` + IncludedModules int32 `json:"included_modules"` + PeriodEnd time.Time `json:"period_end"` } // CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): -// the count of the account's currently-live install timers that are BOTH "over" -// (live-FIFO rank >= included) AND already charged at least once (grace_charged_at -// IS NOT NULL) — i.e. ongoing over-modules continuing into the new period, each -// owed a FULL $3 precharge on the boundary invoice. row_number() over the whole -// live set gives every live timer its 1-based FIFO rank; rn > @included_modules is -// exactly the 0-based rank >= included ("over") predicate. A timer whose grace has -// not elapsed yet (grace_charged_at IS NULL) is EXCLUDED — it stays on Leg 1's own -// timer and is never double-counted here. "over" is re-derived LIVE, so a charged -// timer that has since flipped to "included" (an earlier install removed) is not -// counted. +// the count of the account's currently-live install timers that are "over" +// (live-FIFO rank >= included) AND owed a FULL $3 precharge for the NEW period +// [@period_end, next boundary) — ongoing over-modules continuing into it. +// row_number() over the whole live set gives every live timer its 1-based FIFO +// rank; rn > @included_modules is exactly the 0-based rank >= included ("over") +// predicate. "over" is re-derived LIVE, so a charged timer that has since flipped +// to "included" (an earlier install removed) is not counted. +// +// The coverage contract with the grace legs (review 2026-07-06) — a timer is +// "ongoing" for the new period iff ALL of: +// - installed_at < @period_end — it existed before the new period opened. A +// module installed INSIDE the new period had that period covered by its OWN +// grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base +// leg applies via LiveAppsCreatedBefore; without it a reclaimed +// skipped_no_pm/failed boundary run double-bills the period. +// - grace_expires_at < @period_end — its grace elapsed BEFORE the new period +// opened. Every grace charge covers install → the END of the period its grace +// elapses into, so a boundary-straddling timer's new period belongs to Leg 1, +// not this precharge (counting it would double-bill; skipping the NEXT +// boundary would leave a gap — this predicate does neither). +// - grace_resolved — its grace verdict is terminal. Resolved-WITHOUT-charge +// rows (the D1d period-closed posture: installed pre-activation, so the +// install period itself is forgiven) still owe every period from the first +// post-activation boundary onward; the previous grace_charged_at IS NOT NULL +// proxy silently exempted them from ALL overage billing, forever. func (q *Queries) CountOngoingOverModuleTimers(ctx context.Context, arg CountOngoingOverModuleTimersParams) (int64, error) { - row := q.db.QueryRow(ctx, countOngoingOverModuleTimers, arg.AccountID, arg.IncludedModules) + row := q.db.QueryRow(ctx, countOngoingOverModuleTimers, arg.AccountID, arg.IncludedModules, arg.PeriodEnd) var over_count int64 err := row.Scan(&over_count) return over_count, err diff --git a/internal/account/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql index 0721c65..87edc4c 100644 --- a/internal/account/db/queries/module_timers.sql +++ b/internal/account/db/queries/module_timers.sql @@ -108,27 +108,44 @@ WHERE id = @timer_id::uuid AND grace_resolved = false; -- CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): --- the count of the account's currently-live install timers that are BOTH "over" --- (live-FIFO rank >= included) AND already charged at least once (grace_charged_at --- IS NOT NULL) — i.e. ongoing over-modules continuing into the new period, each --- owed a FULL $3 precharge on the boundary invoice. row_number() over the whole --- live set gives every live timer its 1-based FIFO rank; rn > @included_modules is --- exactly the 0-based rank >= included ("over") predicate. A timer whose grace has --- not elapsed yet (grace_charged_at IS NULL) is EXCLUDED — it stays on Leg 1's own --- timer and is never double-counted here. "over" is re-derived LIVE, so a charged --- timer that has since flipped to "included" (an earlier install removed) is not --- counted. +-- the count of the account's currently-live install timers that are "over" +-- (live-FIFO rank >= included) AND owed a FULL $3 precharge for the NEW period +-- [@period_end, next boundary) — ongoing over-modules continuing into it. +-- row_number() over the whole live set gives every live timer its 1-based FIFO +-- rank; rn > @included_modules is exactly the 0-based rank >= included ("over") +-- predicate. "over" is re-derived LIVE, so a charged timer that has since flipped +-- to "included" (an earlier install removed) is not counted. +-- +-- The coverage contract with the grace legs (review 2026-07-06) — a timer is +-- "ongoing" for the new period iff ALL of: +-- * installed_at < @period_end — it existed before the new period opened. A +-- module installed INSIDE the new period had that period covered by its OWN +-- grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base +-- leg applies via LiveAppsCreatedBefore; without it a reclaimed +-- skipped_no_pm/failed boundary run double-bills the period. +-- * grace_expires_at < @period_end — its grace elapsed BEFORE the new period +-- opened. Every grace charge covers install → the END of the period its grace +-- elapses into, so a boundary-straddling timer's new period belongs to Leg 1, +-- not this precharge (counting it would double-bill; skipping the NEXT +-- boundary would leave a gap — this predicate does neither). +-- * grace_resolved — its grace verdict is terminal. Resolved-WITHOUT-charge +-- rows (the D1d period-closed posture: installed pre-activation, so the +-- install period itself is forgiven) still owe every period from the first +-- post-activation boundary onward; the previous grace_charged_at IS NOT NULL +-- proxy silently exempted them from ALL overage billing, forever. -- name: CountOngoingOverModuleTimers :one SELECT COALESCE(count(*), 0)::bigint AS over_count FROM ( - SELECT grace_charged_at, + SELECT installed_at, grace_expires_at, grace_resolved, row_number() OVER (ORDER BY installed_at, id) AS rn FROM ms_billing.app_module_overage_timers WHERE account_id = @account_id::uuid AND removed_at IS NULL ) ranked WHERE rn > @included_modules::int - AND grace_charged_at IS NOT NULL; + AND grace_resolved = true + AND installed_at < @period_end::timestamptz + AND grace_expires_at < @period_end::timestamptz; -- CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the -- ids of the app's live, unresolved install timers whose install instant IS the From a487747ab90a331bc6b17f7f878262552e1aeb8e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:33:37 +0800 Subject: [PATCH 14/30] fix: Leg 1 covers the straddled period when a module's grace crosses a boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leg 1 anchored its charge strictly to the install period, with a comment deferring the next period to the boundary precharge — but the boundary for that period runs BEFORE a straddling grace elapses and (correctly) excludes the unresolved timer, so the straddled period was billed by NO leg at all: a systematic $3/module undercharge whenever a module was installed within GraceDays of a boundary. Coverage contract: every grace charge covers install day → the END of the period its grace elapses into. For a straddler that is the install period prorated + the straddled period in full; the boundary precharge picks the timer up from the FIRST boundary after its grace elapsed (grace_expires_at < period_end, previous commit), so coverage is complete and disjoint by construction. Amount stays deterministic across retries (installed_at + activation anchor are immutable), so the per-timer Stripe idem keys stay stable. Invoice mirror window extends with the amount. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/overage.go | 43 ++++++++++++---- internal/account/cycle/overage_test.go | 69 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 59795be..d2b695e 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -41,6 +41,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) // moduleOverageGraceWindow is the per-install grace window: a module's own timer @@ -130,13 +131,14 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan return res, nil } - // "Over": price $3 prorated from the install's UTC day to the end of the - // anchored period CONTAINING the install (ADR 0005 anchor from activation) — - // install-anchored, NOT grace-elapse-anchored and NOT now-anchored. Anchoring - // on the install's own period (rather than now's) keeps this leg's coverage - // strictly within the install period, so a grace that straddles a period - // boundary never charges the NEXT period here — that period is the boundary - // precharge's job (scenario 6, Stage B), which would otherwise double-bill it. + // "Over": price $3 prorated from the install's UTC day over the install's own + // anchored period (ADR 0005 anchor from activation) — install-anchored, NOT + // grace-elapse-anchored and NOT now-anchored — plus, for a grace that + // STRADDLES the period boundary, the full fee for the period the grace + // elapses into (see the coverage comment below; the boundary precharge + // deliberately excludes straddlers via its grace_expires_at < period_end + // cutoff, so that period is THIS leg's to bill, and only from the boundary + // after that does the precharge take over). periodStart, periodEnd, closed := periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration @@ -188,7 +190,25 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan return res, nil } + // Coverage (review 2026-07-06 contract): install day → the END of the period + // the grace ELAPSES INTO. Normally that is the install period itself (grace ≪ + // period length), so the amount is the familiar install-anchored proration. + // When the grace STRADDLES the boundary (grace_expires_at at/after the install + // period's end), the boundary run for the next period already executed while + // this timer was unresolved and — by the same contract (grace_expires_at < + // period_end) — excluded it; if this leg only covered the install period, the + // straddled period would be billed by NO leg at all. So this charge covers it: + // prorated install period + the FULL fee for the straddled period. Both inputs + // (installed_at, activation anchor) are immutable, so the amount stays + // deterministic across retries — the per-timer Stripe idem keys stay stable. + // The precharge picks the timer up from the FIRST boundary after its grace + // elapsed, so coverage is complete and disjoint by construction. + coverageEnd := periodEnd proratedMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) + if !cand.GraceExpiresAt.Before(periodEnd) { + _, coverageEnd = billingperiod.AnchoredPeriodWindow(cand.GraceExpiresAt.UTC(), billingperiod.AnchorDay(cand.ActivatedAt)) + proratedMicros += usage.ModuleOverageFeeMicros + } cents, err := centsFromMicros(proratedMicros) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) @@ -243,11 +263,12 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan AmountDueCents: inv.AmountDue, AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, - // Partial coverage window [install day (UTC midnight), period end) — the - // SAME instant ProratedBaseMicros priced, so the mirrored window and the - // charged amount agree by construction. + // Partial coverage window [install day (UTC midnight), coverage end) — the + // SAME window the amount above priced (coverage end extends past the install + // period only for a boundary-straddling grace), so the mirrored window and + // the charged amount agree by construction. PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), - PeriodEnd: periodEnd, + PeriodEnd: coverageEnd, IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 44ddd46..11d9ff5 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -178,6 +178,75 @@ func TestModuleOverage_RemovedWithinGraceNeverCharged(t *testing.T) { require.False(t, store.timers[over].graceCharged) } +// --- grace straddling a period boundary: Leg 1 covers the straddled period ---- + +// Regression (review 2026-07-06, M1): a grace window that straddles a period +// boundary used to leave the straddled period billed by NO leg — Leg 1's comment +// deferred it to the boundary precharge, but the boundary ran BEFORE the grace +// elapsed and (correctly) excluded the unresolved timer. Under the coverage +// contract Leg 1 now charges install day → the END of the period the grace +// elapses into: the install period prorated + the straddled period in full. +func TestModuleOverage_GraceStraddlingBoundaryCoversStraddledPeriod(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + // Installed Jul 2 — 2 days before the [Jun 4, Jul 4) period closes — so its + // grace elapses Jul 5, INSIDE the next period [Jul 4, Aug 4). + over := seedTimer(store, acct, app, time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + // $3 × 2/30 days (Jul 2 → Jul 4 of the 30-day install period, round-half-up + // = $0.20) + the FULL $3 for the straddled [Jul 4, Aug 4) period = $3.20. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 320, sc.itemCalls[0].amountCfg, + "install-period proration + the full straddled period") + + // The mirrored window agrees with the amount: coverage runs through the END + // of the period the grace elapsed into. + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), inv.PeriodStart) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd) + } +} + +// The complement: a grace that elapses INSIDE the install period keeps the +// familiar install-anchored proration — no straddle surcharge. +func TestModuleOverage_GraceInsidePeriodChargesInstallPeriodOnly(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + // Installed Jun 10; grace elapses Jun 13, well inside [Jun 4, Jul 4). + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + // $3 × 24/30 days (Jun 10 → Jul 4) = $2.40 — and nothing more. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg) + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd, + "no straddle → coverage ends at the install period's end") + } +} + // --- over module with no usable PM is skipped and retried (not resolved) ------ func TestModuleOverage_NoPMSkipsAndRetries(t *testing.T) { From 3e6f26cdf11ecb775e79bbb00705e74a80e1acda Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:43:08 +0800 Subject: [PATCH 15/30] fix: apps still in creation grace at a boundary are excluded from the advance base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advance-base leg billed the FULL next-period base for every live app with created_at < period_end — including apps still INSIDE their 3-day creation grace at the boundary. An app created within GraceDays of its period boundary and deleted in grace (spec scenario 1: never charged) was billed a full month of base. Coverage contract (same rule as the module timers): the boundary counts an app only once its creation grace elapsed BEFORE the new period opened; the creation-proration charge in turn covers creation day → the END of the period the grace elapses into (creation period prorated + the straddled period in full, one more snapshot row for the display). A grace-deleted straddler now pays $0; a survivor pays the same total as before, on one invoice, and joins the advance leg at the NEXT boundary — matching the spec's 'join this boundary mechanism starting at the NEXT boundary after their own creation charge fires'. The co-created overage lines on the combined scenario-3 invoice extend the same way, complementing the Leg-2 grace_expires_at cutoff. AccountHasLiveApps (the no-usage boundary gate) applies the same rule so a boundary is not armed by grace-only apps. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge.go | 33 +++++---- .../cycle/migration027_integration_test.go | 19 +++-- internal/account/cycle/proration.go | 71 +++++++++++++++---- internal/account/cycle/proration_test.go | 45 +++++++----- internal/account/cycle/scenarios_test.go | 23 ++++++ internal/account/cycle/service_test.go | 16 +++-- internal/account/cycle/store.go | 36 +++++++--- internal/account/db/apps.sql.go | 47 +++++++----- internal/account/db/queries/apps.sql | 39 ++++++---- 9 files changed, 239 insertions(+), 90 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 11f79d6..1b9f36b 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -131,17 +131,21 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } // ADVANCE base leg: the NEW period's base fee for every LIVE app on the - // roster that EXISTED BEFORE the new period opened (created_at < periodEnd - // — the closed window's end IS the new period's start), snapshotted at - // charge time (D1b/D1e — see the method comment). An app created INSIDE - // the new period is EXCLUDED: RegisterApp's creation-proration leg already - // charged its new-period base (full or prorated), so adding it here would - // double-bill the same period — on the same-day cron race, and - // deterministically whenever a skipped_no_pm/failed run is reclaimed after - // the app registered. It joins the advance leg at the NEXT boundary. + // roster that had JOINED the advance mechanism before the new period opened, + // snapshotted at charge time (D1b/D1e — see the method comment). An app + // created INSIDE the new period is EXCLUDED (its creation-proration leg + // already charged that period's base — adding it here would double-bill on + // the same-day cron race, and deterministically on a reclaimed + // skipped_no_pm/failed run), and so is an app still INSIDE its creation + // grace at the boundary (review 2026-07-06, H2): it hasn't survived grace — + // an app deleted in grace is NEVER charged (scenario 1), so precharging its + // next-period base would bill a full month for an app still deletable for + // free — and when it survives, its creation charge covers through the END of + // the period its grace elapses into, making this boundary's new period that + // leg's coverage. Either way it joins the advance leg at the NEXT boundary. // Deleted apps drop out of the base but their usage arrears (already in // `total` above) still bill. Empty roster (pre-backfill) → base 0. - apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, periodEnd) + apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, periodEnd, usage.GraceDays) if err != nil { return nil, billing.Internal("live app roster read failed", err) } @@ -472,15 +476,16 @@ func (s *Service) AccountsWithUnbilledUsage(ctx context.Context, periodStart, pe // cycle's gate for running the boundary charge on a NO-USAGE period: an // account with live pre-existing apps still owes the advance base fee, while // a no-usage, no-apps (pre-backfill) account keeps the historical skip (no -// billing_run at all). Apps created INSIDE the new period don't arm the gate: -// their new-period base is RegisterApp's proration leg's, and they join the -// advance leg at the NEXT boundary — running a boundary for them here would -// only mint a zero-charge run row. +// billing_run at all). Apps created INSIDE the new period — or still inside +// their creation grace at the boundary (H2, same rule as the advance leg +// itself) — don't arm the gate: their new-period base is the creation- +// proration leg's, and they join the advance leg at the NEXT boundary — +// running a boundary for them here would only mint a zero-charge run row. func (s *Service) AccountHasLiveApps(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) (bool, error) { if accountID == uuid.Nil { return false, billing.InvalidInput("account_id required") } - apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, createdBefore) + apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, createdBefore, usage.GraceDays) if err != nil { return false, billing.Internal("live app roster read failed", err) } diff --git a/internal/account/cycle/migration027_integration_test.go b/internal/account/cycle/migration027_integration_test.go index e2b6328..6adb500 100644 --- a/internal/account/cycle/migration027_integration_test.go +++ b/internal/account/cycle/migration027_integration_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" ) @@ -94,7 +95,7 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { // proration leg, never this boundary's advance sum. require.NoError(t, store.InsertAppMirror(ctx, late, acct, 4, mustTime(t, "2026-07-01T10:00:00Z"))) - apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart) + apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) counts := make([]int, 0, len(apps)) for _, a := range apps { @@ -104,10 +105,20 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { require.ElementsMatch(t, []int{0, 6}, counts, "deleted apps, other accounts' apps, and apps created inside the new period never enter the advance-base sum") - // At the NEXT boundary the late app pre-exists the newer period and joins. - apps, err = store.LiveAppsCreatedBefore(ctx, acct, mustTime(t, "2026-08-01T00:00:00Z")) + // Created inside period A but still IN GRACE at the boundary (H2, review + // 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"))) + apps, err = store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) - require.Len(t, apps, 3) + require.Len(t, apps, 2, "an app whose creation grace straddles the boundary joins only at the NEXT boundary") + + // At the NEXT boundary the late + in-grace apps pre-exist the newer period + // (grace long elapsed) and join. + apps, err = store.LiveAppsCreatedBefore(ctx, acct, mustTime(t, "2026-08-01T00:00:00Z"), usage.GraceDays) + require.NoError(t, err) + require.Len(t, apps, 4) // EnsureAccountForUser: get-or-create resolves the SAME account twice. userID := uuid.New() diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index fbc0274..d08b562 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -125,11 +125,15 @@ const ( // guard, so the combined charge is all-or-nothing: a co-created over-module and // the app base fee are billed and marked together, never one without the other. type ProrationCharge struct { - InvoiceID string - Cents int64 - Invoice InvoiceMirror - Snapshot AppBaseSnapshot - TimerCharges []ModuleTimerCharge + InvoiceID string + Cents int64 + Invoice InvoiceMirror + Snapshot AppBaseSnapshot + // StraddleSnapshot freezes the straddled period billed IN FULL on this same + // invoice when the app's creation grace crossed its period boundary (the + // coverage contract, review 2026-07-06) — nil otherwise. + StraddleSnapshot *AppBaseSnapshot + TimerCharges []ModuleTimerCharge } // ModuleTimerCharge is one co-created over-module install timer's terminal @@ -265,7 +269,25 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) // its own grace timer). created_module_count is still frozen at RegisterApp // time and recorded on the snapshot for display, but it no longer moves the // base amount — a create with 0 or 50 modules prorates the identical flat base. - prorated := usage.ProratedBaseMicros(usage.BaseFeeMicros, locked.CreatedAt, periodStart, periodEnd) + creationPeriodMicros := usage.ProratedBaseMicros(usage.BaseFeeMicros, locked.CreatedAt, periodStart, periodEnd) + + // Coverage contract (review 2026-07-06, H2): this charge covers creation + // day → the END of the period the creation grace ELAPSES INTO. Normally + // that is the creation period itself. An app created within GraceDays of + // its period boundary is still IN GRACE at that boundary, so the advance + // leg deliberately excludes it there (LiveAppsCreatedBefore's grace + // cutoff — a grace-deleted app must never pay a month of base); when it + // SURVIVES, this charge bills the straddled period in full on top of the + // creation-period proration, and the app joins the advance leg at the + // NEXT boundary. Deterministic across retries (created_at + activation + // anchor are immutable) — the app-keyed Stripe idem keys stay stable. + coverageEnd := periodEnd + prorated := creationPeriodMicros + straddle := !moduleGraceExpiry(locked.CreatedAt.UTC()).Before(periodEnd) + if straddle { + _, coverageEnd = billingperiod.AnchoredPeriodWindow(moduleGraceExpiry(locked.CreatedAt.UTC()), billingperiod.AnchorDay(activatedAt)) + prorated += usage.BaseFeeMicros + } c, err := centsFromMicros(prorated) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) @@ -294,7 +316,14 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if err != nil { return nil, billing.Internal("co-created over-module timers lookup failed", err) } + // Same coverage as the base: creation period prorated, plus the straddled + // period in full when the (shared, co-created) grace crosses the boundary + // — the boundary precharge's grace_expires_at cutoff excluded these timers + // there, so the straddled period is this combined invoice's to bill. overageMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, locked.CreatedAt, periodStart, periodEnd) + if straddle { + overageMicros += usage.ModuleOverageFeeMicros + } overageCents, err := centsFromMicros(overageMicros) if err != nil { return nil, billing.Internal("overage micros to cents conversion failed", err) @@ -337,11 +366,13 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, billing.Internal("account collection lookup failed", err) } - // Mirror the PARTIAL window [creation day, period end) — the same coverage - // start ProratedBaseMicros priced, so mirror and amount agree by construction. + // Mirror the PARTIAL window [creation day, coverage end) — the same window + // the amount priced (coverage end extends past the creation period only + // when the grace straddles the boundary), so mirror and amount agree by + // construction. partialStart := usage.ProrationCoverageStart(locked.CreatedAt, periodStart) cents = c - return &ProrationCharge{ + pc := &ProrationCharge{ InvoiceID: inv.ID, Cents: c, Invoice: InvoiceMirror{ @@ -352,21 +383,35 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, PeriodStart: partialStart, - PeriodEnd: periodEnd, + PeriodEnd: coverageEnd, IsLargeAutoCollect: flagLargeAutoCollect(prorated+overageTotalMicros, acct), }, // Freeze what was billed keyed by the FULL anchored period_start (the // display identity, migration 028); BaseMicros is the prorated BASE amount - // (the co-created overage rides the per-module timers, not the base snapshot). + // for the CREATION period only (the co-created overage rides the + // per-module timers, not the base snapshot). Snapshot: AppBaseSnapshot{ AppID: locked.AppID, PeriodStart: periodStart, PeriodEnd: periodEnd, ModuleCount: locked.CreatedModuleCount, - BaseMicros: prorated, + BaseMicros: creationPeriodMicros, }, TimerCharges: timerCharges, - }, nil + } + if straddle { + // The straddled period was billed IN FULL on this same invoice — freeze + // its own snapshot row so the display shows that period's base as + // charged (the boundary leg excluded the app there and writes nothing). + pc.StraddleSnapshot = &AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodEnd, + PeriodEnd: coverageEnd, + ModuleCount: locked.CreatedModuleCount, + BaseMicros: usage.BaseFeeMicros, + } + } + return pc, nil }) if err != nil { // A billing.Error from the charge callback (Stripe / conversion) is already diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index 91911b3..342fe31 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -88,7 +88,10 @@ func TestSweep_ChargesSurvivorExactlyOnceAcrossReruns(t *testing.T) { require.Equal(t, 1, first.Pending) require.Equal(t, 1, first.Charged) require.Len(t, sc.itemCalls, 1) - require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) // 3 of 30 days of $20 + // 3 of 30 days of $20 ($2) + the straddled [Jul 4, Aug 4) period in full + // ($20) — created Jul 1 08:00, so the grace crosses the Jul 4 boundary and + // the advance leg excludes the app there (coverage contract, H2). + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) armed := store.apps[appID].ProrationInvoiceID require.NotEmpty(t, armed) @@ -102,13 +105,16 @@ func TestSweep_ChargesSurvivorExactlyOnceAcrossReruns(t *testing.T) { require.Equal(t, armed, store.apps[appID].ProrationInvoiceID) } -// --- (d) the proration $ amount is unchanged from the pre-grace charge -------- +// --- (d) the proration $ amount, mirror window, and snapshots ----------------- func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { - // The SAME numbers the pre-grace RegisterApp charge produced, now via the - // delayed charge leg: 20e6 × 3/30 = 2_000_000 micros → 200 cents, mirrored - // with the PARTIAL window [creation day, period end), snapshot frozen at the - // FULL anchored period start with the prorated amount. + // The creation-period part is the SAME number the pre-grace RegisterApp + // charge produced: 20e6 × 3/30 = 2_000_000 micros. Created Jul 1 08:00, the + // grace crosses the Jul 4 boundary (coverage contract, H2), so the charge + // ALSO covers the straddled [Jul 4, Aug 4) period in full: 22e6 micros → + // 2200 cents, mirrored with the window [creation day, straddled period end), + // TWO snapshots frozen — the creation period's prorated amount and the + // straddled period's full base. store := newFakeStore() user, acct := registeredAccount(store) sc := newFakeStripe() @@ -119,11 +125,11 @@ func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { resp, err := svc.ChargeCreationProration(context.Background(), appID) require.NoError(t, err) require.Equal(t, cycle.ProrationStatusCharged, resp.Status) - require.EqualValues(t, 200, resp.ProrationCents) + require.EqualValues(t, 2200, resp.ProrationCents) require.Len(t, sc.itemCalls, 1) require.Len(t, sc.invoiceCalls, 1) - require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) require.Equal(t, "cus_apps_1", sc.itemCalls[0].custID) require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) require.Equal(t, "app-inv-"+appID.String(), sc.invoiceCalls[0].idemKey) @@ -133,15 +139,21 @@ func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { mirror := store.invoices[sc.invoiceID] require.Equal(t, acct, mirror.AccountID) require.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) // partial coverage start - require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd, "coverage runs through the straddled period's end") require.Equal(t, sc.invoiceID, store.apps[appID].ProrationInvoiceID) snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] require.True(t, ok, "the charge freezes its base keyed on the FULL anchored period start") require.Equal(t, "proration", snap.source) - require.EqualValues(t, 2_000_000, snap.snap.BaseMicros) + require.EqualValues(t, 2_000_000, snap.snap.BaseMicros, "the creation-period snapshot carries only the prorated part") require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), snap.snap.PeriodEnd) require.Equal(t, 0, snap.snap.ModuleCount) + + straddleSnap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the straddled period billed in full gets its own snapshot (the boundary leg writes nothing for it)") + require.Equal(t, "proration", straddleSnap.source) + require.EqualValues(t, 20_000_000, straddleSnap.snap.BaseMicros) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), straddleSnap.snap.PeriodEnd) } func TestChargeCreationProration_ChargesFlatBaseNotFoldedOverage(t *testing.T) { @@ -338,10 +350,11 @@ func TestChargeCreationProration_PricesFrozenCountNotLiveCountAfterMidGraceInsta // mandatory grace window — before the sweep ever charges — and the sweep // fires after grace elapses. Pre-fix, the charge priced off the module_count // read FRESH at sweep time (7, live) → 20e6 + 2×3e6 = 26e6 base, 3 of 30 days - // → 2_600_000 micros → 260 cents: a HIGHER tier retroactively applied to - // days that never had 7 modules installed. Fixed: the charge prices off - // created_module_count (frozen at registration, 0) → 20e6 base, 3 of 30 days - // → 2_000_000 micros → 200 cents — identical to + // → 2_600_000 micros: a HIGHER tier retroactively applied to days that never + // had 7 modules installed. Fixed: the charge prices off created_module_count + // (frozen at registration, 0) → 20e6 base, 3 of 30 days → 2_000_000 micros, + // plus the straddled [Jul 4, Aug 4) period's full base (created Jul 1 08:00 — + // the grace crosses the boundary) → 22e6 → 2200 cents — identical to // TestChargeCreationProration_AmountMatchesLegacyProration's un-synced case. store := newFakeStore() user, _ := registeredAccount(store) @@ -361,10 +374,10 @@ func TestChargeCreationProration_PricesFrozenCountNotLiveCountAfterMidGraceInsta resp, err := svc.ChargeCreationProration(context.Background(), appID) require.NoError(t, err) require.Equal(t, cycle.ProrationStatusCharged, resp.Status) - require.EqualValues(t, 200, resp.ProrationCents, + require.EqualValues(t, 2200, resp.ProrationCents, "must price off the FROZEN count (0 modules → $20 base), not the live count (7 → $26 base)") require.Len(t, sc.itemCalls, 1) - require.EqualValues(t, 200, sc.itemCalls[0].amountCfg) + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) // The migration-028 snapshot must also record the FROZEN count/amount — the // display must never show a tier that never applied to those days either. diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go index 6f043fa..5d5fedd 100644 --- a/internal/account/cycle/scenarios_test.go +++ b/internal/account/cycle/scenarios_test.go @@ -445,6 +445,29 @@ func TestScenario6_D1dResolvedUnchargedOverModuleStillPrecharged(t *testing.T) { "a D1d resolved-uncharged over-module is ongoing — only its pre-activation install period is forgiven, not every period after") } +// Regression (review 2026-07-06, H2): an app created within GraceDays of the +// boundary is still IN GRACE when the boundary runs — it must NOT be precharged +// the new period's base. It can still be deleted for free (scenario 1: deleted +// within grace is NEVER charged); an app that survives has the straddled period +// billed by its own creation charge, and joins the advance leg at the NEXT +// boundary. Pre-fix the boundary billed it a full month while still deletable. +func TestScenario6_AppStillInGraceAtBoundaryNotPrechargedBase(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // keep the invoice non-zero without any base + store.hasPM = true + store.stripeCustomer = "cus_h2" + // Created Jun 29 — inside period A [Jun 1, Jul 1) but within GraceDays of + // the Jul 1 boundary, so its grace (expires Jul 2) straddles it. + seedAppCreated(store, chargeAccount, 0, false, timeUTC(2026, 6, 29, 0)) + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceBaseMicros, + "an app still inside its creation grace at the boundary is not precharged — deleted-in-grace must stay free, and a survivor's creation charge covers the straddled period") + require.EqualValues(t, 1_000_000, resp.ArrearsMicros) +} + // Regression (review 2026-07-06, M1 boundary side): a charged over-module whose // grace STRADDLES the boundary is excluded from the precharge — its own Leg 1 // charge covers through the END of the period its grace elapses into, so the diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 79132f4..0f842f6 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -576,6 +576,9 @@ func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, ch } f.invoices[pc.Invoice.StripeInvoiceID] = pc.Invoice f.baseSnapshots[snapKey{pc.Snapshot.AppID, pc.Snapshot.PeriodStart}] = fakeBaseSnapshot{snap: pc.Snapshot, source: "proration"} + if pc.StraddleSnapshot != nil { + f.baseSnapshots[snapKey{pc.StraddleSnapshot.AppID, pc.StraddleSnapshot.PeriodStart}] = fakeBaseSnapshot{snap: *pc.StraddleSnapshot, source: "proration"} + } app.ProrationInvoiceID = pc.InvoiceID // first-charge-wins, like WHERE … IS NULL under the lock f.apps[appID] = app // Scenario 3 — mark the co-created over-module timers billed on this combined @@ -593,16 +596,19 @@ func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, ch return cycle.ProrationLockedCharged, pc.InvoiceID, nil } -func (f *fakeStore) LiveAppsCreatedBefore(_ context.Context, accountID uuid.UUID, createdBefore time.Time) ([]cycle.AppModuleCount, error) { +func (f *fakeStore) LiveAppsCreatedBefore(_ context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]cycle.AppModuleCount, error) { if f.errLiveCounts != nil { return nil, f.errLiveCounts } apps := []cycle.AppModuleCount{} for _, app := range f.apps { - // Strictly-before cutoff, mirroring the SQL's created_at < $2: an app - // created ON or AFTER the new period's start is excluded (its base is - // RegisterApp's proration leg's, never the advance leg's). - if app.AccountID == accountID && !app.Deleted && app.CreatedAt.Before(createdBefore) { + // Strictly-before cutoffs, mirroring the SQL: an app created ON or AFTER + // the new period's start is excluded (its base is the proration leg's, + // never the advance leg's), and so is an app whose creation grace had not + // yet elapsed when the new period opened (H2 — it hasn't survived grace, + // and its creation charge covers through the grace-elapsed period). + if app.AccountID == accountID && !app.Deleted && app.CreatedAt.Before(createdBefore) && + app.CreatedAt.AddDate(0, 0, graceDays).Before(createdBefore) { apps = append(apps, cycle.AppModuleCount{AppID: app.AppID, ModuleCount: app.ModuleCount}) } } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index f4fcb14..23cdfd0 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -252,14 +252,17 @@ type Store interface { MarkAppDeleted(ctx context.Context, appID uuid.UUID) error // LiveAppsCreatedBefore returns every LIVE (deleted_at IS NULL) app on the - // account created STRICTLY BEFORE createdBefore, with its module_count — - // the boundary charge's advance-base input. createdBefore is the NEW - // period's start (the closed window's period_end): an app created inside - // the new period is EXCLUDED because RegisterApp's creation-proration leg - // already owns that period's base (full or prorated) — it joins the + // account that has JOINED the advance-base mechanism by createdBefore (the + // NEW period's start, i.e. the closed window's period_end), with its + // module_count — the boundary charge's advance-base input. An app is + // excluded when created inside the new period (its creation-proration leg + // owns that period's base) OR when its creation grace (graceDays) had not + // yet elapsed by createdBefore (it hasn't survived grace — deleted-in-grace + // is never charged — and when it survives, its creation charge covers + // through the END of the period its grace elapses into). It joins the // advance leg at the NEXT boundary. Empty for a pre-backfill account → // advance base 0 (pre-027 behavior). - LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) ([]AppModuleCount, error) + LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]AppModuleCount, error) // UpsertProrationBaseSnapshot persists the creation-proration leg's // per-app-period base snapshot (migration 028, source='proration'), keyed @@ -1170,6 +1173,20 @@ func (s *pgxStore) persistProrationCharge(ctx context.Context, appID uuid.UUID, }); err != nil { return 0, "", err } + // A creation grace that straddled the period boundary billed the straddled + // period IN FULL on this same invoice — freeze its snapshot too (the boundary + // leg excluded the app there and writes nothing for that period). + if pc.StraddleSnapshot != nil { + if err := qtx.UpsertProrationBaseSnapshot(ctx, db.UpsertProrationBaseSnapshotParams{ + AppID: pc.StraddleSnapshot.AppID.String(), + PeriodStart: pc.StraddleSnapshot.PeriodStart, + PeriodEnd: pc.StraddleSnapshot.PeriodEnd, + ModuleCount: int32(pc.StraddleSnapshot.ModuleCount), //nolint:gosec // same validated apps-row count as above + BaseMicros: pc.StraddleSnapshot.BaseMicros, + }); err != nil { + return 0, "", err + } + } // Arm the one-shot guard. First-write-wins (WHERE proration_invoice_id IS // NULL): a concurrent second attempt for the same app affects 0 rows here // and keeps the winner's (identical, by construction) invoice id. @@ -1260,10 +1277,11 @@ func (s *pgxStore) MarkAppDeleted(ctx context.Context, appID uuid.UUID) error { return err } -func (s *pgxStore) LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) ([]AppModuleCount, error) { +func (s *pgxStore) LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]AppModuleCount, error) { rows, err := s.q.LiveAppModuleCountsCreatedBefore(ctx, db.LiveAppModuleCountsCreatedBeforeParams{ - AccountID: accountID.String(), - CreatedAt: createdBefore, + AccountID: accountID.String(), + CreatedBefore: createdBefore, + GraceDays: int32(graceDays), //nolint:gosec // graceDays is the small GraceDays const (3) }) if err != nil { return nil, err diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index c846c4a..0252987 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -130,14 +130,16 @@ func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams const liveAppModuleCountsCreatedBefore = `-- name: LiveAppModuleCountsCreatedBefore :many SELECT app_id, module_count FROM ms_billing.apps -WHERE account_id = $1 +WHERE account_id = $1::uuid AND deleted_at IS NULL - AND created_at < $2 + AND created_at < $2::timestamptz + AND created_at + make_interval(days => $3::int) < $2::timestamptz ` type LiveAppModuleCountsCreatedBeforeParams struct { - AccountID string `json:"account_id"` - CreatedAt time.Time `json:"created_at"` + AccountID string `json:"account_id"` + CreatedBefore time.Time `json:"created_before"` + GraceDays int32 `json:"grace_days"` } type LiveAppModuleCountsCreatedBeforeRow struct { @@ -146,20 +148,33 @@ type LiveAppModuleCountsCreatedBeforeRow struct { } // LiveAppModuleCountsCreatedBefore returns (app_id, module_count) for every -// LIVE (deleted_at IS NULL) app on the account created STRICTLY BEFORE the -// cutoff — the boundary charge's advance-base input: +// LIVE (deleted_at IS NULL) app on the account that has JOINED the advance +// base mechanism by the cutoff — the boundary charge's advance-base input: // advance base = Σ (BaseFee + Overage × max(0, module_count − included)). -// The cutoff is the NEW period's start (the closed window's period_end): an -// app created INSIDE the new period is excluded, because RegisterApp's -// creation-proration leg already charged that app's new-period base (full or -// prorated) — summing it here would double-bill the same period (same-day -// cron race, and deterministically on reclaimed skipped_no_pm/failed runs). -// Such an app joins the advance leg at the NEXT boundary. Deleted apps are -// excluded (D1e); an account with no rows (pre-backfill) sums to base 0 and -// keeps the pre-027 arrears-only invoice. app_id is returned so the charge -// leg can write the per-app-period base snapshot (migration 028) it bills. +// The cutoff is the NEW period's start (the closed window's period_end). Two +// conditions, mirroring the module-timer coverage contract (review 2026-07-06): +// - created_at < @created_before — an app created INSIDE the new period is +// excluded, because RegisterApp's creation-proration leg already charged +// that app's new-period base (full or prorated) — summing it here would +// double-bill the same period (same-day cron race, and deterministically +// on reclaimed skipped_no_pm/failed runs). +// - created_at + grace < @created_before — an app whose CREATION GRACE had +// not yet elapsed when the new period opened is excluded: it has not +// survived grace yet (an app deleted in grace is NEVER charged, scenario +// 1 — precharging its next-period base here would bill a full month for +// an app that can still be deleted for free), and when it does survive, +// its creation-proration charge covers through the END of the period its +// grace elapses into (the straddled period), so this boundary's new +// period is already that leg's coverage. Spec: apps "join this boundary +// mechanism starting at the NEXT boundary after their own creation charge +// fires". +// +// Deleted apps are excluded (D1e); an account with no rows (pre-backfill) +// sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is +// returned so the charge leg can write the per-app-period base snapshot +// (migration 028) it bills. func (q *Queries) LiveAppModuleCountsCreatedBefore(ctx context.Context, arg LiveAppModuleCountsCreatedBeforeParams) ([]LiveAppModuleCountsCreatedBeforeRow, error) { - rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedAt) + rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedBefore, arg.GraceDays) if err != nil { return nil, err } diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index d70997d..ab50fd2 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -106,24 +106,37 @@ WHERE app_id = $1 AND deleted_at IS NULL; -- LiveAppModuleCountsCreatedBefore returns (app_id, module_count) for every --- LIVE (deleted_at IS NULL) app on the account created STRICTLY BEFORE the --- cutoff — the boundary charge's advance-base input: +-- LIVE (deleted_at IS NULL) app on the account that has JOINED the advance +-- base mechanism by the cutoff — the boundary charge's advance-base input: -- advance base = Σ (BaseFee + Overage × max(0, module_count − included)). --- The cutoff is the NEW period's start (the closed window's period_end): an --- app created INSIDE the new period is excluded, because RegisterApp's --- creation-proration leg already charged that app's new-period base (full or --- prorated) — summing it here would double-bill the same period (same-day --- cron race, and deterministically on reclaimed skipped_no_pm/failed runs). --- Such an app joins the advance leg at the NEXT boundary. Deleted apps are --- excluded (D1e); an account with no rows (pre-backfill) sums to base 0 and --- keeps the pre-027 arrears-only invoice. app_id is returned so the charge --- leg can write the per-app-period base snapshot (migration 028) it bills. +-- The cutoff is the NEW period's start (the closed window's period_end). Two +-- conditions, mirroring the module-timer coverage contract (review 2026-07-06): +-- * created_at < @created_before — an app created INSIDE the new period is +-- excluded, because RegisterApp's creation-proration leg already charged +-- that app's new-period base (full or prorated) — summing it here would +-- double-bill the same period (same-day cron race, and deterministically +-- on reclaimed skipped_no_pm/failed runs). +-- * created_at + grace < @created_before — an app whose CREATION GRACE had +-- not yet elapsed when the new period opened is excluded: it has not +-- survived grace yet (an app deleted in grace is NEVER charged, scenario +-- 1 — precharging its next-period base here would bill a full month for +-- an app that can still be deleted for free), and when it does survive, +-- its creation-proration charge covers through the END of the period its +-- grace elapses into (the straddled period), so this boundary's new +-- period is already that leg's coverage. Spec: apps "join this boundary +-- mechanism starting at the NEXT boundary after their own creation charge +-- fires". +-- Deleted apps are excluded (D1e); an account with no rows (pre-backfill) +-- sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is +-- returned so the charge leg can write the per-app-period base snapshot +-- (migration 028) it bills. -- name: LiveAppModuleCountsCreatedBefore :many SELECT app_id, module_count FROM ms_billing.apps -WHERE account_id = $1 +WHERE account_id = @account_id::uuid AND deleted_at IS NULL - AND created_at < $2; + AND created_at < @created_before::timestamptz + AND created_at + make_interval(days => @grace_days::int) < @created_before::timestamptz; -- UpsertProrationBaseSnapshot records what RegisterApp's creation-proration -- leg billed one app for its creation period (migration 028). Keyed by the From 0570a9f764aeafe0db34b4ee23cb751e6f6a54dd Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:49:22 +0800 Subject: [PATCH 16/30] fix: pin invoice items to their own draft invoice (kill cross-leg pending-item pooling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every charge site used CreateInvoiceItem (a floating customer-level pending item) followed by CreateInvoice with pending_invoice_items_behavior=include — which sweeps up ALL of the customer's pending items, not just the ones that leg created. Pre-PR there was effectively one item→invoice sequence per account; this PR runs several concurrently (boundary + per-timer Leg 1 + combined creation), so any crash between item-create and invoice-create leaked the orphaned item onto the NEXT leg's unrelated invoice: money collected on the wrong invoice, the crashed leg livelocked or double-charged, and a wedged combined-invoice proration. New flow at every site: CreateDraftInvoice (empty, pending_invoice_items_behavior=exclude, auto_advance=false, stamped with a deterministic ms_charge_ref metadata anchor) → CreateInvoiceItem PINNED to that draft via InvoiceItemParams.Invoice → FinalizeInvoice (auto_advance — the ONLY money-moving step), under three deterministic idem keys (inv-/ii-/fin-). A crash at any step leaves an inert draft no other invoice can sweep; the retry replays the same objects. Includes the first Stripe-failure injection tests on Leg 1 (errItem was previously never assigned by any test) proving a failed item or finalize leaves the timer unresolved and retryable through identical keys. Co-Authored-By: Claude Fable 5 --- internal/account/billing/service_test.go | 13 ++-- internal/account/cycle/charge.go | 44 ++++++++----- internal/account/cycle/charge_test.go | 43 ++++++++----- internal/account/cycle/overage.go | 49 +++++++++------ internal/account/cycle/overage_test.go | 58 +++++++++++++++++ internal/account/cycle/proration.go | 55 +++++++++------- internal/account/cycle/proration_test.go | 2 +- internal/shared/stripe/client.go | 80 ++++++++++++++++-------- internal/shared/stripe/types.go | 55 +++++++++------- 9 files changed, 275 insertions(+), 124 deletions(-) diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index 5c0f8a4..8c535da 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -242,16 +242,21 @@ func (f *fakeStripe) SetDefaultPaymentMethod(_ context.Context, stripeCustomerID return nil } -// CreateInvoiceItem / CreateInvoice are the PR #6 charge methods. The billing +// CreateDraftInvoice / CreateInvoiceItem / FinalizeInvoice are the charge +// methods (PR #6, draft→pinned-items→finalize since the C2 fix). The billing // package never calls them (the charge cycle lives in internal/account/cycle), // so these are panic stubs present only to keep this fake satisfying the // widened billingstripe.Client interface. -func (f *fakeStripe) CreateInvoiceItem(context.Context, string, int64, string, string, string) (billingstripe.InvoiceItem, error) { +func (f *fakeStripe) CreateDraftInvoice(context.Context, string, string, string) (billingstripe.Invoice, error) { + panic("CreateDraftInvoice must not be called by the billing package") +} + +func (f *fakeStripe) CreateInvoiceItem(context.Context, string, string, int64, string, string, string) (billingstripe.InvoiceItem, error) { panic("CreateInvoiceItem must not be called by the billing package") } -func (f *fakeStripe) CreateInvoice(context.Context, string, bool, string) (billingstripe.Invoice, error) { - panic("CreateInvoice must not be called by the billing package") +func (f *fakeStripe) FinalizeInvoice(context.Context, string, string) (billingstripe.Invoice, error) { + panic("FinalizeInvoice must not be called by the billing package") } // --- tests ---------------------------------------------------------------- diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 1b9f36b..221dbaf 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -423,22 +423,29 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return summary, nil } -// charge creates the Stripe invoice item + draft invoice for the boundary total -// (usage arrears + advance base + advance overage), with the two deterministic -// Idempotency-Keys (ii-, inv-) so a re-run reuses the same Stripe -// objects. withBase only widens the line DESCRIPTION when the total includes an -// advance base fee and/or ongoing-module overage — a pure-usage invoice keeps the -// historical line text. Returns the created invoice projection (id/status/amounts) -// for the mirror upsert. +// charge runs the boundary total (usage arrears + advance base + advance +// overage) through the draft→pinned-item→finalize flow with three +// deterministic Idempotency-Keys (inv-, ii-, fin-) so a re-run +// reuses the same Stripe objects. The item is PINNED to this run's own draft +// (never a floating pending item another leg's invoice could sweep up — C2), +// and only the finalize step moves money. withBase only widens the line +// DESCRIPTION when the total includes an advance base fee and/or +// ongoing-module overage — a pure-usage invoice keeps the historical line +// text. Returns the finalized invoice projection (id/status/amounts) for the +// mirror upsert. func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { desc := fmt.Sprintf("MirrorStack usage — run %s", runID) if withBase { desc = fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) } - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "run:"+runID.String(), invoiceIdemKey(runID)) + if err != nil { + return billingstripe.Invoice{}, err + } + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { return billingstripe.Invoice{}, err } - return s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) + return s.stripe.FinalizeInvoice(ctx, draft.ID, invoiceFinalizeIdemKey(runID)) } // AccountsWithUsageEvents returns the accounts with raw usage_events in the @@ -517,14 +524,17 @@ func (s *Service) LatestClosedPeriodEnd(ctx context.Context, accountID uuid.UUID return end, found, nil } -// invoiceItemIdemKey / invoiceIdemKey build the deterministic per-run Stripe -// Idempotency-Keys. The run id is the stable charge identity, so a re-fire -// (same run row) produces the SAME keys and Stripe returns the original objects -// instead of creating duplicates. The arrears charge is a SINGLE pooled line -// per run (Σ charged across all metrics), so the item key is ii- (not -// per-metric) — matching the single combined invoice item this leg creates. -func invoiceItemIdemKey(runID uuid.UUID) string { return "ii-" + runID.String() } -func invoiceIdemKey(runID uuid.UUID) string { return "inv-" + runID.String() } +// invoiceItemIdemKey / invoiceIdemKey / invoiceFinalizeIdemKey build the +// deterministic per-run Stripe Idempotency-Keys for the boundary leg's +// draft→item→finalize flow. The run id is the stable charge identity, so a +// re-fire (same run row) produces the SAME keys and Stripe returns the +// original objects instead of creating duplicates. The arrears charge is a +// SINGLE pooled line per run (Σ charged across all metrics), so the item key +// is ii- (not per-metric) — matching the single combined invoice item +// this leg creates. +func invoiceItemIdemKey(runID uuid.UUID) string { return "ii-" + runID.String() } +func invoiceIdemKey(runID uuid.UUID) string { return "inv-" + runID.String() } +func invoiceFinalizeIdemKey(runID uuid.UUID) string { return "fin-" + runID.String() } // flagLargeAutoCollect is the ONE large-charge disclosure resolver (scenario 5, // migration 034), called from EVERY off-session charge site — the boundary leg diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 52b1f07..613c279 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -24,8 +24,9 @@ import ( type fakeStripe struct { // recorded calls - itemCalls []itemCall - invoiceCalls []invoiceCall + itemCalls []itemCall + invoiceCalls []invoiceCall // draft creations (one per invoice, C2 flow) + finalizeCalls []finalizeCall // returns invoiceID string @@ -35,9 +36,9 @@ type fakeStripe struct { // injected errors errItem error - errInvoice error - - // onCreateInvoice, when set, runs INSIDE CreateInvoice right before it + errDraft error + errInvoice error // injected on FinalizeInvoice — the money-moving step + // onCreateInvoice, when set, runs INSIDE FinalizeInvoice right before it // returns success — modeling a concurrent account mutation (e.g. a // threshold edit) that lands while the real Stripe HTTP call is in // flight, i.e. strictly AFTER any pre-charge store read the caller @@ -48,6 +49,7 @@ type fakeStripe struct { type itemCall struct { custID string + invoiceID string amountCfg int64 currency string desc string @@ -55,9 +57,14 @@ type itemCall struct { } type invoiceCall struct { - custID string - autoAdvance bool - idemKey string + custID string + ref string + idemKey string +} + +type finalizeCall struct { + invoiceID string + idemKey string } func newFakeStripe() *fakeStripe { @@ -68,16 +75,24 @@ func newFakeStripe() *fakeStripe { } } -func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID string, amountCents int64, currency, desc, idemKey string) (billingstripe.InvoiceItem, error) { - f.itemCalls = append(f.itemCalls, itemCall{custID, amountCents, currency, desc, idemKey}) +func (f *fakeStripe) CreateDraftInvoice(_ context.Context, custID, ref, idemKey string) (billingstripe.Invoice, error) { + f.invoiceCalls = append(f.invoiceCalls, invoiceCall{custID, ref, idemKey}) + if f.errDraft != nil { + return billingstripe.Invoice{}, f.errDraft + } + return billingstripe.Invoice{ID: f.invoiceID, Status: "draft", Currency: "usd"}, nil +} + +func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (billingstripe.InvoiceItem, error) { + f.itemCalls = append(f.itemCalls, itemCall{custID, invoiceID, amountCents, currency, desc, idemKey}) if f.errItem != nil { return billingstripe.InvoiceItem{}, f.errItem } return billingstripe.InvoiceItem{ID: "ii_test_" + uuid.NewString()}, nil } -func (f *fakeStripe) CreateInvoice(_ context.Context, custID string, autoAdvance bool, idemKey string) (billingstripe.Invoice, error) { - f.invoiceCalls = append(f.invoiceCalls, invoiceCall{custID, autoAdvance, idemKey}) +func (f *fakeStripe) FinalizeInvoice(_ context.Context, invoiceID, idemKey string) (billingstripe.Invoice, error) { + f.finalizeCalls = append(f.finalizeCalls, finalizeCall{invoiceID, idemKey}) if f.errInvoice != nil { return billingstripe.Invoice{}, f.errInvoice } @@ -85,7 +100,7 @@ func (f *fakeStripe) CreateInvoice(_ context.Context, custID string, autoAdvance f.onCreateInvoice() } return billingstripe.Invoice{ - ID: f.invoiceID, + ID: invoiceID, Status: f.invoiceStatus, AmountDue: f.invoiceAmountDue, AmountPaid: f.invoiceAmountPaid, @@ -147,7 +162,7 @@ func TestRunBillingCycle_ChargesArrears(t *testing.T) { require.Equal(t, "cus_test_1", sc.itemCalls[0].custID) require.EqualValues(t, 123, sc.itemCalls[0].amountCfg) require.Equal(t, "usd", sc.itemCalls[0].currency) - require.True(t, sc.invoiceCalls[0].autoAdvance) + require.Len(t, sc.finalizeCalls, 1, "the draft is finalized (auto_advance) — the money-moving step") // Invoice mirrored + run marked invoiced. require.Len(t, store.invoices, 1) diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index d2b695e..74391c9 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -167,19 +167,19 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // Scenario 3 combined-invoice ownership guard. A co-created over-module timer // (installed AT the app's created_at) whose app's creation proration is still // UNRESOLVED is the COMBINED-invoice path's responsibility (proration.go), NOT - // Leg 1's: the creation-proration charge folds this timer's overage onto the - // app's ONE creation invoice (app-inv-) using the SHARED per-timer item - // key. cmd/billing-cycle runs the proration sweep BEFORE this one so the happy - // path resolves these timers first (they never reach here). But if that sweep's - // persist phase FAILED after its Stripe calls already landed the overage item on - // the combined invoice, the timer is still unresolved when this sweep runs in - // the SAME process — and minting our OWN invoice (mod-overage-inv-) - // here would mis-attribute money Stripe already pooled onto the combined invoice - // to a second, stray invoice (or conflict outright). So DEFER (skip WITHOUT - // resolving): the proration sweep retries every cycle and converges on the SAME - // combined invoice via the deterministic keys, then marks this timer resolved, - // dropping it from this work list. A LATER install (installed_at != created_at) - // is never co-created, so it charges here normally. + // Leg 1's: the creation-proration charge pins this timer's overage line onto + // the app's ONE creation invoice (app-inv-) using the SHARED per-timer + // item key. cmd/billing-cycle runs the proration sweep BEFORE this one so the + // happy path resolves these timers first (they never reach here). But if that + // sweep's persist phase FAILED after its Stripe calls already finalized the + // combined invoice (money moved, lines pinned), the timer is still unresolved + // when this sweep runs in the SAME process — and minting our OWN invoice + // (mod-overage-inv-) here would double-charge overage the combined + // invoice already collected. So DEFER (skip WITHOUT resolving): the proration + // sweep retries every cycle and converges on the SAME combined invoice via + // the deterministic keys, then marks this timer resolved, dropping it from + // this work list. A LATER install (installed_at != created_at) is never + // co-created, so it charges here normally. app, found, err := s.store.AppMirror(ctx, cand.AppID) if err != nil { return nil, billing.Internal("app mirror lookup failed", err) @@ -235,17 +235,24 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan return res, nil } - // Charge via a per-timer invoice with deterministic idem keys derived from the - // timer id (the stable charge identity — each install charges at most once, - // the grace_resolved guard), so a crash-retry reuses the SAME Stripe objects. + // Charge via a per-timer draft→pinned-item→finalize flow with deterministic + // idem keys derived from the timer id (the stable charge identity — each + // install charges at most once, the grace_resolved guard), so a crash-retry + // reuses the SAME Stripe objects. The item is PINNED to this timer's own + // draft (C2 — a floating pending item could be swept onto another leg's + // invoice); only the finalize step moves money. desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) - item, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)) + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "timer:"+cand.ID.String(), moduleOverageInvoiceIdemKey(cand.ID)) + if err != nil { + return nil, billing.StripeError("module overage draft invoice failed", err) + } + item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)) if err != nil { return nil, billing.StripeError("module overage invoice item failed", err) } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, moduleOverageInvoiceIdemKey(cand.ID)) + inv, err := s.stripe.FinalizeInvoice(ctx, draft.ID, moduleOverageFinalizeIdemKey(cand.ID)) if err != nil { - return nil, billing.StripeError("module overage invoice failed", err) + return nil, billing.StripeError("module overage invoice finalize failed", err) } // Resolve the large-charge disclosure threshold AT CHARGE TIME, immediately @@ -345,3 +352,7 @@ func moduleOverageItemIdemKey(timerID uuid.UUID) string { func moduleOverageInvoiceIdemKey(timerID uuid.UUID) string { return "mod-overage-inv-" + timerID.String() } + +func moduleOverageFinalizeIdemKey(timerID uuid.UUID) string { + return "mod-overage-fin-" + timerID.String() +} diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 11d9ff5..95c5e2f 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -9,6 +9,7 @@ package cycle_test import ( "context" + "errors" "testing" "time" @@ -247,6 +248,63 @@ func TestModuleOverage_GraceInsidePeriodChargesInstallPeriodOnly(t *testing.T) { } } +// --- C2: items are pinned to their own draft; Stripe failures stay retryable -- + +// Regression (review 2026-07-06, C2): every Leg-1 line item is PINNED to the +// timer's own draft invoice (created first, pending_invoice_items_behavior= +// exclude) and money moves only at finalize — a crash at any step leaves an +// inert draft that no other charge leg's invoice can sweep up. Also the first +// Stripe-failure injection on this leg: a failed item or finalize leaves the +// timer unresolved, and the retry replays the SAME deterministic idem keys. +func TestModuleOverage_StripeFailureLeavesTimerRetryableWithSameKeys(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + sweepAt := time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + // Attempt 1: the pinned-item create fails AFTER the draft exists. + sc.errItem = errors.New("stripe: item boom") + res, err := svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err, "a per-timer failure never aborts the batch") + require.Equal(t, 1, res.Failed) + require.Empty(t, sc.finalizeCalls, "no finalize → no money moved") + require.False(t, store.timers[over].graceResolved, "left unresolved for the retry") + require.Empty(t, store.invoices, "nothing mirrored") + + // Attempt 2: the finalize (money-moving step) fails. + sc.errItem = nil + sc.errInvoice = errors.New("stripe: finalize boom") + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Failed) + require.False(t, store.timers[over].graceResolved) + require.Empty(t, store.invoices) + + // Attempt 3 succeeds — through the SAME deterministic keys as both failures. + sc.errInvoice = nil + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + require.GreaterOrEqual(t, len(sc.invoiceCalls), 3) + require.GreaterOrEqual(t, len(sc.itemCalls), 2) + for _, dc := range sc.invoiceCalls { + require.Equal(t, "mod-overage-inv-"+over.String(), dc.idemKey, "every attempt reuses the same draft idem key") + require.Equal(t, "timer:"+over.String(), dc.ref, "the draft carries the charge-ref metadata anchor") + } + for _, ic := range sc.itemCalls { + require.Equal(t, "mod-overage-ii-"+over.String(), ic.idemKey) + require.NotEmpty(t, ic.invoiceID, "the line item is PINNED to the timer's own draft, never a floating pending item") + } + require.Equal(t, "mod-overage-fin-"+over.String(), sc.finalizeCalls[len(sc.finalizeCalls)-1].idemKey) +} + // --- over module with no usable PM is skipped and retried (not resolved) ------ func TestModuleOverage_NoPMSkipsAndRetries(t *testing.T) { diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index d08b562..c2f26ed 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -296,22 +296,32 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, nil // rounds to 0 cents → nothing to invoice (guard stays unarmed) } + // Draft→pinned-items→finalize (C2): the empty draft is created FIRST so + // the base line and every co-created overage line are pinned to THIS + // invoice explicitly — never floating customer-level pending items that a + // concurrently-finalizing leg's invoice could sweep up (or that a crash + // here would leak onto the account's next unrelated invoice). Only the + // finalize step at the end moves money. + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "app-proration:"+locked.AppID.String(), appProrationInvoiceIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration draft invoice failed", err) + } + desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { return nil, billing.StripeError("proration invoice item failed", err) } // Scenario 3 — the combined creation invoice. Modules co-created with the // app (install date == created_at) that are "over" per the live FIFO have // their OWN grace elapse at this SAME instant (same GraceDays anchor), so - // bill them as ADDITIONAL line items on this SAME invoice rather than minting - // a second one. Each is $3 prorated over the IDENTICAL day-0 → period-end - // window as the base (same created_at, so every co-created module is the - // same amount). They use the SAME per-timer idem keys (mod-overage-ii-) - // Leg 1 would use, so if Leg 1 races and charges one first, this - // CreateInvoiceItem returns the already-consumed item and the money lands - // exactly once; the grace_resolved guard (persistProrationCharge) records the - // winner. Priced/marked BEFORE the invoice is created so all lines pool onto it. + // bill them as ADDITIONAL line items PINNED to this SAME draft rather than + // minting a second invoice. Each is $3 over the IDENTICAL coverage window + // as the base (same created_at, so every co-created module is the same + // amount). They use the SAME per-timer item idem keys (mod-overage-ii-) + // Leg 1 would use; the deferred-to-combined guard (overage.go) keeps Leg 1 + // off co-created timers while this charge is pending, and the + // grace_resolved guard (persistProrationCharge) records the winner. overTimers, err := s.store.CoCreatedOverModuleTimers(ctx, locked.AccountID, locked.AppID, locked.CreatedAt, usage.IncludedModules) if err != nil { return nil, billing.Internal("co-created over-module timers lookup failed", err) @@ -333,26 +343,23 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if overageCents > 0 { overDesc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", locked.AppID) for _, timerID := range overTimers { - item, err := s.stripe.CreateInvoiceItem(ctx, custID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) + item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) if err != nil { return nil, billing.StripeError("combined module overage invoice item failed", err) } timerCharges = append(timerCharges, ModuleTimerCharge{ TimerID: timerID, ChargedAt: s.nowFn().UTC(), + InvoiceID: draft.ID, InvoiceItemID: item.ID, }) overageTotalMicros += overageMicros } } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, appProrationInvoiceIdemKey(locked.AppID)) + inv, err := s.stripe.FinalizeInvoice(ctx, draft.ID, appProrationFinalizeIdemKey(locked.AppID)) if err != nil { - return nil, billing.StripeError("proration invoice failed", err) - } - // Stamp the (single) combined-invoice id onto each co-created overage mark. - for i := range timerCharges { - timerCharges[i].InvoiceID = inv.ID + return nil, billing.StripeError("proration invoice finalize failed", err) } // Resolve the account's large-charge disclosure threshold AT CHARGE TIME, @@ -482,11 +489,13 @@ func (s *Service) SweepCreationProrations(ctx context.Context, at time.Time) (*S return res, nil } -// appProrationItemIdemKey / appProrationInvoiceIdemKey build the deterministic -// Stripe Idempotency-Keys for the creation-proration charge. The APP id is the -// stable charge identity (each app prorates at most once — the one-shot +// appProrationItemIdemKey / appProrationInvoiceIdemKey / +// appProrationFinalizeIdemKey build the deterministic Stripe Idempotency-Keys +// for the creation-proration charge's draft→items→finalize flow. The APP id is +// the stable charge identity (each app prorates at most once — the one-shot // proration_invoice_id guard), so a re-attempt (a retried sweep after a crash -// between the Stripe call and the guard-arm) reuses the SAME Stripe objects and -// can never double-charge even before the guard is armed. -func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } -func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } +// between the Stripe calls and the guard-arm) reuses the SAME Stripe objects +// and can never double-charge even before the guard is armed. +func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } +func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } +func appProrationFinalizeIdemKey(appID uuid.UUID) string { return "app-fin-" + appID.String() } diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index 342fe31..2bdd72f 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -133,7 +133,7 @@ func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { require.Equal(t, "cus_apps_1", sc.itemCalls[0].custID) require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) require.Equal(t, "app-inv-"+appID.String(), sc.invoiceCalls[0].idemKey) - require.True(t, sc.invoiceCalls[0].autoAdvance) + require.Len(t, sc.finalizeCalls, 1, "the draft is finalized (auto_advance) — the money-moving step") require.Equal(t, sc.invoiceID, resp.ProrationInvoiceID) mirror := store.invoices[sc.invoiceID] diff --git a/internal/shared/stripe/client.go b/internal/shared/stripe/client.go index c8eb45e..c5b61fa 100644 --- a/internal/shared/stripe/client.go +++ b/internal/shared/stripe/client.go @@ -113,16 +113,46 @@ func (c *realClient) SetDefaultPaymentMethod(ctx context.Context, stripeCustomer return err } -// CreateInvoiceItem creates a pending invoice item on the Customer for one -// metered metric line. amountCents is whole cents (the caller converts -// micro-dollars → cents round-half-up before this call; Stripe amounts are -// integer minor units). The deterministic Idempotency-Key (ii--) -// makes a re-run safe — Stripe returns the original item instead of creating a -// second one. We project to a plain InvoiceItem (id only) so consumers stay -// off stripe-go. -func (c *realClient) CreateInvoiceItem(ctx context.Context, custID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) { +// CreateDraftInvoice creates an EMPTY draft invoice line items are then +// pinned to (CreateInvoiceItem) and that charges only on FinalizeInvoice. +// PendingInvoiceItemsBehavior=exclude is load-bearing (review 2026-07-06, C2): +// it guarantees this invoice can never sweep up another charge leg's orphaned +// customer-level pending item — with several independent item→invoice +// sequences per account, the legacy include behavior pooled a crashed leg's +// item onto the next leg's unrelated invoice. ref is stamped as the +// ms_charge_ref metadata anchor for crash reconciliation. The deterministic +// Idempotency-Key (inv-) makes a re-run reuse the original draft. +func (c *realClient) CreateDraftInvoice(ctx context.Context, custID, ref, idemKey string) (Invoice, error) { + params := &stripego.InvoiceParams{ + Customer: stripego.String(custID), + CollectionMethod: stripego.String(string(stripego.InvoiceCollectionMethodChargeAutomatically)), + AutoAdvance: stripego.Bool(false), + PendingInvoiceItemsBehavior: stripego.String("exclude"), + } + if ref != "" { + params.AddMetadata("ms_charge_ref", ref) + } + params.Context = ctx + params.SetIdempotencyKey(idemKey) + inv, err := c.sc.Invoices.New(params) + if err != nil { + return Invoice{}, err + } + return projectInvoice(inv), nil +} + +// CreateInvoiceItem creates an invoice item PINNED to the given draft invoice +// (never a floating customer-level pending item — see CreateDraftInvoice). +// amountCents is whole cents (the caller converts micro-dollars → cents +// round-half-up before this call; Stripe amounts are integer minor units). The +// deterministic Idempotency-Key makes a re-run safe — Stripe returns the +// original item (already pinned to the same replayed draft) instead of +// creating a second one. We project to a plain InvoiceItem (id only) so +// consumers stay off stripe-go. +func (c *realClient) CreateInvoiceItem(ctx context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) { params := &stripego.InvoiceItemParams{ Customer: stripego.String(custID), + Invoice: stripego.String(invoiceID), Amount: stripego.Int64(amountCents), Currency: stripego.String(currency), } @@ -138,35 +168,35 @@ func (c *realClient) CreateInvoiceItem(ctx context.Context, custID string, amoun return InvoiceItem{ID: item.ID}, nil } -// CreateInvoice creates a draft invoice with -// collection_method=charge_automatically that sweeps up the Customer's pending -// invoice items. With autoAdvance=true Stripe finalizes the draft and runs the -// off-session PaymentIntent against the default PM automatically — the metered -// auto-charge. PendingInvoiceItemsBehavior=include pulls the pending items -// created just above into this invoice (the default behavior changed across -// Stripe API versions; pinning it keeps the line items deterministic). The -// deterministic Idempotency-Key (inv-) makes a re-run reuse the original -// invoice. Projected to a plain Invoice (id/status/amounts) for the mirror. -func (c *realClient) CreateInvoice(ctx context.Context, custID string, autoAdvance bool, idemKey string) (Invoice, error) { - params := &stripego.InvoiceParams{ - Customer: stripego.String(custID), - CollectionMethod: stripego.String(string(stripego.InvoiceCollectionMethodChargeAutomatically)), - AutoAdvance: stripego.Bool(autoAdvance), - PendingInvoiceItemsBehavior: stripego.String("include"), +// FinalizeInvoice finalizes the draft with auto_advance=true — Stripe runs the +// off-session PaymentIntent against the default PM (the metered auto-charge). +// The ONLY money-moving step of the draft→items→finalize flow. The +// deterministic Idempotency-Key (fin-) makes a re-run replay the original +// finalization instead of double-charging. Projected to a plain Invoice +// (id/status/amounts) for the mirror. +func (c *realClient) FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) { + params := &stripego.InvoiceFinalizeInvoiceParams{ + AutoAdvance: stripego.Bool(true), } params.Context = ctx params.SetIdempotencyKey(idemKey) - inv, err := c.sc.Invoices.New(params) + inv, err := c.sc.Invoices.FinalizeInvoice(invoiceID, params) if err != nil { return Invoice{}, err } + return projectInvoice(inv), nil +} + +// projectInvoice maps a stripe-go invoice to the trust-boundary-edge Invoice +// projection the cycle consumer mirrors. +func projectInvoice(inv *stripego.Invoice) Invoice { return Invoice{ ID: inv.ID, Status: string(inv.Status), AmountDue: inv.AmountDue, AmountPaid: inv.AmountPaid, Currency: string(inv.Currency), - }, nil + } } // NewVerifier returns a Verifier for the configured webhook signing diff --git a/internal/shared/stripe/types.go b/internal/shared/stripe/types.go index c6cef76..99ea492 100644 --- a/internal/shared/stripe/types.go +++ b/internal/shared/stripe/types.go @@ -51,28 +51,41 @@ type Client interface { // webhook syncs is_default across the account's mirror rows. SetDefaultPaymentMethod(ctx context.Context, stripeCustomerID, stripePaymentMethodID string) error - // CreateInvoiceItem creates a pending invoice item on the Customer — one - // per metered metric line — that the next CreateInvoice draft sweeps up. - // amountCents is the whole-cent customer charge (micro-dollars are - // converted to cents round-half-up by the caller BEFORE reaching Stripe — - // Stripe amounts are integer minor units, never float). desc is the line - // description shown on the invoice. idemKey is a deterministic Stripe - // Idempotency-Key (ii--) so a re-run / partial-failure resume - // never creates a duplicate line. Unlike the card-management methods this - // returns a plain InvoiceItem (not a stripe-go type) so the cycle consumer - // stays free of stripe-go imports — the charge path is the trust-boundary - // edge and the consumer needs only the id. - CreateInvoiceItem(ctx context.Context, custID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) + // CreateDraftInvoice creates an EMPTY draft invoice + // (collection_method=charge_automatically, auto_advance=false, + // pending_invoice_items_behavior=exclude) that line items are then PINNED + // to via CreateInvoiceItem, and that charges only once FinalizeInvoice + // runs. The exclude behavior is load-bearing (review 2026-07-06, C2): the + // legacy include behavior swept up ALL of the Customer's pending items, so + // with several independent item→invoice sequences per account (boundary + + // per-timer Leg 1 + combined creation) an orphaned item from any crashed + // leg leaked onto the NEXT leg's unrelated invoice — money collected on the + // wrong invoice and a permanent idempotency wedge for the crashed leg. ref + // is the deterministic charge identity ("run:" / "timer:" / + // "app-proration:"), stamped as metadata for crash reconciliation. + // idemKey (inv-) makes a re-run reuse the SAME draft. + CreateDraftInvoice(ctx context.Context, custID, ref, idemKey string) (Invoice, error) - // CreateInvoice creates a draft invoice with - // collection_method=charge_automatically that sweeps up the Customer's - // pending invoice items. When autoAdvance is true, Stripe finalizes the - // draft and attempts an off-session PaymentIntent on the Customer's default - // payment method automatically (the off-session metered charge). idemKey is - // the deterministic per-run Stripe Idempotency-Key (inv-). Returns a - // plain Invoice (id + status + amounts) so the cycle consumer can mirror it - // without importing stripe-go. - CreateInvoice(ctx context.Context, custID string, autoAdvance bool, idemKey string) (Invoice, error) + // CreateInvoiceItem creates an invoice item PINNED to the given draft + // invoice — never a floating customer-level pending item. amountCents is + // the whole-cent customer charge (micro-dollars are converted to cents + // round-half-up by the caller BEFORE reaching Stripe — Stripe amounts are + // integer minor units, never float). desc is the line description shown on + // the invoice. idemKey is a deterministic Stripe Idempotency-Key + // (ii- / mod-overage-ii- / app-ii-) so a re-run / + // partial-failure resume never creates a duplicate line (the replayed item + // is already pinned to the same replayed draft). Returns a plain + // InvoiceItem so the cycle consumer stays free of stripe-go imports. + CreateInvoiceItem(ctx context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) + + // FinalizeInvoice finalizes a draft invoice with auto_advance=true: Stripe + // runs the off-session PaymentIntent against the Customer's default payment + // method (the metered auto-charge). This is the ONLY step that moves money — + // a crash before it leaves an inert draft that can never charge and never + // pollute another leg's invoice. idemKey (fin-) makes a re-run replay + // the original finalization. Returns the finalized invoice projection + // (id/status/amounts) for the mirror. + FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) } // InvoiceItem is the trust-boundary-edge projection of a Stripe invoice item From 6dff73d42dcab7cf499cb46f4a2ced3feb15f9a3 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:55:50 +0800 Subject: [PATCH 17/30] fix: reconcile the frozen boundary charge before every gate; atomic freeze-or-adopt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H8: the frozen-charge lookup ran AFTER the zero-skip and the prepaid/ceiling/risk/PM gates. A reclaimed run whose prior attempt had already put money through Stripe could be re-gated into skipped_prepaid/skipped_no_pm — or zero-skipped 'invoiced' when the live total collapsed — WITHOUT mirroring the charge that already fired: unmirrored money now, a fresh double-charge once the idem keys age out. The frozen lookup now runs FIRST; a frozen run's only job is to finish (replay the same Stripe objects, mirror, mark). The PM gate is deliberately bypassed on the frozen path: an idem-key replay needs no fresh authorization, and a genuinely fresh finalize with the PM removed fails loudly into the retryable 'failed' path instead of a skip. H6: FreezeBillingRunCharge was write-first-wins but the charger kept its LOCALLY computed amount — two daemons reclaiming the same run could send different bodies under the shared idem keys. The freeze now returns the SURVIVING row value and the charger adopts it. M4: the disclosure flag is computed from the amount actually sent to Stripe when it differs from the live recompute (frozen reclaim / lost freeze race), not from a live total describing a charge that never happened. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge.go | 212 ++++++++++++++++--------- internal/account/cycle/charge_test.go | 100 ++++++++++++ internal/account/cycle/service_test.go | 18 ++- internal/account/cycle/store.go | 36 +++-- 4 files changed, 274 insertions(+), 92 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 221dbaf..814822e 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -94,6 +94,22 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return &ChargeSummary{FirstRun: false}, nil } + // FROZEN-CHARGE RECONCILIATION — resolved FIRST, before ANY gate or skip + // (review 2026-07-06, H8). A frozen charge means a prior attempt of this run + // committed to a Stripe request under the deterministic idem keys — and may + // have already MOVED MONEY before crashing short of MarkBillingRun. From that + // point the run's ONE job is to finish: replay the same Stripe objects, + // mirror the invoice, mark invoiced. Every early-out that used to run first + // (prepaid fast-path, zero-skip, spend ceiling, risk judge, PM gate) would + // record the run as skipped/invoiced WITHOUT mirroring the charge that + // already fired — unmirrored money now, and a fresh double-charge after the + // idem keys age out. So each of those gates below applies ONLY when no + // frozen charge exists. + frozen, hasFrozen, err := s.store.BillingRunFrozenCharge(ctx, runID) + if err != nil { + return nil, billing.Internal("frozen boundary charge lookup failed", err) + } + // RISK-GRADED COLLECTION GATE (PR #9, design §7-A / billing-tiers §3). Load // the account's collection state up front. The off-session arrears leg may // only ship behind this gate (the GA gate); the run row already exists, so a @@ -107,8 +123,10 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // Fast path: an account ALREADY in prepaid mode never reads aggregates or // touches Stripe — the off-session arrears leg is not permitted. The usage is // RETAINED (usage_aggregates untouched); the prepaid-credit wallet that would - // settle it is a DEFERRED follow-up. - if acct.Mode == BillingModePrepaid { + // settle it is a DEFERRED follow-up. Never applied over a frozen charge (H8): + // a mode tightened AFTER the crashed attempt charged must not strand the + // moved money unmirrored. + if !hasFrozen && acct.Mode == BillingModePrepaid { if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedPrepaid, "", 0); err != nil { return nil, billing.Internal("mark billing run (skipped_prepaid) failed", err) } @@ -191,8 +209,11 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // period with no live apps or ongoing over-modules) is there nothing to // invoice — mark invoiced with NO Stripe call, never auto-create a Customer // with nothing to charge. A zero total can never breach a limit/ceiling, so - // this short-circuits ahead of the risk gate. - if boundaryTotal == 0 { + // this short-circuits ahead of the risk gate. Never applied over a frozen + // charge (H8): a reclaimed run whose LIVE total collapsed to 0 (a module + // uninstalled, an app deleted since the crash) still owes the mirror + mark + // for the non-zero amount the crashed attempt already put through Stripe. + if !hasFrozen && boundaryTotal == 0 { if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { return nil, billing.Internal("mark billing run (zero arrears) failed", err) } @@ -208,8 +229,9 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // overage never trip a cap that exists to guard against USAGE surprises. (When // a breach skips, the whole invoice — base + overage included — waits for the // re-attempt, keeping one-invoice-per-boundary.) Independent of mode/credit- - // limit (a hard cap, not a trust judgment). - if collection.ExceedsSpendCeiling(toCollectionAccount(acct), arrears) { + // limit (a hard cap, not a trust judgment). Never applied over a frozen + // charge (H8) — the crashed attempt's money may already have moved. + if !hasFrozen && collection.ExceedsSpendCeiling(toCollectionAccount(acct), arrears) { // skipped_ceiling, NOT skipped_prepaid: the ceiling is a per-cycle cap, not // a mode transition — the account stays in arrears mode and the next cycle // re-attempts once the ceiling is raised or the arrears net below it. The @@ -232,49 +254,71 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // prepaid → arrears. The relax driver lives in the webhook (invoice.paid with // no remaining open delinquency → RelaxCollectionOnPaidInvoice) so a relax is // driven by a real successful-payment signal and is decoupled from charging — - // an account is never relaxed and charged in the same beat. TODO(#9-followup): - // wire a usage-spike anomaly signal + a sustained-clean-standing window. - delinquent, err := s.store.HasUnpaidInvoice(ctx, accountID) - if err != nil { - return nil, billing.Internal("delinquency lookup failed", err) - } - decision := collection.RiskAssess( - toCollectionAccount(acct), - collection.Signals{Delinquent: delinquent, AccruedArrearsMicros: arrears}, - false, // cleanStanding: the charge cycle never auto-relaxes (relax is webhook-driven) - ) - if decision.Action == collection.ActionSkipPrepaid { - summary.Status = RunStatusSkippedPrepaid - if decision.ModeChanged { - // A fresh tighten: persist the prepaid mode AND mark the run skipped in - // ONE transaction (TightenAndMarkRun) so a crash can't strand the account - // tightened with the run row still 'pending'. - updated := acct - updated.Mode = BillingMode(decision.DesiredMode) - if err := s.store.TightenAndMarkRun(ctx, accountID, updated, runID, RunStatusSkippedPrepaid); err != nil { - return nil, billing.Internal("tighten and mark billing run failed", err) + // an account is never relaxed and charged in the same beat. Never applied + // over a frozen charge (H8) — a delinquency signal raised by the crashed + // attempt's OWN open invoice must not strand that invoice unmirrored. + // TODO(#9-followup): wire a usage-spike anomaly signal + a + // sustained-clean-standing window. + if !hasFrozen { + delinquent, err := s.store.HasUnpaidInvoice(ctx, accountID) + if err != nil { + return nil, billing.Internal("delinquency lookup failed", err) + } + decision := collection.RiskAssess( + toCollectionAccount(acct), + collection.Signals{Delinquent: delinquent, AccruedArrearsMicros: arrears}, + false, // cleanStanding: the charge cycle never auto-relaxes (relax is webhook-driven) + ) + if decision.Action == collection.ActionSkipPrepaid { + summary.Status = RunStatusSkippedPrepaid + if decision.ModeChanged { + // A fresh tighten: persist the prepaid mode AND mark the run skipped in + // ONE transaction (TightenAndMarkRun) so a crash can't strand the account + // tightened with the run row still 'pending'. + updated := acct + updated.Mode = BillingMode(decision.DesiredMode) + if err := s.store.TightenAndMarkRun(ctx, accountID, updated, runID, RunStatusSkippedPrepaid); err != nil { + return nil, billing.Internal("tighten and mark billing run failed", err) + } + return summary, nil + } + // Already prepaid (no transition to persist): just mark the run skipped. + if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedPrepaid, "", 0); err != nil { + return nil, billing.Internal("mark billing run (skipped_prepaid) failed", err) } return summary, nil } - // Already prepaid (no transition to persist): just mark the run skipped. - if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedPrepaid, "", 0); err != nil { - return nil, billing.Internal("mark billing run (skipped_prepaid) failed", err) - } - return summary, nil } - // No usable default PM (or the usable-PM-implies-Customer anomaly): skip - // (usage RETAINED), re-attempt next cycle. - custID, ok, err := s.resolveChargeableCustomer(ctx, accountID) - if err != nil { - return nil, err - } - if !ok { - if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedNoPM, "", 0); err != nil { - return nil, billing.Internal("mark billing run (skipped_no_pm) failed", err) + // PM gate. Fresh run: no usable default PM (or the usable-PM-implies-Customer + // anomaly) → skip (usage RETAINED), re-attempt next cycle. Frozen run (H8): + // the PM gate does NOT apply — the idem-key replay of already-created Stripe + // objects needs no fresh authorization, and a genuinely fresh finalize with + // the PM since removed fails loudly into the 'failed' (retryable, auditable) + // path below rather than being recorded as a skip over moved money. Only the + // Customer id is required. + var custID string + if hasFrozen { + custID, err = s.store.AccountStripeCustomer(ctx, accountID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return nil, billing.Internal("billing run has a frozen charge but the account has no Stripe customer id", nil) + } + } else { + var ok bool + custID, ok, err = s.resolveChargeableCustomer(ctx, accountID) + if err != nil { + return nil, err + } + if !ok { + if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedNoPM, "", 0); err != nil { + return nil, billing.Internal("mark billing run (skipped_no_pm) failed", err) + } + summary.Status = RunStatusSkippedNoPM + return summary, nil } - summary.Status = RunStatusSkippedNoPM - return summary, nil } // Resolve the NEW period's window for the base snapshots BEFORE any Stripe @@ -302,46 +346,47 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) } + liveCents := cents // what the LIVE state says; may be replaced by a frozen amount below withBase := advanceBase+advanceOverage > 0 // FREEZE-OR-REUSE the boundary Stripe request (crash-safe idempotency, - // migration 035). The idem keys ii-/inv- are STABLE across a reclaim - // of this run, so the request sent under them must be stable too. A prior - // attempt that already reached Stripe froze its computed (cents, withBase); - // REUSE those frozen values rather than the ones just recomputed from LIVE - // state — drift between the crash and this retry (a module uninstalled flipping - // an over-module to included, an app deleted) could have moved the live total, - // and re-sending the same idem key with a different amount/description is the - // permanent Stripe idempotency-conflict stall this guards against (the bug - // ee5043c fixed once for the account-wide model, whose freeze migration 033 - // dropped). Reconciled BEFORE the cents==0 short-circuit so a retry whose live - // total collapsed to 0 still re-charges — and records — the non-zero amount the - // crashed attempt already put through Stripe. - if frozen, ok, err := s.store.BillingRunFrozenCharge(ctx, runID); err != nil { - return nil, billing.Internal("frozen boundary charge lookup failed", err) - } else if ok { + // migration 035). The idem keys inv-/ii-/fin- are STABLE across a + // reclaim of this run, so the request sent under them must be stable too. + // + // - Frozen (read at the very top, before every gate — H8): a prior attempt + // already committed to a Stripe request; REUSE the frozen (cents, + // withBase) verbatim rather than the values just recomputed from LIVE + // state — drift between the crash and this retry (a module uninstalled + // flipping an over-module to included, an app deleted) could have moved + // the live total, and re-sending the same idem key with a different + // amount/description is the permanent Stripe idempotency-conflict stall + // this guards against (the bug ee5043c fixed once for the account-wide + // model, whose freeze migration 033 dropped). + // - Fresh: the cents==0 sub-half-cent short-circuit applies (never call + // Stripe for $0 — an advance base/overage, when present, is always ≥ $3 + // and can never round to 0; nothing was ever put through Stripe for this + // run), then freeze BEFORE the first Stripe call. The freeze is + // first-write-wins AND returns the SURVIVING row value (H6): a concurrent + // second daemon that reclaimed the same run and froze first wins, and + // THIS process adopts the winner's amount — two racing processes can + // never send different bodies under the shared idem keys. + if hasFrozen { cents = frozen.Cents withBase = frozen.WithBase - } - - if cents == 0 { - // A sub-half-cent arrears total (an advance base/overage, when present, is - // always ≥ $3 and can never round to 0) — never call Stripe for $0. The - // reuse above found no prior frozen charge (it would have set cents > 0), so - // nothing was ever put through Stripe for this run. - if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { - return nil, billing.Internal("mark billing run (zero cents) failed", err) + } else { + if cents == 0 { + if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { + return nil, billing.Internal("mark billing run (zero cents) failed", err) + } + summary.Status = RunStatusInvoiced + return summary, nil } - summary.Status = RunStatusInvoiced - return summary, nil - } - - // Freeze the amount + description this run will charge BEFORE the first Stripe - // call. First-write-wins (a reclaim that already froze is a no-op — the reuse - // above already adopted its values), so a crash after Stripe succeeds but before - // MarkBillingRun commits leaves the frozen request durable for the retry. - if err := s.store.FreezeBillingRunCharge(ctx, runID, FrozenBoundaryCharge{Cents: cents, WithBase: withBase}); err != nil { - return nil, billing.Internal("freeze boundary charge failed", err) + surviving, err := s.store.FreezeBillingRunCharge(ctx, runID, FrozenBoundaryCharge{Cents: cents, WithBase: withBase}) + if err != nil { + return nil, billing.Internal("freeze boundary charge failed", err) + } + cents = surviving.Cents + withBase = surviving.WithBase } summary.ChargedCents = cents @@ -381,6 +426,17 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri if err != nil { return nil, billing.Internal("account collection lookup failed (post-charge threshold resolve)", err) } + // The flag describes the money that MOVED (review 2026-07-06, M4). On the + // fresh path that is boundaryTotal (whose cents conversion is exactly what + // was charged — sub-cent precision preserved for the threshold comparison). + // When the charged cents came from a frozen/surviving value that DIFFERS + // from the live recompute (a reclaim after roster drift, or a lost freeze + // race), the live total describes a charge that never happened — flag the + // amount actually sent to Stripe instead. + chargedMicros := boundaryTotal + if cents != liveCents { + chargedMicros = cents * microsPerCent + } if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ AccountID: accountID, StripeInvoiceID: inv.ID, @@ -390,7 +446,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri Currency: chargeCurrency, PeriodStart: periodStart, PeriodEnd: periodEnd, - IsLargeAutoCollect: flagLargeAutoCollect(boundaryTotal, postChargeAcct), + IsLargeAutoCollect: flagLargeAutoCollect(chargedMicros, postChargeAcct), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 613c279..ca45e77 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -246,6 +246,106 @@ func TestRunBillingCycle_ReclaimReusesFrozenBoundaryChargeAmount(t *testing.T) { } } +// Regression (review 2026-07-06, H8): every early-out — the zero-skip and the +// prepaid/ceiling/risk/PM gates — used to run BEFORE the frozen-charge lookup. +// A reclaimed run whose prior attempt already put money through Stripe could be +// marked skipped/invoiced WITHOUT mirroring that charge: unmirrored money now, +// a fresh double-charge after the idem keys age out. The frozen lookup now runs +// FIRST, and a frozen run's only job is to finish. +func TestRunBillingCycle_FrozenRunChargesEvenWhenLiveTotalCollapsesToZero(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 arrears + store.hasPM = true + store.stripeCustomer = "cus_h8" + app := seedApp(store, chargeAccount, 0, false) // + $20 base = $21 + + sc := newFakeStripe() + + // FIRST attempt: Stripe charges $21, crash before MarkBillingRun. + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Len(t, sc.finalizeCalls, 1, "the money moved") + + // Between attempts the LIVE total collapses to zero: the app is deleted and + // the arrears vanish (e.g. an aggregates correction). + a := store.apps[app] + a.Deleted = true + store.apps[app] = a + store.chargedTotal = 0 + store.errMarkRun = nil + + // RETRY: pre-fix the boundaryTotal==0 zero-skip marked the run 'invoiced' + // with NO mirror of the $21 already charged. Fixed: the frozen charge is + // reconciled first — replayed through the same keys, mirrored, marked. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 2100, resp.ChargedCents, "the frozen $21, not the collapsed live $0") + require.Len(t, store.invoices, 1, "the crashed attempt's charge is mirrored") + for _, m := range store.markedRuns { + require.EqualValues(t, 2100, m.totalCents) + } +} + +func TestRunBillingCycle_FrozenRunNotSkippedByPrepaidOrPMGates(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h8b" + + sc := newFakeStripe() + + // FIRST attempt charges $1, crash before mark. + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Len(t, sc.finalizeCalls, 1) + + // Between attempts: the account tightens to prepaid (possibly triggered by + // the crashed attempt's own open invoice) AND its default PM is removed. + store.collection.Mode = cycle.BillingModePrepaid + store.hasPM = false + store.errMarkRun = nil + + // RETRY: pre-fix → skipped_prepaid (or skipped_no_pm), stranding the moved + // money unmirrored. Fixed: gates never apply over a frozen charge; the + // replay completes through the same keys. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status, + "a frozen run finishes; it is never re-gated into a skip over moved money") + require.Len(t, store.invoices, 1) +} + +// Regression (review 2026-07-06, H6): the freeze is first-write-wins AND the +// charger adopts the SURVIVING value — a concurrent second daemon that +// reclaimed the same run and froze first wins, so both processes send Stripe +// the same body under the shared idem keys. +func TestRunBillingCycle_LostFreezeRaceAdoptsWinnersAmount(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // this process computes $1 → 100¢ + store.hasPM = true + store.stripeCustomer = "cus_h6" + + sc := newFakeStripe() + + // A concurrent daemon B froze $47 in the race window between this process's + // top-of-run frozen read (empty) and its own freeze attempt. + store.onFreezeCharge = func(runID uuid.UUID) { + if _, exists := store.frozenCharges[runID]; !exists { + store.frozenCharges[runID] = cycle.FrozenBoundaryCharge{Cents: 4700, WithBase: true} + } + } + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, 4700, resp.ChargedCents, + "the loser of the freeze race must charge the winner's frozen amount, never its own") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 4700, sc.itemCalls[0].amountCfg) +} + func TestRunBillingCycle_CentsRoundHalfUp(t *testing.T) { // 5_000 micros = 0.5 cents → round-half-up → 1 cent. store := newFakeStore() diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 0f842f6..99b9ebd 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -52,6 +52,9 @@ type fakeStore struct { activatedAccounts []cycle.AccountAnchor // ActivatedAccounts return latestPeriodEnd map[uuid.UUID]time.Time // LatestClosedPeriodEnd return (absent → not found) + // onFreezeCharge runs at the top of FreezeBillingRunCharge (see there). + onFreezeCharge func(runID uuid.UUID) + // risk-graded collection inputs (PR #9) collection cycle.AccountCollection // AccountCollection return unpaidInvoice bool // HasUnpaidInvoice return (delinquency signal) @@ -385,14 +388,23 @@ func (f *fakeStore) MarkBillingRun(_ context.Context, runID uuid.UUID, status cy // First-write-wins, mirroring the SQL's WHERE frozen_charge_cents IS NULL: a // reclaim that already froze keeps the ORIGINAL values, so a retry can never // overwrite the amount a crashed attempt already put through Stripe. -func (f *fakeStore) FreezeBillingRunCharge(_ context.Context, runID uuid.UUID, charge cycle.FrozenBoundaryCharge) error { +func (f *fakeStore) FreezeBillingRunCharge(_ context.Context, runID uuid.UUID, charge cycle.FrozenBoundaryCharge) (cycle.FrozenBoundaryCharge, error) { if f.errFreezeCharge != nil { - return f.errFreezeCharge + return cycle.FrozenBoundaryCharge{}, f.errFreezeCharge + } + // onFreezeCharge, when set, runs BEFORE this process's write — modeling a + // concurrent second daemon that reclaimed the same run and froze first (its + // write lands in the race window between the caller's top-of-run frozen read + // and this freeze). Used by the H6 regression test. + if f.onFreezeCharge != nil { + f.onFreezeCharge(runID) } + // First-write-wins, returning the SURVIVING value (mirrors the SQL's + // WHERE frozen_charge_cents IS NULL + read-back). if _, exists := f.frozenCharges[runID]; !exists { f.frozenCharges[runID] = charge } - return nil + return f.frozenCharges[runID], nil } func (f *fakeStore) BillingRunFrozenCharge(_ context.Context, runID uuid.UUID) (cycle.FrozenBoundaryCharge, bool, error) { diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 23cdfd0..d0d8205 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -144,11 +144,13 @@ type Store interface { // FreezeBillingRunCharge records — BEFORE the boundary run's first Stripe // charge — the exact amount + base/overage description determinant it will send // under the deterministic idem keys ii-/inv- (migration 035). - // First-write-wins: a reclaimed run that already froze keeps the ORIGINAL - // values, so a retry recomputing a different LIVE total never sends Stripe a - // mismatched request under the same idem key (the boundary analogue of the - // account_overage_snapshots freeze migration 033 dropped). - FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) error + // First-write-wins, and it returns the SURVIVING row value: when a concurrent + // second daemon reclaimed the same run and froze first, the loser's write + // no-ops and it MUST adopt the returned winner value — charging a locally + // computed amount under the shared idem keys would send Stripe two different + // bodies for the same key (the H6 race). The retry path likewise never sends + // a request that differs from what a prior attempt froze. + FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) (FrozenBoundaryCharge, error) // BillingRunFrozenCharge reads a run's frozen boundary charge; ok=false when no // prior attempt reached the Stripe call (a fresh run). On a reclaim it is the @@ -881,16 +883,28 @@ func (s *pgxStore) MarkBillingRun(ctx context.Context, runID uuid.UUID, status B }) } -func (s *pgxStore) FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) error { +func (s *pgxStore) FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) (FrozenBoundaryCharge, error) { // WHERE frozen_charge_cents IS NULL (in the query) makes this first-write-wins: - // a reclaimed run that already froze affects 0 rows and keeps its ORIGINAL - // frozen amount, which the caller reads back via BillingRunFrozenCharge first - // and reuses — so this only ever writes on the genuine first attempt. - return s.q.FreezeBillingRunCharge(ctx, db.FreezeBillingRunChargeParams{ + // a run that already froze (an earlier attempt, or a CONCURRENT daemon that got + // here first) affects 0 rows and keeps the ORIGINAL frozen amount. The read-back + // below returns the SURVIVING value regardless of which write won, and the + // caller charges THAT — never its locally computed amount — so two racing + // processes can never send different bodies under the shared idem keys. + if err := s.q.FreezeBillingRunCharge(ctx, db.FreezeBillingRunChargeParams{ ID: runID.String(), FrozenChargeCents: pgtype.Int8{Int64: charge.Cents, Valid: true}, FrozenChargeWithBase: pgtype.Bool{Bool: charge.WithBase, Valid: true}, - }) + }); err != nil { + return FrozenBoundaryCharge{}, err + } + surviving, ok, err := s.BillingRunFrozenCharge(ctx, runID) + if err != nil { + return FrozenBoundaryCharge{}, err + } + if !ok { + return FrozenBoundaryCharge{}, fmt.Errorf("billing run %s has no frozen charge immediately after freezing", runID) + } + return surviving, nil } func (s *pgxStore) BillingRunFrozenCharge(ctx context.Context, runID uuid.UUID) (FrozenBoundaryCharge, bool, error) { From c22a19759330b995fcb1f92691ecf6812c39832a Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 10:59:44 +0800 Subject: [PATCH 18/30] =?UTF-8?q?fix:=20timer=20lifecycle=20=E2=80=94=20de?= =?UTF-8?q?leted-app=20guard,=20delete-retry=20self-heal,=20locked=20synth?= =?UTF-8?q?esis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three timer-lifecycle defects from the review: - RegisterApp retry (or billing-backfill re-registration) landing after the app was deleted resurrected K live timers: deletion freezes module_count and soft-removes timers, so the reconcile saw live=0 against the frozen count and re-inserted phantoms that occupied FIFO slots and charged $3 at every boundary with no removal path. Timer synthesis now never runs for a deleted app. - SyncAppModules deletion is two non-transactional writes; a crash between MarkAppDeleted and the timer soft-remove left live orphans, and the retry guard ('req.Deleted && !app.Deleted') skipped the soft-remove forever. The delete signal now re-fires the idempotent soft-remove on every delivery, self-healing the crash window. - Synthesis was an unguarded count-then-insert in a fire-and-forget- with-retry RPC environment: two concurrent executions both read the same live count and both inserted the full deficit (recurring phantom charges). New ReconcileModuleTimersToTarget runs count+write in one transaction under a per-app pg_advisory_xact_lock; both RPCs use it, with an 8-way concurrent integration test pinning the invariant. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/apps.go | 90 ++++++++----------- internal/account/cycle/apps_test.go | 69 ++++++++++++++ .../cycle/migration033_integration_test.go | 39 ++++++++ internal/account/cycle/service_test.go | 22 +++++ internal/account/cycle/store.go | 54 +++++++++++ 5 files changed, 219 insertions(+), 55 deletions(-) diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 3b89e43..98c4d0e 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -163,31 +163,21 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg // Synthesize the app's per-module install timers (migration 033), all anchored // at the app's created_at (the read-back mirror's stable first-registration // value) — the RPC carries only an integer module_count, so K identical timer - // rows stand in for the K co-created module instances. Insert-only reconcile - // makes a fire-and-forget retry a no-op once the first registration already - // synthesized them. - if err := s.ensureModuleTimers(ctx, app.AccountID, app.AppID, app.CreatedAt, app.ModuleCount); err != nil { - return nil, err - } - return resp, nil -} - -// ensureModuleTimers brings an app's live install-timer set UP to targetCount by -// inserting the deficit, all anchored at installedAt (RegisterApp: created_at). -// Insert-only (never removes): reconcile-to-target so a fire-and-forget retry -// that re-runs after the first registration already synthesized the timers -// inserts 0, and a crashed first attempt self-heals on the retry. -func (s *Service) ensureModuleTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt time.Time, targetCount int) error { - live, err := s.store.LiveModuleTimerCountForApp(ctx, appID) - if err != nil { - return billing.Internal("live module timer count lookup failed", err) - } - if deficit := targetCount - live; deficit > 0 { - if err := s.store.InsertModuleOverageTimers(ctx, accountID, appID, installedAt, moduleGraceExpiry(installedAt), deficit); err != nil { - return billing.Internal("insert module overage timers failed", err) + // rows stand in for the K co-created module instances. The reconcile runs + // under a per-app advisory lock (H7 — a concurrent retry can never + // double-insert the deficit) and makes a fire-and-forget retry a no-op once + // the first registration already synthesized them. NEVER for a deleted app + // (review 2026-07-06, H4): a late retry — or a billing-backfill + // re-registration — that lands after the app was deleted must not resurrect + // live timers for it (deletion freezes module_count, so the deficit would + // look real and the phantom timers would charge forever). + if !app.Deleted { + if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AccountID, app.AppID, + app.ModuleCount, app.CreatedAt, moduleGraceExpiry(app.CreatedAt), s.nowFn().UTC()); err != nil { + return nil, billing.Internal("reconcile module overage timers failed", err) } } - return nil + return resp, nil } // SyncAppModules updates an app's roster row: a new installed-module-count @@ -230,14 +220,23 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) now := s.nowFn().UTC() - if req.Deleted && !app.Deleted { - if err := s.store.MarkAppDeleted(ctx, req.AppID); err != nil { - return nil, billing.Internal("mark app deleted failed", err) + if req.Deleted { + if !app.Deleted { + if err := s.store.MarkAppDeleted(ctx, req.AppID); err != nil { + return nil, billing.Internal("mark app deleted failed", err) + } + app.Deleted = true } - app.Deleted = true // Deletion soft-removes ALL the app's still-live install timers — they drop // out of the FIFO and the Leg 1 sweep, so a module deleted with its app is // never charged its overage (no refund of anything already charged, D1e). + // Re-fired on EVERY delete signal, not only the first (review 2026-07-06, + // H4): the two writes are not transactional, so a crash between them + // leaves the app deleted with live orphaned timers — and a retry gated on + // !app.Deleted would skip this block forever, leaving the orphans in the + // FIFO (demoting other apps' modules to "over") and chargeable. The + // soft-remove is idempotent (WHERE removed_at IS NULL), so the re-fire is + // free on the happy path and self-heals the crashed one. if err := s.store.SoftRemoveAllModuleTimersForApp(ctx, req.AppID, now); err != nil { return nil, billing.Internal("soft-remove app module timers failed", err) } @@ -250,10 +249,15 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) return nil, billing.Internal("set app module count failed", err) } app.ModuleCount = *req.ModuleCount - // Reconcile the app's live install timers to the new count: grow inserts - // (anchored at now — genuine new installs), shrink LIFO-removes the newest. - if err := s.syncModuleTimers(ctx, app.AccountID, req.AppID, now, *req.ModuleCount); err != nil { - return nil, err + // Reconcile the app's live install timers to the new count under the + // per-app advisory lock (H7): grow inserts (anchored at now — genuine new + // installs), shrink LIFO-removes the newest. Reconciling against the LIVE + // timer count (not the app's prior module_count) makes it idempotent + // across SyncAppModules retries and self-healing after a crash between + // the count write and the timer write. + if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AccountID, req.AppID, + *req.ModuleCount, now, moduleGraceExpiry(now), now); err != nil { + return nil, billing.Internal("reconcile module overage timers failed", err) } } @@ -263,27 +267,3 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) Deleted: app.Deleted, }, nil } - -// syncModuleTimers reconciles an app's live install-timer set to targetCount: a -// grow inserts the deficit anchored at `now` (each a genuine new install on its -// own grace timer), a shrink LIFO-soft-removes the surplus (newest installs -// first). Reconciling against the LIVE timer count (not the app's prior -// module_count) makes it idempotent across SyncAppModules retries and -// self-healing after a crash between the count write and the timer write. -func (s *Service) syncModuleTimers(ctx context.Context, accountID, appID uuid.UUID, now time.Time, targetCount int) error { - live, err := s.store.LiveModuleTimerCountForApp(ctx, appID) - if err != nil { - return billing.Internal("live module timer count lookup failed", err) - } - switch { - case targetCount > live: - if err := s.store.InsertModuleOverageTimers(ctx, accountID, appID, now, moduleGraceExpiry(now), targetCount-live); err != nil { - return billing.Internal("insert module overage timers failed", err) - } - case targetCount < live: - if err := s.store.SoftRemoveNewestModuleTimers(ctx, appID, live-targetCount, now); err != nil { - return billing.Internal("soft-remove module overage timers failed", err) - } - } - return nil -} diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index ea46411..2451396 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -116,6 +116,75 @@ func TestRegisterApp_SynthesizesPerModuleTimersButNeverCharges(t *testing.T) { } } +// Regression (review 2026-07-06, H4): a late RegisterApp retry — or a +// billing-backfill re-registration — that lands AFTER the app was deleted must +// not resurrect live timers. Deletion freezes module_count (it is not zeroed) +// and soft-removes all timers, so the pre-fix reconcile saw live=0 against the +// frozen count and re-inserted K phantom timers for a DELETED app, which then +// occupied FIFO slots and charged at every boundary with no removal path. +func TestRegisterApp_RetryAfterDeletionDoesNotResurrectTimers(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + created := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) + + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: 4, CreatedAt: created, + }) + require.NoError(t, err) + require.Equal(t, 4, liveTimerCount(store, appID)) + + // The app is deleted (timers soft-removed, module_count frozen at 4). + _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Zero(t, liveTimerCount(store, appID)) + + // The fire-and-forget RegisterApp RETRY lands late. + _, err = svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: 4, CreatedAt: created, + }) + require.NoError(t, err) + require.Zero(t, liveTimerCount(store, appID), + "a deleted app's timers must never be resurrected by a late retry") +} + +// Regression (review 2026-07-06, H4): SyncAppModules deletion is two +// non-transactional writes (MarkAppDeleted, then the timer soft-remove). A +// crash between them leaves the app deleted with live orphaned timers — and +// the pre-fix retry guard (`req.Deleted && !app.Deleted`) skipped the +// soft-remove forever on the re-fire, leaving the orphans in the account FIFO +// (demoting other apps' modules to "over") and chargeable. The delete signal +// now re-fires the idempotent soft-remove every time. +func TestSyncAppModules_DeleteRetrySelfHealsOrphanedTimers(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: 3, + CreatedAt: time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), + }) + require.NoError(t, err) + require.Equal(t, 3, liveTimerCount(store, appID)) + + // Simulate the crash window: the app row was marked deleted, but the timer + // soft-remove never committed. + app := store.apps[appID] + app.Deleted = true + store.apps[appID] = app + require.Equal(t, 3, liveTimerCount(store, appID), "orphaned live timers of a deleted app") + + // The fire-and-forget delete RETRY must self-heal the orphans. + _, err = svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Zero(t, liveTimerCount(store, appID), + "the delete re-fire soft-removes the orphaned timers instead of skipping on !app.Deleted") +} + func TestRegisterApp_DefaultsCreatedAtToNow(t *testing.T) { // Zero CreatedAt → the server's now (appsNow, Jul 1) is stamped on the mirror // row (the anchor the later sweep prorates from). Still no charge here. diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index bdd3da3..196165d 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -95,6 +95,45 @@ func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { require.Zero(t, n) } +// Regression (review 2026-07-06, H7): timer synthesis is a count-then-insert +// reconcile in a fire-and-forget-with-retry RPC environment — two CONCURRENT +// executions for the same app used to both read the same live count and both +// insert the full deficit, minting phantom timers wrongfully charged $3 each at +// every boundary. ReconcileModuleTimersToTarget serializes per app under a +// pg_advisory_xact_lock; hammer it concurrently and assert the invariant. +func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + 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)) + + const workers = 8 + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + go func() { + errs <- store.ReconcileModuleTimersToTarget(ctx, acct, app, 7, created, created.AddDate(0, 0, 3), created) + }() + } + for i := 0; i < workers; i++ { + require.NoError(t, <-errs) + } + + n, err := store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 7, n, "concurrent reconciles must never double-insert the deficit") + + // And a concurrent grow/shrink mix still converges to the LAST target + // applied — each reconcile is atomic, so no interleaving can overshoot. + require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, acct, app, 3, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-20T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 3, n) +} + // Stage B: the row_number()-windowed reads backing scenario 3 (CoCreatedOverModuleTimers), // scenario 6 / Leg 2 (CountOngoingOverModuleTimers), and the display // (CountLiveModuleTimersForAccount) — validated against real Postgres, since the diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 99b9ebd..9fcd4c6 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -130,6 +130,7 @@ type fakeStore struct { errLiveTimerCount error // LiveModuleTimerCountForApp errInsertTimers error // InsertModuleOverageTimers + errReconcileTimers error // ReconcileModuleTimersToTarget errRemoveNewest error // SoftRemoveNewestModuleTimers errRemoveAllTimers error // SoftRemoveAllModuleTimersForApp errTimersPastGrace error // ModuleOverageTimersPastGrace @@ -713,6 +714,27 @@ func (f *fakeStore) SoftRemoveNewestModuleTimers(_ context.Context, appID uuid.U return nil } +func (f *fakeStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error { + if f.errReconcileTimers != nil { + return f.errReconcileTimers + } + // Mirrors the pgx locked reconcile: count live, insert the deficit anchored + // at installedAt/graceExpiresAt, or LIFO-remove the surplus at removedAt. + // (Unit tests are single-threaded; the advisory-lock serialization itself is + // exercised by the integration test.) + live, err := f.LiveModuleTimerCountForApp(ctx, appID) + if err != nil { + return err + } + switch { + case target > live: + return f.InsertModuleOverageTimers(ctx, accountID, appID, installedAt, graceExpiresAt, target-live) + case target < live: + return f.SoftRemoveNewestModuleTimers(ctx, appID, live-target, removedAt) + } + return nil +} + func (f *fakeStore) SoftRemoveAllModuleTimersForApp(_ context.Context, appID uuid.UUID, removedAt time.Time) error { if f.errRemoveAllTimers != nil { return f.errRemoveAllTimers diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index d0d8205..06957d6 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -302,6 +302,17 @@ type Store interface { // for an app — the app-deletion path. Idempotent (WHERE removed_at IS NULL). SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error + // ReconcileModuleTimersToTarget brings an app's live install-timer set to + // exactly target, ATOMICALLY under a per-app advisory transaction lock + // (review 2026-07-06, H7): a grow inserts the deficit anchored at + // installedAt/graceExpiresAt, a shrink LIFO-soft-removes the surplus at + // removedAt. The lock is what makes the count-then-write reconcile safe + // against the fire-and-forget-with-retry RPC environment — two concurrent + // RegisterApp/SyncAppModules executions for the same app used to both read + // the same live count and both insert the full deficit, minting phantom + // timers that were then wrongfully charged $3 each at every boundary. + ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error + // ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install // timers whose grace window has elapsed as of `at`, on chargeable (activated) // accounts — each with the account's activation anchor so the sweep resolves @@ -1357,6 +1368,49 @@ func (s *pgxStore) InsertModuleOverageTimers(ctx context.Context, accountID, app }) } +// ReconcileModuleTimersToTarget — count + write in ONE transaction serialized +// by a per-app advisory xact lock, so concurrent RegisterApp/SyncAppModules +// retries can never both observe the same live count and double-insert (H7). +// The lock key is derived from the app id; pg_advisory_xact_lock releases on +// commit/rollback automatically. +func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "module-timers:"+appID.String()); err != nil { + return err + } + live, err := qtx.LiveModuleTimerCountForApp(ctx, appID.String()) + if err != nil { + return err + } + switch { + case int64(target) > live: + if err := qtx.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ + AccountID: accountID.String(), + AppID: appID.String(), + InstalledAt: installedAt, + GraceExpiresAt: graceExpiresAt, + Count: int32(int64(target) - live), //nolint:gosec // bounded by maxModuleCount (100000) + }); err != nil { + return err + } + case int64(target) < live: + if err := qtx.SoftRemoveNewestModuleTimers(ctx, db.SoftRemoveNewestModuleTimersParams{ + AppID: appID.String(), + RemovedAt: removedAt, + LimitCount: int32(live - int64(target)), //nolint:gosec // bounded by maxModuleCount (100000) + }); err != nil { + return err + } + } + return tx.Commit(ctx) +} + func (s *pgxStore) SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error { if n <= 0 { return nil From 3f05525a90505047fdfe8788b8559fbbbc965c30 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 11:01:36 +0800 Subject: [PATCH 19/30] fix: prepaid accounts are never auto-charged by the creation or module-overage legs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary spine always gated off-session charging on the account's collection mode, but the two NEW charge legs (creation proration and per-module overage) bypassed it entirely — a prepaid-mode account (tightened after delinquency, or opted in) could still be auto-charged by them. Both legs now share one offSessionChargePermitted gate: a prepaid account is skipped TRANSIENTLY (nothing resolved, guard unarmed), and a webhook-driven relax back to arrears lets the deferred charge fire through the unchanged deterministic idem keys. The spend ceiling deliberately stays boundary-only: it caps USAGE surprises, and by the spine's own documented design the predictable base fee + overage never trip it. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/overage.go | 15 ++++++++++++ internal/account/cycle/overage_test.go | 31 ++++++++++++++++++++++++ internal/account/cycle/proration.go | 14 +++++++++++ internal/account/cycle/proration_test.go | 27 +++++++++++++++++++++ internal/account/cycle/service.go | 15 ++++++++++++ 5 files changed, 102 insertions(+) diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 74391c9..9897cb1 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -70,6 +70,11 @@ const ( // ModuleOverageSkippedNoPM: "over" but no usable default PM — left unresolved, // re-attempted on the next sweep through the SAME per-timer idem keys. ModuleOverageSkippedNoPM ModuleOverageStatus = "skipped_no_pm" + // ModuleOverageSkippedPrepaid: "over" but the account is in PREPAID collection + // mode — off-session auto-charges are not permitted (H10, the same gate the + // boundary spine applies). Left unresolved and re-attempted: a webhook-driven + // relax back to arrears lets the deferred charge fire through the same keys. + ModuleOverageSkippedPrepaid ModuleOverageStatus = "skipped_prepaid" // ModuleOverageSkippedZeroCents: "over" but the prorated overage rounded to 0 // cents (unreachable for a real ≥1-day over module at $3) — resolved with no // charge so it never re-sweeps forever. @@ -224,6 +229,16 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan } res.ChargedCents = cents + // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is never + // auto-charged off-session by ANY leg. Skip WITHOUT resolving — a relax back + // to arrears re-attempts through the same keys. + if permitted, err := s.offSessionChargePermitted(ctx, cand.AccountID); err != nil { + return nil, err + } else if !permitted { + res.Status = ModuleOverageSkippedPrepaid + return res, nil + } + // PM gate (same posture as the proration leg): no usable PM → skip WITHOUT // resolving, re-attempted next sweep (the per-timer idem keys stay stable). custID, ok, err := s.resolveChargeableCustomer(ctx, cand.AccountID) diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 95c5e2f..bc2423a 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -248,6 +248,37 @@ func TestModuleOverage_GraceInsidePeriodChargesInstallPeriodOnly(t *testing.T) { } } +// Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged +// off-session — the boundary spine always gated on this, but the per-module +// grace leg bypassed it entirely. The skip is transient: nothing is resolved, +// and a relax back to arrears charges through the same keys. +func TestModuleOverage_PrepaidAccountSkippedNotCharged(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + store.collection.Mode = cycle.BillingModePrepaid + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + sweepAt := time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + res, err := svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Skipped) + require.Zero(t, res.Charged) + require.Empty(t, sc.invoiceCalls, "a prepaid account is never auto-charged by Leg 1") + require.False(t, store.timers[over].graceResolved, "transient skip — nothing resolved") + + // The account relaxes back to arrears → the deferred charge fires. + store.collection.Mode = cycle.BillingModeArrears + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) +} + // --- C2: items are pinned to their own draft; Stripe failures stay retryable -- // Regression (review 2026-07-06, C2): every Leg-1 line item is PINNED to the diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index c2f26ed..b655654 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -68,6 +68,11 @@ const ( // ProrationStatusNoPM: activated but no usable default PM → skipped, same // posture as the boundary spine; re-attempted on the next sweep. ProrationStatusNoPM ProrationStatus = "skipped_no_pm" + // ProrationStatusPrepaid: the account is in PREPAID collection mode — + // off-session auto-charges are not permitted (H10, the same gate the + // boundary spine applies). Transient like no-PM: re-attempted once a + // webhook-driven relax flips the account back to arrears. + ProrationStatusPrepaid ProrationStatus = "skipped_prepaid" // ProrationStatusNoCharge: the proration rounded to 0 cents (effectively // unreachable for a real survived app whose base is ≥ $20) → nothing to // invoice, guard left unarmed. @@ -238,6 +243,15 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil } + // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is never + // auto-charged off-session by ANY leg. Transient skip (guard unarmed), like + // no-PM — re-attempted once the account relaxes back to arrears. + if permitted, err := s.offSessionChargePermitted(ctx, app.AccountID); err != nil { + return nil, err + } else if !permitted { + return &ProrationResult{AppID: appID, Status: ProrationStatusPrepaid}, nil + } + // PM gate (D1d), same posture as the boundary spine: activated but no // usable default PM is skipped and re-attempted next sweep (unlike the // period-closed case above, "no PM right now" is not itself evidence the diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index 2bdd72f..cf5d3dd 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -72,6 +72,33 @@ func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { require.Empty(t, store.apps[appID].ProrationInvoiceID) } +// Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged +// off-session — the boundary spine always gated on this, but the creation- +// proration leg bypassed it. Transient skip (guard unarmed); a relax back to +// arrears charges through the same keys. +func TestChargeCreationProration_PrepaidAccountSkippedNotCharged(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + store.collection.Mode = cycle.BillingModePrepaid + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPrepaid, resp.Status) + require.Empty(t, sc.invoiceCalls, "a prepaid account is never auto-charged by the creation leg") + require.Empty(t, store.apps[appID].ProrationInvoiceID, "transient skip — the guard stays unarmed") + + // Relax → the deferred creation charge fires normally. + store.collection.Mode = cycle.BillingModeArrears + resp, err = svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.NotEmpty(t, store.apps[appID].ProrationInvoiceID) +} + // --- (c) a survivor is charged EXACTLY ONCE even if the sweep runs twice ------ func TestSweep_ChargesSurvivorExactlyOnceAcrossReruns(t *testing.T) { diff --git a/internal/account/cycle/service.go b/internal/account/cycle/service.go index ac67432..8feef99 100644 --- a/internal/account/cycle/service.go +++ b/internal/account/cycle/service.go @@ -51,6 +51,21 @@ func periodClosedByActivation(anchorInstant, activatedAt time.Time) (periodStart return periodStart, periodEnd, !activatedAt.Before(periodEnd) } +// offSessionChargePermitted is the collection-mode gate shared by the +// creation-proration and per-module-overage legs (review 2026-07-06, H10): an +// account in PREPAID mode is never auto-charged off-session — by ANY leg, not +// only the boundary spine (which gates itself inside RunBillingCycle). The +// skip must be TRANSIENT (retried, nothing resolved/armed): the webhook-driven +// relax can flip the account back to arrears, at which point the deferred +// charges fire through their unchanged deterministic idem keys. +func (s *Service) offSessionChargePermitted(ctx context.Context, accountID uuid.UUID) (bool, error) { + acct, err := s.store.AccountCollection(ctx, accountID) + if err != nil { + return false, billing.Internal("account collection lookup failed", err) + } + return acct.Mode != BillingModePrepaid, nil +} + // resolveChargeableCustomer is the PM-gate + Stripe-customer resolution shared // by all three charge legs (RunBillingCycle, ChargeCreationProration, // ChargeModuleOverage): no usable default PM -> ok=false (the caller's own From 405f2ae95e7eb15d3e8f1ca1c082e09ec5b7a63f Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 11:16:30 +0800 Subject: [PATCH 20/30] fix: crash recovery beyond Stripe's idempotency-key window + charge-time re-verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H5: every charge site's crash story rested solely on deterministic idem keys — but Stripe prunes keys at ~24h and the retry driver is a daily cron, so the first retry sat exactly on that boundary and any later one was guaranteed to mint brand-new Stripe objects and double-charge. Migration 036 adds charge-attempt markers (timers: charge_attempted_at; apps: proration_attempted_at — the boundary leg's marker is the migration-035 freeze), stamped first-write-wins BEFORE the first Stripe call. A retry that sees its marker set reconciles against Stripe via the ms_charge_ref metadata anchor (FindInvoiceByRef, Stripe Search API) before recomputing anything: a finalized invoice is adopted (mirrored + marked, zero new objects); an inert draft is completed (the deterministic line attached only if it never landed — a mismatched draft is refused loudly); nothing found means nothing moved → charge fresh. Gates never strand an attempted charge (the proration analogue of the boundary H8 rule). H9: Leg-1's retry used to recompute the FIFO verdict live, so a crash after Stripe succeeded + an earlier module's removal resolved the timer 'included' — money moved with no mirror, no mark, no disclosure. Recovery now runs BEFORE the live verdict: a rank flip can never orphan a real charge. M2: sweep candidates are re-verified (still live + unresolved) immediately before acting — the work list is read once and can be minutes stale for late candidates. Co-Authored-By: Claude Fable 5 --- internal/account/billing/service_test.go | 4 + internal/account/cycle/charge.go | 67 +++++- internal/account/cycle/charge_test.go | 59 ++++- internal/account/cycle/overage.go | 211 +++++++++++++++--- internal/account/cycle/overage_test.go | 126 +++++++++++ internal/account/cycle/proration.go | 198 +++++++++++----- internal/account/cycle/proration_test.go | 30 +++ internal/account/cycle/service_test.go | 37 ++- internal/account/cycle/store.go | 57 ++++- internal/account/db/apps.sql.go | 60 +++-- internal/account/db/models.go | 4 + internal/account/db/module_timers.sql.go | 61 ++++- internal/account/db/queries/apps.sql | 13 +- internal/account/db/queries/module_timers.sql | 28 ++- internal/shared/stripe/client.go | 24 ++ internal/shared/stripe/types.go | 10 + .../036_charge_attempt_markers.down.sql | 7 + .../billing/036_charge_attempt_markers.up.sql | 33 +++ 18 files changed, 890 insertions(+), 139 deletions(-) create mode 100644 migrations/billing/036_charge_attempt_markers.down.sql create mode 100644 migrations/billing/036_charge_attempt_markers.up.sql diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index 8c535da..cf38952 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -259,6 +259,10 @@ func (f *fakeStripe) FinalizeInvoice(context.Context, string, string) (billingst panic("FinalizeInvoice must not be called by the billing package") } +func (f *fakeStripe) FindInvoiceByRef(context.Context, string, string) (billingstripe.Invoice, bool, error) { + panic("FindInvoiceByRef must not be called by the billing package") +} + // --- tests ---------------------------------------------------------------- func TestEnsure_NoAccount_ReturnsMissingBillingAccount(t *testing.T) { diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 814822e..61a5110 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -390,9 +390,14 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } summary.ChargedCents = cents - // Charge. A failure after the PM gate marks the run 'failed' (auditable) and - // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, withBase) + // Charge — or, on a frozen reclaim, RECONCILE against Stripe first (H5): the + // frozen marker means a prior attempt reached its Stripe section, and past + // the ~24h idempotency-key window a bare "replay" would mint brand-new + // objects and double-charge. boundaryInvoice looks the run's invoice up by + // its ms_charge_ref anchor and finishes whatever it finds; only when Stripe + // has nothing does it charge fresh. A failure after the PM gate marks the + // run 'failed' (auditable) and returns the error. + inv, err := s.boundaryInvoice(ctx, runID, custID, cents, withBase, hasFrozen) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -490,20 +495,64 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // text. Returns the finalized invoice projection (id/status/amounts) for the // mirror upsert. func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { - desc := fmt.Sprintf("MirrorStack usage — run %s", runID) - if withBase { - desc = fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) - } - draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "run:"+runID.String(), invoiceIdemKey(runID)) + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, boundaryChargeRef(runID), invoiceIdemKey(runID)) if err != nil { return billingstripe.Invoice{}, err } - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { return billingstripe.Invoice{}, err } return s.stripe.FinalizeInvoice(ctx, draft.ID, invoiceFinalizeIdemKey(runID)) } +// boundaryInvoice resolves the boundary run's Stripe invoice. reconcile=true +// (a frozen reclaim — a prior attempt reached its Stripe section) looks the +// invoice up by the run's ms_charge_ref anchor first (H5 — idem keys are +// pruned by Stripe after ~24h, so key replay alone cannot be trusted on a +// late reclaim): a finalized invoice is returned as-is (money already moved); +// an inert draft is completed — the deterministic line attached if it never +// landed, then finalized; a mismatched draft is refused loudly. Only when +// Stripe has nothing under the ref (the crashed attempt never created its +// invoice) — or on a fresh run — does it charge through s.charge. +func (s *Service) boundaryInvoice(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase, reconcile bool) (billingstripe.Invoice, error) { + if reconcile { + found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, boundaryChargeRef(runID)) + if err != nil { + return billingstripe.Invoice{}, err + } + if ok { + if found.Status != "draft" { + return found, nil // already finalized — the money moved; just mirror it + } + switch found.AmountDue { + case 0: + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { + return billingstripe.Invoice{}, err + } + case cents: + // line already attached — nothing to add + default: + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: draft %s carries %d cents but the frozen amount is %d — refusing to finalize a mismatched draft (run %s)", + found.ID, found.AmountDue, cents, runID) + } + return s.stripe.FinalizeInvoice(ctx, found.ID, invoiceFinalizeIdemKey(runID)) + } + } + return s.charge(ctx, runID, custID, cents, withBase) +} + +// boundaryChargeRef is the deterministic ms_charge_ref metadata anchor for one +// boundary run's invoice — what FindInvoiceByRef recovers by. +func boundaryChargeRef(runID uuid.UUID) string { return "run:" + runID.String() } + +func boundaryChargeDesc(runID uuid.UUID, withBase bool) string { + if withBase { + return fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) + } + return fmt.Sprintf("MirrorStack usage — run %s", runID) +} + // AccountsWithUsageEvents returns the accounts with raw usage_events in the // [periodStart, periodEnd) window — the rollup-phase (phase 1) work list // cmd/billing-cycle iterates before charging. A thin pass-through to the store. diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index ca45e77..d45ac4d 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -35,9 +35,15 @@ type fakeStripe struct { invoiceAmountPaid int64 // injected errors - errItem error - errDraft error - errInvoice error // injected on FinalizeInvoice — the money-moving step + errItem error + errDraft error + errInvoice error // injected on FinalizeInvoice — the money-moving step + errFindByRef error + + // crash-recovery lookup (FindInvoiceByRef): the invoice "found" on Stripe + // under any ref, and the refs queried. + findByRef *billingstripe.Invoice + findByRefCalls []string // onCreateInvoice, when set, runs INSIDE FinalizeInvoice right before it // returns success — modeling a concurrent account mutation (e.g. a // threshold edit) that lands while the real Stripe HTTP call is in @@ -91,6 +97,20 @@ func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID, invoiceID stri return billingstripe.InvoiceItem{ID: "ii_test_" + uuid.NewString()}, nil } +// findByRef, when set, is what FindInvoiceByRef returns — models the crash- +// recovery lookup finding a crashed attempt's invoice on Stripe. nil = not +// found (the default: nothing ever reached Stripe under the ref). +func (f *fakeStripe) FindInvoiceByRef(_ context.Context, _, ref string) (billingstripe.Invoice, bool, error) { + f.findByRefCalls = append(f.findByRefCalls, ref) + if f.errFindByRef != nil { + return billingstripe.Invoice{}, false, f.errFindByRef + } + if f.findByRef != nil { + return *f.findByRef, true, nil + } + return billingstripe.Invoice{}, false, nil +} + func (f *fakeStripe) FinalizeInvoice(_ context.Context, invoiceID, idemKey string) (billingstripe.Invoice, error) { f.finalizeCalls = append(f.finalizeCalls, finalizeCall{invoiceID, idemKey}) if f.errInvoice != nil { @@ -346,6 +366,39 @@ func TestRunBillingCycle_LostFreezeRaceAdoptsWinnersAmount(t *testing.T) { require.EqualValues(t, 4700, sc.itemCalls[0].amountCfg) } +// Regression (review 2026-07-06, H5): a frozen reclaim past Stripe's ~24h +// idempotency-key window can no longer trust key replay — a bare re-send would +// mint a SECOND draft+item+charge. The reclaim now reconciles by the run's +// ms_charge_ref anchor first: the crashed attempt's finalized invoice is +// adopted (mirrored + marked) with NO new Stripe objects. +func TestRunBillingCycle_LateReclaimAdoptsFoundInvoiceWithoutNewObjects(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h5" + + sc := newFakeStripe() + + // FIRST attempt charges $1, crash before mark (frozen marker durable). + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + drafts, finalizes := len(sc.invoiceCalls), len(sc.finalizeCalls) + + // The reclaim lands DAYS later — keys pruned — but the crashed attempt's + // invoice is findable under run:. + sc.findByRef = &billingstripe.Invoice{ID: "in_prior_boundary", Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"} + store.errMarkRun = nil + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.Len(t, sc.invoiceCalls, drafts, "no second draft on a recovered reclaim") + require.Len(t, sc.finalizeCalls, finalizes, "no second finalize — the money moved once") + _, mirrored := store.invoices["in_prior_boundary"] + require.True(t, mirrored, "the crashed attempt's invoice is mirrored") +} + func TestRunBillingCycle_CentsRoundHalfUp(t *testing.T) { // 5_000 micros = 0.5 cents → round-half-up → 1 cent. store := newFakeStore() diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 9897cb1..2277805 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -89,6 +89,10 @@ const ( // app's ONE combined creation invoice (scenario 3), not a separate Leg 1 one, so // it is DEFERRED (left unresolved) for the proration sweep to charge and mark. ModuleOverageDeferredToCombined ModuleOverageStatus = "deferred_to_combined" + // ModuleOverageSkippedStale: the charge-time re-verification (M2) found the + // timer no longer live/unresolved — removed, or resolved by a concurrent + // sweep — between the work-list read and this candidate's turn. Nothing done. + ModuleOverageSkippedStale ModuleOverageStatus = "skipped_stale" ) // ModuleOverageResult reports what one ChargeModuleOverage call did. @@ -100,16 +104,42 @@ type ModuleOverageResult struct { StripeInvoiceID string } +// moduleOverageCoverage resolves the deterministic coverage + amount for one +// timer under the 2026-07-06 coverage contract — install day → the END of the +// period the grace elapses into (install period prorated + the straddled +// period in full when the grace crosses the boundary). Every input +// (installed_at, grace_expires_at, activation anchor) is immutable, so the +// SAME amount is recomputed by a fresh charge, an idem-key replay, and the +// post-idem-key-window recovery path — one home for the math keeps the three +// from drifting. +func moduleOverageCoverage(cand ModuleOverageCandidate) (proratedMicros int64, periodStart, periodEnd, coverageEnd time.Time, closed bool) { + periodStart, periodEnd, closed = periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) + coverageEnd = periodEnd + proratedMicros = usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) + if !cand.GraceExpiresAt.Before(periodEnd) { + _, coverageEnd = billingperiod.AnchoredPeriodWindow(cand.GraceExpiresAt.UTC(), billingperiod.AnchorDay(cand.ActivatedAt)) + proratedMicros += usage.ModuleOverageFeeMicros + } + return proratedMicros, periodStart, periodEnd, coverageEnd, closed +} + // ChargeModuleOverage evaluates + (if "over") charges ONE per-module install -// timer whose grace has elapsed. Gated on a usable default PM exactly like the -// proration leg (the candidate account is already activated — the work-list -// query filters activated_at IS NOT NULL). Idempotent + race-safe WITHOUT a lock: -// the deterministic per-timer Stripe Idempotency-Keys dedupe the charge across -// retries, and the grace_resolved first-write-wins guard records the terminal -// verdict — a crash between Stripe succeeding and the mark committing resumes on -// the next sweep through the SAME keys (Stripe returns the same objects) and then -// marks. Unlike the superseded account-wide model there is exactly ONE charge leg -// and ONE key namespace per timer, so no pending-claim ledger is needed. +// timer whose grace has elapsed. Gated on the collection mode and a usable +// default PM exactly like the proration leg (the candidate account is already +// activated — the work-list query filters activated_at IS NOT NULL). +// Idempotent + race-safe WITHOUT a lock, via three layers: +// +// - the grace_resolved first-write-wins guard records the terminal verdict; +// - the deterministic per-timer Stripe Idempotency-Keys dedupe the charge +// across SHORT-window retries (a crash between Stripe succeeding and the +// mark committing resumes through the SAME keys); +// - the migration-036 charge_attempted_at marker + the ms_charge_ref +// metadata anchor cover retries PAST Stripe's ~24h idempotency-key window +// (H5): an attempted candidate reconciles against what Stripe actually has +// BEFORE recomputing any live verdict — so money moved by a crashed +// attempt is mirrored+marked even if the timer's FIFO rank has since +// improved to "included" (H9), and a pruned-key retry never mints a second +// set of Stripe objects. func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCandidate, at time.Time) (*ModuleOverageResult, error) { if cand.ID == uuid.Nil { return nil, billing.InvalidInput("timer id required") @@ -119,6 +149,37 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan } res := &ModuleOverageResult{TimerID: cand.ID} + // CHARGE-TIME RE-VERIFICATION (review 2026-07-06, M2): the sweep's work list + // was read once and this candidate may be minutes stale — re-check live + + // unresolved immediately before acting, so a module removed (or resolved by + // a concurrent sweep) mid-batch is not charged. + pending, err := s.store.ModuleTimerStillPending(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("module timer pending re-check failed", err) + } + if !pending { + res.Status = ModuleOverageSkippedStale + return res, nil + } + + // CRASH RECOVERY (review 2026-07-06, H5/H9) — BEFORE the live FIFO verdict. + // A set charge_attempted_at means a prior attempt reached its Stripe section + // and may have moved money before crashing short of the mark. Reconcile + // against Stripe by the ms_charge_ref anchor: whatever is found is finished + // (mirrored + marked) regardless of what the timer's rank says NOW — a rank + // that improved over→included since the crash must not orphan a real charge. + // Nothing found ⇒ the crashed attempt never created its invoice; fall + // through and charge fresh. + if !cand.ChargeAttemptedAt.IsZero() { + recovered, err := s.recoverModuleOverageCharge(ctx, cand, at, res) + if err != nil { + return nil, err + } + if recovered { + return res, nil + } + } + // LIVE FIFO determination, computed fresh (never cached): this install's rank // among the account's currently-live timers ordered (installed_at, id). rank, err := s.store.LiveModuleTimerRankBefore(ctx, cand.AccountID, cand.ID, cand.InstalledAt) @@ -140,11 +201,11 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // anchored period (ADR 0005 anchor from activation) — install-anchored, NOT // grace-elapse-anchored and NOT now-anchored — plus, for a grace that // STRADDLES the period boundary, the full fee for the period the grace - // elapses into (see the coverage comment below; the boundary precharge - // deliberately excludes straddlers via its grace_expires_at < period_end - // cutoff, so that period is THIS leg's to bill, and only from the boundary - // after that does the precharge take over). - periodStart, periodEnd, closed := periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) + // elapses into (moduleOverageCoverage; the boundary precharge deliberately + // excludes straddlers via its grace_expires_at < period_end cutoff, so that + // period is THIS leg's to bill, and only from the boundary after that does + // the precharge take over). + proratedMicros, periodStart, _, coverageEnd, closed := moduleOverageCoverage(cand) // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration // enforces on the creation leg, proration.go). RegisterApp synthesizes an app's @@ -195,25 +256,13 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan return res, nil } - // Coverage (review 2026-07-06 contract): install day → the END of the period - // the grace ELAPSES INTO. Normally that is the install period itself (grace ≪ - // period length), so the amount is the familiar install-anchored proration. - // When the grace STRADDLES the boundary (grace_expires_at at/after the install - // period's end), the boundary run for the next period already executed while - // this timer was unresolved and — by the same contract (grace_expires_at < - // period_end) — excluded it; if this leg only covered the install period, the - // straddled period would be billed by NO leg at all. So this charge covers it: - // prorated install period + the FULL fee for the straddled period. Both inputs - // (installed_at, activation anchor) are immutable, so the amount stays + // Coverage (review 2026-07-06 contract, moduleOverageCoverage): install day → + // the END of the period the grace ELAPSES INTO — install period prorated + + // the straddled period in full when the grace crosses the boundary. Both + // inputs (installed_at, activation anchor) are immutable, so the amount stays // deterministic across retries — the per-timer Stripe idem keys stay stable. // The precharge picks the timer up from the FIRST boundary after its grace // elapsed, so coverage is complete and disjoint by construction. - coverageEnd := periodEnd - proratedMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) - if !cand.GraceExpiresAt.Before(periodEnd) { - _, coverageEnd = billingperiod.AnchoredPeriodWindow(cand.GraceExpiresAt.UTC(), billingperiod.AnchorDay(cand.ActivatedAt)) - proratedMicros += usage.ModuleOverageFeeMicros - } cents, err := centsFromMicros(proratedMicros) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) @@ -250,6 +299,13 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan return res, nil } + // Stamp the migration-036 recovery marker BEFORE the first Stripe call + // (first-write-wins): from here on, any retry — however late — reconciles + // against Stripe rather than trusting a recomputed live verdict. + if err := s.store.MarkModuleTimerChargeAttempted(ctx, cand.ID, at.UTC()); err != nil { + return nil, billing.Internal("mark module timer charge attempted failed", err) + } + // Charge via a per-timer draft→pinned-item→finalize flow with deterministic // idem keys derived from the timer id (the stable charge identity — each // install charges at most once, the grace_resolved guard), so a crash-retry @@ -257,7 +313,7 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // draft (C2 — a floating pending item could be swept onto another leg's // invoice); only the finalize step moves money. desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) - draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "timer:"+cand.ID.String(), moduleOverageInvoiceIdemKey(cand.ID)) + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, moduleOverageChargeRef(cand.ID), moduleOverageInvoiceIdemKey(cand.ID)) if err != nil { return nil, billing.StripeError("module overage draft invoice failed", err) } @@ -360,6 +416,99 @@ func (s *Service) SweepModuleOverage(ctx context.Context, at time.Time) (*SweepM // grace_resolved guard), so a re-attempt (a retried sweep after a crash between // the Stripe call and the mark) reuses the SAME Stripe objects and can never // double-charge even before the row is marked resolved. +// recoverModuleOverageCharge is the H5/H9 recovery path for a candidate whose +// charge_attempted_at marker is set: look the timer's invoice up on Stripe by +// its ms_charge_ref anchor and finish whatever the crashed attempt left — +// finalized invoice → mirror + mark; inert draft → complete it (attach the +// deterministic line if it never landed, then finalize) → mirror + mark. +// Returns recovered=false when Stripe has nothing under the ref (the crashed +// attempt never created its invoice — the caller charges fresh). The PM gate +// is deliberately NOT applied: reconciling possibly-moved money must never be +// blocked by a PM removed after the crash (an idem/finalize failure lands in +// the sweep's retried-error path instead). +func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOverageCandidate, at time.Time, res *ModuleOverageResult) (bool, error) { + custID, err := s.store.AccountStripeCustomer(ctx, cand.AccountID) + if err != nil { + return false, billing.Internal("stripe customer lookup failed (module overage recovery)", err) + } + if custID == "" { + // No Customer ⇒ no prior attempt could have created Stripe objects. + return false, nil + } + found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, moduleOverageChargeRef(cand.ID)) + if err != nil { + return false, billing.StripeError("module overage recovery lookup failed", err) + } + if !ok { + return false, nil + } + + proratedMicros, periodStart, _, coverageEnd, _ := moduleOverageCoverage(cand) + cents, err := centsFromMicros(proratedMicros) + if err != nil { + return false, billing.Internal("micros to cents conversion failed", err) + } + + inv := found + if found.Status == "draft" { + // The crashed attempt never finalized. Complete ITS draft — never mint a + // second one. The line either never landed (AmountDue 0 → attach it, with + // the deterministic amount the crashed attempt would have used) or already + // did (AmountDue == cents → just finalize). Anything else means the draft + // was tampered with or the deterministic math changed — refuse loudly. + switch found.AmountDue { + case 0: + desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)); err != nil { + return false, billing.StripeError("module overage recovery invoice item failed", err) + } + case cents: + // line already attached — nothing to add + default: + return false, billing.Internal(fmt.Sprintf( + "module overage recovery: draft %s carries %d cents but the deterministic amount is %d — refusing to finalize a mismatched draft (timer %s)", + found.ID, found.AmountDue, cents, cand.ID), nil) + } + inv, err = s.stripe.FinalizeInvoice(ctx, found.ID, moduleOverageFinalizeIdemKey(cand.ID)) + if err != nil { + return false, billing.StripeError("module overage recovery finalize failed", err) + } + } + + // Mirror + mark exactly like the fresh path. The invoice-item id is unknown + // on recovery (the search projection carries the invoice only) — the mark + // stores NULL for it, with the genuine invoice id as the correlation anchor. + acct, err := s.store.AccountCollection(ctx, cand.AccountID) + if err != nil { + return false, billing.Internal("account collection lookup failed", err) + } + if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ + AccountID: cand.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), + PeriodEnd: coverageEnd, + IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), + }); err != nil { + return false, billing.Internal("invoice mirror upsert failed (module overage recovery)", err) + } + if err := s.store.MarkModuleTimerCharged(ctx, cand.ID, at.UTC(), inv.ID, ""); err != nil { + return false, billing.Internal("mark module timer charged failed (module overage recovery)", err) + } + + res.Status = ModuleOverageCharged + res.ChargedCents = inv.AmountDue + res.StripeInvoiceID = inv.ID + return true, nil +} + +// moduleOverageChargeRef is the deterministic ms_charge_ref metadata anchor for +// one timer's Leg-1 invoice — what FindInvoiceByRef recovers by. +func moduleOverageChargeRef(timerID uuid.UUID) string { return "timer:" + timerID.String() } + func moduleOverageItemIdemKey(timerID uuid.UUID) string { return "mod-overage-ii-" + timerID.String() } diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index bc2423a..1953599 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) // seedTimer inserts one live, unresolved install timer directly into the fake @@ -248,6 +249,131 @@ func TestModuleOverage_GraceInsidePeriodChargesInstallPeriodOnly(t *testing.T) { } } +// Regression (review 2026-07-06, H9): a crash between the Stripe charge and +// MarkModuleTimerCharged, followed by an earlier module's removal, used to let +// the retry recompute the timer's rank as "included" and resolve it uncharged — +// real money moved with no invoice mirror, no disclosure, no mark. With the +// migration-036 attempt marker the retry reconciles against Stripe FIRST: the +// finalized invoice is found by its ms_charge_ref, mirrored, and marked, and +// the (now-improved) live rank never gets to orphan it. +func TestModuleOverage_RetryAfterCrashRecoversChargeEvenWhenRankFlipped(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + // Attempt 1 reached the Stripe section (marker set), charged, and crashed + // before the mark. Its finalized invoice sits on Stripe under the ref. + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + sc.findByRef = &billingstripe.Invoice{ID: "in_crashed", Status: "paid", AmountDue: 240, AmountPaid: 240, Currency: "usd"} + + // Between crash and retry an EARLIER module is removed — x's live rank + // improves to 4 ("included"). + for id, tm := range store.timers { + if tm.installedAt.Equal(time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)) { + store.timers[id].removed = true + store.timers[id].removedAt = time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + break + } + } + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "the moved money is recovered, not orphaned by the rank flip") + require.True(t, store.timers[x].graceCharged) + require.Equal(t, "in_crashed", store.timers[x].graceInvoiceID) + inv, ok := store.invoices["in_crashed"] + require.True(t, ok, "the crashed attempt's invoice is mirrored") + require.EqualValues(t, 240, inv.AmountDueCents) + // H5: no NEW Stripe objects were minted on recovery. + require.Empty(t, sc.invoiceCalls, "no second draft") + require.Empty(t, sc.finalizeCalls, "no second finalize") +} + +// H5, the draft-completion arm: the crashed attempt created its draft but died +// before attaching the line / finalizing. The retry completes THAT draft (the +// deterministic line attached to the FOUND invoice id, then finalized) instead +// of minting a second one — safe even after Stripe pruned the idem keys. +func TestModuleOverage_RetryCompletesCrashedDraftInsteadOfMintingSecond(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + sc.findByRef = &billingstripe.Invoice{ID: "in_orphan_draft", Status: "draft", AmountDue: 0, Currency: "usd"} + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.Empty(t, sc.invoiceCalls, "the found draft is completed — never a second CreateDraftInvoice") + require.Len(t, sc.itemCalls, 1) + require.Equal(t, "in_orphan_draft", sc.itemCalls[0].invoiceID, "the line lands on the crashed attempt's own draft") + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg) + require.Len(t, sc.finalizeCalls, 1) + require.Equal(t, "in_orphan_draft", sc.finalizeCalls[0].invoiceID) + require.True(t, store.timers[x].graceCharged) +} + +// The recovery no-op arm: the marker is set but Stripe has NOTHING under the +// ref — the crashed attempt never created its invoice. The retry charges fresh. +func TestModuleOverage_AttemptedButNothingOnStripeChargesFresh(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + // sc.findByRef stays nil — not found. + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.NotEmpty(t, sc.findByRefCalls, "the recovery lookup ran") + require.Len(t, sc.invoiceCalls, 1, "nothing to recover → fresh draft→item→finalize") + require.Len(t, sc.finalizeCalls, 1) + require.True(t, store.timers[x].graceCharged) +} + +// Regression (review 2026-07-06, M2): a candidate that went stale between the +// work-list read and its turn — removed, or resolved by a concurrent sweep — +// is re-verified at charge time and never charged. +func TestModuleOverage_StaleCandidateNotCharged(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + // The candidate as the work list saw it... + cand := cycle.ModuleOverageCandidate{ + ID: x, AccountID: acct, AppID: store.timers[x].appID, + InstalledAt: store.timers[x].installedAt, GraceExpiresAt: store.timers[x].graceExpiresAt, + ActivatedAt: time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC), + } + // ...then the module is uninstalled before its turn in the batch. + store.timers[x].removed = true + store.timers[x].removedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + res, err := svc.ChargeModuleOverage(ctx, cand, time.Date(2026, 6, 14, 1, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, cycle.ModuleOverageSkippedStale, res.Status) + require.Empty(t, sc.invoiceCalls, "a stale candidate never reaches Stripe") + require.False(t, store.timers[x].graceCharged) +} + // Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged // off-session — the boundary spine always gated on this, but the per-module // grace leg bypassed it entirely. The skip is transient: nothing is resolved, diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index b655654..2f0f91c 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -45,6 +45,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) // ProrationStatus classifies one ChargeCreationProration outcome for the sweep's @@ -243,30 +244,51 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil } - // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is never - // auto-charged off-session by ANY leg. Transient skip (guard unarmed), like - // no-PM — re-attempted once the account relaxes back to arrears. - if permitted, err := s.offSessionChargePermitted(ctx, app.AccountID); err != nil { - return nil, err - } else if !permitted { - return &ProrationResult{AppID: appID, Status: ProrationStatusPrepaid}, nil - } - - // PM gate (D1d), same posture as the boundary spine: activated but no - // usable default PM is skipped and re-attempted next sweep (unlike the - // period-closed case above, "no PM right now" is not itself evidence the - // account was ever ineligible for this specific period, so it stays a - // transient, retried skip rather than a permanent one — see the judgment - // call noted in the PR description for the limits of this). if s.stripe == nil { return nil, billing.Internal("ChargeCreationProration requires a Stripe client", nil) } - custID, ok, err := s.resolveChargeableCustomer(ctx, app.AccountID) - if err != nil { - return nil, err - } - if !ok { - return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil + + // Gates — FIRST attempt only (the proration analogue of the boundary's H8 + // rule): once a prior attempt reached its Stripe section + // (proration_attempted_at set), money may already have moved, and this + // call's job is to RECONCILE — a prepaid tighten or a removed PM after the + // crash must not strand the charge unmirrored behind a transient skip. The + // attempted path resolves only the Customer id (an idem/recovery replay + // needs no fresh authorization). + var custID string + if app.ProrationAttempted { + custID, err = s.store.AccountStripeCustomer(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return nil, billing.Internal("app has an attempted proration charge but the account has no Stripe customer id", nil) + } + } else { + // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is + // never auto-charged off-session by ANY leg. Transient skip (guard + // unarmed), like no-PM — re-attempted once the account relaxes back to + // arrears. + if permitted, err := s.offSessionChargePermitted(ctx, app.AccountID); err != nil { + return nil, err + } else if !permitted { + return &ProrationResult{AppID: appID, Status: ProrationStatusPrepaid}, nil + } + + // PM gate (D1d), same posture as the boundary spine: activated but no + // usable default PM is skipped and re-attempted next sweep (unlike the + // period-closed case above, "no PM right now" is not itself evidence the + // account was ever ineligible for this specific period, so it stays a + // transient, retried skip rather than a permanent one — see the judgment + // call noted in the PR description for the limits of this). + var ok bool + custID, ok, err = s.resolveChargeableCustomer(ctx, app.AccountID) + if err != nil { + return nil, err + } + if !ok { + return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil + } } // The charge callback runs AFTER the row lock is released (ChargeProrationLocked, @@ -310,22 +332,6 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, nil // rounds to 0 cents → nothing to invoice (guard stays unarmed) } - // Draft→pinned-items→finalize (C2): the empty draft is created FIRST so - // the base line and every co-created overage line are pinned to THIS - // invoice explicitly — never floating customer-level pending items that a - // concurrently-finalizing leg's invoice could sweep up (or that a crash - // here would leak onto the account's next unrelated invoice). Only the - // finalize step at the end moves money. - draft, err := s.stripe.CreateDraftInvoice(ctx, custID, "app-proration:"+locked.AppID.String(), appProrationInvoiceIdemKey(locked.AppID)) - if err != nil { - return nil, billing.StripeError("proration draft invoice failed", err) - } - - desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { - return nil, billing.StripeError("proration invoice item failed", err) - } - // Scenario 3 — the combined creation invoice. Modules co-created with the // app (install date == created_at) that are "over" per the live FIFO have // their OWN grace elapse at this SAME instant (same GraceDays anchor), so @@ -352,29 +358,109 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if err != nil { return nil, billing.Internal("overage micros to cents conversion failed", err) } - var timerCharges []ModuleTimerCharge - var overageTotalMicros int64 + expectedTotalCents := c if overageCents > 0 { - overDesc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", locked.AppID) - for _, timerID := range overTimers { - item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) - if err != nil { - return nil, billing.StripeError("combined module overage invoice item failed", err) + expectedTotalCents += overageCents * int64(len(overTimers)) + } + + // CRASH RECOVERY (review 2026-07-06, H5): a set proration_attempted_at + // marker means a prior attempt reached its Stripe section — reconcile by + // the ms_charge_ref anchor before minting anything. Past Stripe's ~24h + // idempotency-key window a bare key replay would create a SECOND draft + + // items + charge. A finalized invoice found → the money moved; adopt it. + // An inert draft found → complete THAT draft below instead of creating one. + var inv billingstripe.Invoice + var draft billingstripe.Invoice + var recoveredFinal bool + if locked.ProrationAttempted { + if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(locked.AppID)); err != nil { + return nil, billing.StripeError("proration recovery lookup failed", err) + } else if ok { + if found.Status == "draft" { + draft = found + } else { + inv = found + recoveredFinal = true } - timerCharges = append(timerCharges, ModuleTimerCharge{ - TimerID: timerID, - ChargedAt: s.nowFn().UTC(), - InvoiceID: draft.ID, - InvoiceItemID: item.ID, - }) - overageTotalMicros += overageMicros } } - inv, err := s.stripe.FinalizeInvoice(ctx, draft.ID, appProrationFinalizeIdemKey(locked.AppID)) - if err != nil { - return nil, billing.StripeError("proration invoice finalize failed", err) + var timerCharges []ModuleTimerCharge + if !recoveredFinal { + // Draft→pinned-items→finalize (C2): the empty draft is created FIRST so + // the base line and every co-created overage line are pinned to THIS + // invoice explicitly — never floating customer-level pending items that a + // concurrently-finalizing leg's invoice could sweep up (or that a crash + // here would leak onto the account's next unrelated invoice). Only the + // finalize step at the end moves money. The migration-036 attempt marker + // is stamped BEFORE the first Stripe call (first-write-wins). + if draft.ID == "" { + if err := s.store.MarkAppProrationAttempted(ctx, locked.AppID, s.nowFn().UTC()); err != nil { + return nil, billing.Internal("mark proration attempted failed", err) + } + draft, err = s.stripe.CreateDraftInvoice(ctx, custID, appProrationChargeRef(locked.AppID), appProrationInvoiceIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration draft invoice failed", err) + } + } + + // Attach the lines — unless a recovered draft already carries them all + // (AmountDue == the deterministic total). A partially-lined draft (some + // items landed before the crash, keys since pruned) cannot be completed + // safely — refuse loudly for ops rather than risk duplicate lines. + switch draft.AmountDue { + case expectedTotalCents: + // every line already attached — collect the marks with the known + // invoice id (item ids unrecoverable from the search projection) + if overageCents > 0 { + for _, timerID := range overTimers { + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, ChargedAt: s.nowFn().UTC(), InvoiceID: draft.ID, + }) + } + } + case 0: + desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { + return nil, billing.StripeError("proration invoice item failed", err) + } + if overageCents > 0 { + overDesc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", locked.AppID) + for _, timerID := range overTimers { + item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) + if err != nil { + return nil, billing.StripeError("combined module overage invoice item failed", err) + } + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, + ChargedAt: s.nowFn().UTC(), + InvoiceID: draft.ID, + InvoiceItemID: item.ID, + }) + } + } + default: + return nil, billing.Internal(fmt.Sprintf( + "proration recovery: draft %s carries %d cents but the deterministic total is %d — refusing to finalize a mismatched combined draft (app %s)", + draft.ID, draft.AmountDue, expectedTotalCents, locked.AppID), nil) + } + + inv, err = s.stripe.FinalizeInvoice(ctx, draft.ID, appProrationFinalizeIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration invoice finalize failed", err) + } + } else { + // Recovered a finalized invoice: rebuild the co-created timer marks + // against it (item ids unrecoverable from the search projection). + if overageCents > 0 { + for _, timerID := range overTimers { + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, ChargedAt: s.nowFn().UTC(), InvoiceID: inv.ID, + }) + } + } } + overageTotalMicros := overageMicros * int64(len(timerCharges)) // Resolve the account's large-charge disclosure threshold AT CHARGE TIME, // immediately AFTER the Stripe calls above succeeded — the SAME point (via @@ -513,3 +599,7 @@ func (s *Service) SweepCreationProrations(ctx context.Context, at time.Time) (*S func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } func appProrationFinalizeIdemKey(appID uuid.UUID) string { return "app-fin-" + appID.String() } + +// appProrationChargeRef is the deterministic ms_charge_ref metadata anchor for +// one app's combined creation invoice — what FindInvoiceByRef recovers by. +func appProrationChargeRef(appID uuid.UUID) string { return "app-proration:" + appID.String() } diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index cf5d3dd..c289f61 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -15,6 +15,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) // registerMirror seeds a roster row through RegisterApp (which only mirrors — no @@ -72,6 +73,35 @@ func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { require.Empty(t, store.apps[appID].ProrationInvoiceID) } +// Regression (review 2026-07-06, H5): a creation-proration retry past Stripe's +// ~24h idempotency-key window reconciles by the app's ms_charge_ref anchor — +// a crashed attempt's finalized combined invoice is adopted (guard armed with +// ITS id, timers marked against it) with no new Stripe objects. +func TestChargeCreationProration_LateRetryAdoptsFoundInvoice(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 0) + + // A prior attempt reached its Stripe section (marker set), finalized the + // invoice, and crashed before persisting. + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + sc.findByRef = &billingstripe.Invoice{ID: "in_prior_combined", Status: "paid", AmountDue: 1000, AmountPaid: 1000, Currency: "usd"} + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.Equal(t, "in_prior_combined", resp.ProrationInvoiceID) + require.Equal(t, "in_prior_combined", store.apps[appID].ProrationInvoiceID, "the guard arms with the recovered invoice") + require.Empty(t, sc.invoiceCalls, "no second draft") + require.Empty(t, sc.itemCalls, "no re-attached lines") + require.Empty(t, sc.finalizeCalls, "no second finalize — the money moved once") +} + // Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged // off-session — the boundary spine always gated on this, but the creation- // proration leg bypassed it. Transient skip (guard unarmed); a relax back to diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 9fcd4c6..eadbced 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -155,6 +155,7 @@ type fakeTimer struct { graceChargedAt time.Time graceInvoiceID string graceInvoiceItemID string + chargeAttemptedAt time.Time // migration-036 recovery marker } // snapKey mirrors the app_base_snapshots PRIMARY KEY (app_id, period_start). @@ -762,12 +763,13 @@ func (f *fakeStore) ModuleOverageTimersPastGrace(_ context.Context, at time.Time continue // activated_at IS NOT NULL gate } out = append(out, cycle.ModuleOverageCandidate{ - ID: t.id, - AccountID: t.accountID, - AppID: t.appID, - InstalledAt: t.installedAt, - GraceExpiresAt: t.graceExpiresAt, - ActivatedAt: activatedAt, + ID: t.id, + AccountID: t.accountID, + AppID: t.appID, + InstalledAt: t.installedAt, + GraceExpiresAt: t.graceExpiresAt, + ChargeAttemptedAt: t.chargeAttemptedAt, + ActivatedAt: activatedAt, }) } // Ordered (installed_at, id) like the query, so the sweep charges oldest-first @@ -798,6 +800,29 @@ func (f *fakeStore) LiveModuleTimerRankBefore(_ context.Context, accountID, time return rank, nil } +func (f *fakeStore) MarkModuleTimerChargeAttempted(_ context.Context, timerID uuid.UUID, at time.Time) error { + if t, ok := f.timers[timerID]; ok && t.chargeAttemptedAt.IsZero() { + t.chargeAttemptedAt = at // first-write-wins, mirroring the SQL + } + return nil +} + +func (f *fakeStore) ModuleTimerStillPending(_ context.Context, timerID uuid.UUID) (bool, error) { + t, ok := f.timers[timerID] + if !ok { + return false, nil + } + return !t.removed && !t.graceResolved, nil +} + +func (f *fakeStore) MarkAppProrationAttempted(_ context.Context, appID uuid.UUID, _ time.Time) error { + if app, ok := f.apps[appID]; ok && !app.ProrationAttempted { + app.ProrationAttempted = true // first-write-wins + f.apps[appID] = app + } + return nil +} + func (f *fakeStore) MarkModuleTimerIncluded(_ context.Context, timerID uuid.UUID) error { if f.errMarkIncluded != nil { return f.errMarkIncluded diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 06957d6..4d84ba8 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -302,6 +302,23 @@ type Store interface { // for an app — the app-deletion path. Idempotent (WHERE removed_at IS NULL). SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error + // MarkModuleTimerChargeAttempted stamps the migration-036 recovery marker + // BEFORE a charge attempt's first Stripe call — first-write-wins, never + // cleared. A later retry seeing it set reconciles against Stripe (the + // ms_charge_ref anchor) before recomputing any live verdict or minting new + // Stripe objects. + MarkModuleTimerChargeAttempted(ctx context.Context, timerID uuid.UUID, at time.Time) error + + // ModuleTimerStillPending re-verifies, immediately before acting on a sweep + // candidate, that the timer is STILL live and unresolved — the work list is + // read once and can be minutes stale by the time a late candidate is + // processed (M2). + ModuleTimerStillPending(ctx context.Context, timerID uuid.UUID) (bool, error) + + // MarkAppProrationAttempted stamps the migration-036 recovery marker for the + // creation-proration leg — first-write-wins, never cleared. + MarkAppProrationAttempted(ctx context.Context, appID uuid.UUID, at time.Time) error + // ReconcileModuleTimersToTarget brings an app's live install-timer set to // exactly target, ATOMICALLY under a per-app advisory transaction lock // (review 2026-07-06, H7): a grow inserts the deficit anchored at @@ -367,6 +384,10 @@ type ModuleOverageCandidate struct { InstalledAt time.Time GraceExpiresAt time.Time ActivatedAt time.Time + // ChargeAttemptedAt: a prior charge attempt reached its Stripe section + // (migration 036 recovery marker); zero = never attempted. A retried + // candidate reconciles against Stripe BEFORE recomputing any live verdict. + ChargeAttemptedAt time.Time } // AppModuleCount pairs one live roster app with its module_count snapshot — @@ -412,6 +433,10 @@ type AppMirror struct { CreatedAt time.Time ProrationInvoiceID string ProrationSkipped bool + // ProrationAttempted: a prior creation-proration charge attempt reached its + // Stripe section (migration 036 recovery marker) — a retry with this set and + // an unarmed guard reconciles against Stripe before minting new objects. + ProrationAttempted bool Deleted bool DeletedAt time.Time } @@ -1068,6 +1093,7 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b CreatedAt: row.CreatedAt, ProrationInvoiceID: row.ProrationInvoiceID.String, // "" when NULL (guard unarmed) ProrationSkipped: row.ProrationSkippedAt.Valid, + ProrationAttempted: row.ProrationAttemptedAt.Valid, Deleted: row.DeletedAt.Valid, DeletedAt: row.DeletedAt.Time, }, true, nil @@ -1139,6 +1165,7 @@ func (s *pgxStore) lockAndReadChargeableApp(ctx context.Context, appID uuid.UUID ModuleCount: int(row.ModuleCount), CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, + ProrationAttempted: row.ProrationAttemptedAt.Valid, } if err := tx.Commit(ctx); err != nil { @@ -1368,6 +1395,28 @@ func (s *pgxStore) InsertModuleOverageTimers(ctx context.Context, accountID, app }) } +func (s *pgxStore) MarkModuleTimerChargeAttempted(ctx context.Context, timerID uuid.UUID, at time.Time) error { + return s.q.MarkModuleTimerChargeAttempted(ctx, db.MarkModuleTimerChargeAttemptedParams{ + ID: timerID.String(), + ChargeAttemptedAt: pgtype.Timestamptz{Time: at, Valid: true}, + }) +} + +func (s *pgxStore) ModuleTimerStillPending(ctx context.Context, timerID uuid.UUID) (bool, error) { + pending, err := s.q.ModuleTimerStillPending(ctx, timerID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil // the row vanished — certainly not pending + } + return pending, err +} + +func (s *pgxStore) MarkAppProrationAttempted(ctx context.Context, appID uuid.UUID, at time.Time) error { + return s.q.MarkAppProrationAttempted(ctx, db.MarkAppProrationAttemptedParams{ + AppID: appID.String(), + ProrationAttemptedAt: pgtype.Timestamptz{Time: at, Valid: true}, + }) +} + // ReconcileModuleTimersToTarget — count + write in ONE transaction serialized // by a per-app advisory xact lock, so concurrent RegisterApp/SyncAppModules // retries can never both observe the same live count and double-insert (H7). @@ -1453,14 +1502,18 @@ func (s *pgxStore) ModuleOverageTimersPastGrace(ctx context.Context, at time.Tim if !r.ActivatedAt.Valid { continue } - out = append(out, ModuleOverageCandidate{ + cand := ModuleOverageCandidate{ ID: id, AccountID: acct, AppID: app, InstalledAt: r.InstalledAt, GraceExpiresAt: r.GraceExpiresAt, ActivatedAt: r.ActivatedAt.Time, - }) + } + if r.ChargeAttemptedAt.Valid { + cand.ChargeAttemptedAt = r.ChargeAttemptedAt.Time + } + out = append(out, cand) } return out, nil } diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 0252987..cdde886 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -211,6 +211,26 @@ func (q *Queries) MarkAppDeleted(ctx context.Context, appID string) (int64, erro return result.RowsAffected(), nil } +const markAppProrationAttempted = `-- name: MarkAppProrationAttempted :exec +UPDATE ms_billing.apps +SET proration_attempted_at = $2 +WHERE app_id = $1 + AND proration_attempted_at IS NULL +` + +type MarkAppProrationAttemptedParams struct { + AppID string `json:"app_id"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` +} + +// MarkAppProrationAttempted stamps the recovery marker (036) BEFORE a +// creation-proration charge attempt's first Stripe call. First-write-wins +// (the FIRST attempt instant is the durable one); never cleared. +func (q *Queries) MarkAppProrationAttempted(ctx context.Context, arg MarkAppProrationAttemptedParams) error { + _, err := q.db.Exec(ctx, markAppProrationAttempted, arg.AppID, arg.ProrationAttemptedAt) + return err +} + const mirroredAppIDsOverlappingWindow = `-- name: MirroredAppIDsOverlappingWindow :many SELECT app_id FROM ms_billing.apps @@ -293,20 +313,21 @@ 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, - proration_invoice_id, proration_skipped_at, deleted_at + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 ` type SelectAppMirrorRow struct { - AppID string `json:"app_id"` - AccountID string `json:"account_id"` - ModuleCount int32 `json:"module_count"` - CreatedModuleCount int32 `json:"created_module_count"` - CreatedAt time.Time `json:"created_at"` - ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` - ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` + CreatedAt time.Time `json:"created_at"` + ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` } // SelectAppMirror reads one roster row (deleted or not — the caller decides @@ -323,6 +344,7 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM &i.CreatedAt, &i.ProrationInvoiceID, &i.ProrationSkippedAt, + &i.ProrationAttemptedAt, &i.DeletedAt, ) return i, err @@ -330,21 +352,22 @@ 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, - proration_invoice_id, proration_skipped_at, deleted_at + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 FOR UPDATE ` type SelectAppMirrorForUpdateRow struct { - AppID string `json:"app_id"` - AccountID string `json:"account_id"` - ModuleCount int32 `json:"module_count"` - CreatedModuleCount int32 `json:"created_module_count"` - CreatedAt time.Time `json:"created_at"` - ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` - ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` + CreatedAt time.Time `json:"created_at"` + ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` } // SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — @@ -366,6 +389,7 @@ func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (S &i.CreatedAt, &i.ProrationInvoiceID, &i.ProrationSkippedAt, + &i.ProrationAttemptedAt, &i.DeletedAt, ) return i, err diff --git a/internal/account/db/models.go b/internal/account/db/models.go index e3c1734..5b92ac7 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -358,6 +358,8 @@ type MsBillingApp struct { CreatedModuleCount int32 `json:"created_module_count"` // Set once (never unset) when ChargeCreationProration determines the account only activated at/after this app's anchored creation period had already closed — a would-be retroactive catch-up charge (D1d). The app is permanently excluded from the proration sweep from then on. 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"` } type MsBillingAppBaseSnapshot struct { @@ -382,6 +384,8 @@ type MsBillingAppModuleOverageTimer struct { GraceInvoiceID pgtype.Text `json:"grace_invoice_id"` GraceInvoiceItemID pgtype.Text `json:"grace_invoice_item_id"` CreatedAt time.Time `json:"created_at"` + // First instant a Leg-1 (or combined-invoice) charge attempt for this timer reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set reconciles against Stripe (ms_charge_ref) before recomputing any live verdict. + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` } type MsBillingBillingPeriod struct { diff --git a/internal/account/db/module_timers.sql.go b/internal/account/db/module_timers.sql.go index 24bf2c7..0c331be 100644 --- a/internal/account/db/module_timers.sql.go +++ b/internal/account/db/module_timers.sql.go @@ -228,6 +228,26 @@ func (q *Queries) LiveModuleTimerRankBefore(ctx context.Context, arg LiveModuleT return rank, err } +const markModuleTimerChargeAttempted = `-- name: MarkModuleTimerChargeAttempted :exec +UPDATE ms_billing.app_module_overage_timers +SET charge_attempted_at = $2 +WHERE id = $1 + AND charge_attempted_at IS NULL +` + +type MarkModuleTimerChargeAttemptedParams struct { + ID string `json:"id"` + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` +} + +// MarkModuleTimerChargeAttempted stamps the recovery marker (036) BEFORE a +// charge attempt's first Stripe call. First-write-wins (the FIRST attempt +// instant is the durable one); never cleared. +func (q *Queries) MarkModuleTimerChargeAttempted(ctx context.Context, arg MarkModuleTimerChargeAttemptedParams) error { + _, err := q.db.Exec(ctx, markModuleTimerChargeAttempted, arg.ID, arg.ChargeAttemptedAt) + return err +} + const markModuleTimerCharged = `-- name: MarkModuleTimerCharged :exec UPDATE ms_billing.app_module_overage_timers SET grace_resolved = true, @@ -279,7 +299,7 @@ func (q *Queries) MarkModuleTimerIncluded(ctx context.Context, id string) error const moduleOverageTimersPastGrace = `-- name: ModuleOverageTimersPastGrace :many SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, - a.activated_at + t.charge_attempted_at, a.activated_at FROM ms_billing.app_module_overage_timers t JOIN ms_billing.accounts a ON a.id = t.account_id WHERE t.removed_at IS NULL @@ -290,21 +310,23 @@ ORDER BY t.installed_at, t.id ` type ModuleOverageTimersPastGraceRow struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - AppID string `json:"app_id"` - InstalledAt time.Time `json:"installed_at"` - GraceExpiresAt time.Time `json:"grace_expires_at"` - ActivatedAt pgtype.Timestamptz `json:"activated_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` + ActivatedAt pgtype.Timestamptz `json:"activated_at"` } // ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install // timers whose grace window has elapsed as of $1, on accounts that are chargeable // (activated_at IS NOT NULL — the same activation gate as the spine + proration // leg). Each row carries the account's activation anchor so the sweep can resolve -// the install's period window without a second read. Ordered (installed_at, id) -// so the oldest install charges first (matches the FIFO ordering). Backed by -// app_module_overage_timers_sweep_idx. +// the install's period window without a second read, and the charge_attempted_at +// recovery marker (036) so a retried candidate reconciles against Stripe first. +// Ordered (installed_at, id) so the oldest install charges first (matches the +// FIFO ordering). Backed by app_module_overage_timers_sweep_idx. func (q *Queries) ModuleOverageTimersPastGrace(ctx context.Context, graceExpiresAt time.Time) ([]ModuleOverageTimersPastGraceRow, error) { rows, err := q.db.Query(ctx, moduleOverageTimersPastGrace, graceExpiresAt) if err != nil { @@ -320,6 +342,7 @@ func (q *Queries) ModuleOverageTimersPastGrace(ctx context.Context, graceExpires &i.AppID, &i.InstalledAt, &i.GraceExpiresAt, + &i.ChargeAttemptedAt, &i.ActivatedAt, ); err != nil { return nil, err @@ -332,6 +355,24 @@ func (q *Queries) ModuleOverageTimersPastGrace(ctx context.Context, graceExpires return items, nil } +const moduleTimerStillPending = `-- name: ModuleTimerStillPending :one +SELECT (removed_at IS NULL AND grace_resolved = false)::bool AS pending +FROM ms_billing.app_module_overage_timers +WHERE id = $1 +` + +// ModuleTimerStillPending is the charge-time re-verification read (review +// 2026-07-06, M2): the sweep's work list is read ONCE and can be minutes stale +// by the time a late candidate is processed — re-check live + unresolved +// immediately before acting so a module removed (or resolved by a concurrent +// sweep) mid-batch is not charged. +func (q *Queries) ModuleTimerStillPending(ctx context.Context, id string) (bool, error) { + row := q.db.QueryRow(ctx, moduleTimerStillPending, id) + var pending bool + err := row.Scan(&pending) + return pending, err +} + const softRemoveAllModuleTimersForApp = `-- name: SoftRemoveAllModuleTimersForApp :exec UPDATE ms_billing.app_module_overage_timers SET removed_at = $2 diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index ab50fd2..78671a5 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -23,7 +23,7 @@ ON CONFLICT (app_id) DO NOTHING; -- GetAppBill still displays the spent creation-period base). -- name: SelectAppMirror :one SELECT app_id, account_id, module_count, created_module_count, created_at, - proration_invoice_id, proration_skipped_at, deleted_at + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1; @@ -37,7 +37,7 @@ WHERE app_id = $1; -- 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, - proration_invoice_id, proration_skipped_at, deleted_at + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 FOR UPDATE; @@ -85,6 +85,15 @@ WHERE app_id = $1 AND proration_skipped_at IS NULL AND proration_invoice_id IS NULL; +-- MarkAppProrationAttempted stamps the recovery marker (036) BEFORE a +-- creation-proration charge attempt's first Stripe call. First-write-wins +-- (the FIRST attempt instant is the durable one); never cleared. +-- name: MarkAppProrationAttempted :exec +UPDATE ms_billing.apps +SET proration_attempted_at = $2 +WHERE app_id = $1 + AND proration_attempted_at IS NULL; + -- SetAppModuleCount snapshots a new installed-module count (SyncAppModules). -- WHERE deleted_at IS NULL makes a count update on a deleted app a no-op -- (D1e — a deleted app accrues no future base, so its count is frozen). diff --git a/internal/account/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql index 87edc4c..6179c06 100644 --- a/internal/account/db/queries/module_timers.sql +++ b/internal/account/db/queries/module_timers.sql @@ -54,12 +54,13 @@ WHERE app_id = $1 -- timers whose grace window has elapsed as of $1, on accounts that are chargeable -- (activated_at IS NOT NULL — the same activation gate as the spine + proration -- leg). Each row carries the account's activation anchor so the sweep can resolve --- the install's period window without a second read. Ordered (installed_at, id) --- so the oldest install charges first (matches the FIFO ordering). Backed by --- app_module_overage_timers_sweep_idx. +-- the install's period window without a second read, and the charge_attempted_at +-- recovery marker (036) so a retried candidate reconciles against Stripe first. +-- Ordered (installed_at, id) so the oldest install charges first (matches the +-- FIFO ordering). Backed by app_module_overage_timers_sweep_idx. -- name: ModuleOverageTimersPastGrace :many SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, - a.activated_at + t.charge_attempted_at, a.activated_at FROM ms_billing.app_module_overage_timers t JOIN ms_billing.accounts a ON a.id = t.account_id WHERE t.removed_at IS NULL @@ -68,6 +69,25 @@ WHERE t.removed_at IS NULL AND a.activated_at IS NOT NULL ORDER BY t.installed_at, t.id; +-- MarkModuleTimerChargeAttempted stamps the recovery marker (036) BEFORE a +-- charge attempt's first Stripe call. First-write-wins (the FIRST attempt +-- instant is the durable one); never cleared. +-- name: MarkModuleTimerChargeAttempted :exec +UPDATE ms_billing.app_module_overage_timers +SET charge_attempted_at = $2 +WHERE id = $1 + AND charge_attempted_at IS NULL; + +-- ModuleTimerStillPending is the charge-time re-verification read (review +-- 2026-07-06, M2): the sweep's work list is read ONCE and can be minutes stale +-- by the time a late candidate is processed — re-check live + unresolved +-- immediately before acting so a module removed (or resolved by a concurrent +-- sweep) mid-batch is not charged. +-- name: ModuleTimerStillPending :one +SELECT (removed_at IS NULL AND grace_resolved = false)::bool AS pending +FROM ms_billing.app_module_overage_timers +WHERE id = $1; + -- LiveModuleTimerRankBefore returns how many of the account's currently-live -- install timers order STRICTLY BEFORE a given (installed_at, id) under the FIFO -- ordering (installed_at ASC, id ASC) — i.e. the target's 0-based FIFO rank. diff --git a/internal/shared/stripe/client.go b/internal/shared/stripe/client.go index c5b61fa..6e05b43 100644 --- a/internal/shared/stripe/client.go +++ b/internal/shared/stripe/client.go @@ -2,6 +2,7 @@ package stripe import ( "context" + "fmt" stripego "github.com/stripe/stripe-go/v85" stripeclient "github.com/stripe/stripe-go/v85/client" @@ -187,6 +188,29 @@ func (c *realClient) FinalizeInvoice(ctx context.Context, invoiceID, idemKey str return projectInvoice(inv), nil } +// FindInvoiceByRef searches the Customer's invoices for the ms_charge_ref +// metadata anchor — the crash-recovery read for retries past Stripe's ~24h +// idempotency-key window (see the interface comment). Uses the Stripe Search +// API; at most one invoice can carry a given ref (the ref is the deterministic +// charge identity and the draft that carries it is created under an idem key). +func (c *realClient) FindInvoiceByRef(ctx context.Context, custID, ref string) (Invoice, bool, error) { + params := &stripego.InvoiceSearchParams{ + SearchParams: stripego.SearchParams{ + Query: fmt.Sprintf(`customer:"%s" AND metadata["ms_charge_ref"]:"%s"`, custID, ref), + Limit: stripego.Int64(1), + Context: ctx, + }, + } + it := c.sc.Invoices.Search(params) + if it.Next() { + return projectInvoice(it.Invoice()), true, nil + } + if err := it.Err(); err != nil { + return Invoice{}, false, err + } + return Invoice{}, false, nil +} + // projectInvoice maps a stripe-go invoice to the trust-boundary-edge Invoice // projection the cycle consumer mirrors. func projectInvoice(inv *stripego.Invoice) Invoice { diff --git a/internal/shared/stripe/types.go b/internal/shared/stripe/types.go index 99ea492..9d5d920 100644 --- a/internal/shared/stripe/types.go +++ b/internal/shared/stripe/types.go @@ -86,6 +86,16 @@ type Client interface { // the original finalization. Returns the finalized invoice projection // (id/status/amounts) for the mirror. FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) + + // FindInvoiceByRef looks a Customer's invoice up by its ms_charge_ref + // metadata anchor (stamped by CreateDraftInvoice) — the crash-recovery read + // (review 2026-07-06, H5): Stripe prunes idempotency keys after ~24h, so a + // charge leg retrying past that window can no longer rely on key replay to + // find what a crashed attempt created. found=false when no invoice carries + // the ref. Backed by the Stripe Search API; its indexing lags writes by up + // to ~1 minute, which the retry cadences (daily sweeps) sit far above — + // short-window retries are still covered by idem-key replay. + FindInvoiceByRef(ctx context.Context, custID, ref string) (Invoice, bool, error) } // InvoiceItem is the trust-boundary-edge projection of a Stripe invoice item diff --git a/migrations/billing/036_charge_attempt_markers.down.sql b/migrations/billing/036_charge_attempt_markers.down.sql new file mode 100644 index 0000000..d331e19 --- /dev/null +++ b/migrations/billing/036_charge_attempt_markers.down.sql @@ -0,0 +1,7 @@ +-- 036 down: drop the charge-attempt recovery markers. + +ALTER TABLE ms_billing.app_module_overage_timers + DROP COLUMN IF EXISTS charge_attempted_at; + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS proration_attempted_at; diff --git a/migrations/billing/036_charge_attempt_markers.up.sql b/migrations/billing/036_charge_attempt_markers.up.sql new file mode 100644 index 0000000..8f4437e --- /dev/null +++ b/migrations/billing/036_charge_attempt_markers.up.sql @@ -0,0 +1,33 @@ +-- 036: charge-attempt markers (review 2026-07-06, H5/H9). +-- +-- The grace legs' crash-recovery story used to rest ENTIRELY on deterministic +-- Stripe idempotency keys: a crash between the Stripe charge and the DB mark +-- was healed by the next sweep replaying the same keys. But Stripe prunes +-- idempotency keys once they are at least 24 hours old, and the retry driver +-- is a DAILY cron — the first retry lands exactly on that boundary (a coin +-- flip) and any later retry is guaranteed pruned, at which point the "replay" +-- mints brand-new Stripe objects and double-charges. +-- +-- These markers are the durable "a prior attempt reached the Stripe section" +-- bit (the grace-leg analogue of migration 035's billing_runs freeze). A +-- retry that sees the marker set goes RECOVERY-FIRST: it looks the invoice up +-- on Stripe by the ms_charge_ref metadata anchor (stamped on every draft +-- since the C2 pinning fix) and reconciles what it finds — finalized → mirror +-- + mark; draft → finish it; nothing → charge fresh (nothing ever reached +-- Stripe). Recovery runs BEFORE the live FIFO verdict, so a timer whose rank +-- improved over→included between the crash and the retry can never resolve +-- "included" while charged money sits unmirrored (H9). +-- +-- Stamped first-write-wins BEFORE the first Stripe call; never cleared. + +ALTER TABLE ms_billing.app_module_overage_timers + ADD COLUMN charge_attempted_at timestamptz; + +COMMENT ON COLUMN ms_billing.app_module_overage_timers.charge_attempted_at IS + 'First instant a Leg-1 (or combined-invoice) charge attempt for this timer reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set reconciles against Stripe (ms_charge_ref) before recomputing any live verdict.'; + +ALTER TABLE ms_billing.apps + ADD COLUMN proration_attempted_at timestamptz; + +COMMENT ON COLUMN ms_billing.apps.proration_attempted_at IS + '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.'; From 5272214686bd400625568c9cd5ec6e1c5c2138af Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 11:22:46 +0800 Subject: [PATCH 21/30] test: select integration candidates by app/install instead of asserting list length The extended migration-033 fixtures keep the 5 included timers unresolved, so the late work lists legitimately contain them too. Co-Authored-By: Claude Fable 5 --- .../cycle/migration033_integration_test.go | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 196165d..1ea63c5 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -215,8 +215,14 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { // exempted such modules from ALL overage billing forever. lateCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-02T00:00:00Z")) require.NoError(t, err) - require.Len(t, lateCands, 1, "only the rank-7 timer is still unresolved") - require.NoError(t, store.MarkModuleTimerIncluded(ctx, lateCands[0].ID)) + var rank7 cycle.ModuleOverageCandidate + for _, c := range lateCands { + if c.AppID == appB { + rank7 = c + } + } + require.NotEqual(t, uuid.Nil, rank7.ID, "the rank-7 appB timer is in the late work list") + require.NoError(t, store.MarkModuleTimerIncluded(ctx, rank7.ID)) ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) require.Equal(t, 3, ongoing, "a resolved-uncharged (D1d) over-module still owes every later period") @@ -229,8 +235,14 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, straddle, straddle.AddDate(0, 0, 3), 1)) straddleCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-06T00:00:00Z")) require.NoError(t, err) - require.Len(t, straddleCands, 1) - require.NoError(t, store.MarkModuleTimerCharged(ctx, straddleCands[0].ID, mustTime(t, "2026-07-05T00:00:00Z"), "in_straddle", "ii_straddle")) + var straddleTimer cycle.ModuleOverageCandidate + for _, c := range straddleCands { + if c.InstalledAt.Equal(straddle) { + straddleTimer = c + } + } + require.NotEqual(t, uuid.Nil, straddleTimer.ID, "the Jul-2 straddle timer is in the late work list") + require.NoError(t, store.MarkModuleTimerCharged(ctx, straddleTimer.ID, mustTime(t, "2026-07-05T00:00:00Z"), "in_straddle", "ii_straddle")) ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) require.Equal(t, 3, ongoing, "a boundary-straddling grace is Leg 1's coverage, never the precharge's") From 3e7879dd10023622d26d893111266070e60302a7 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:27:25 +0800 Subject: [PATCH 22/30] fix: DST-safe grace cutoff, void-invoice refusal, guarded zero-skip (wave 2: D5/D10/D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D5: the SQL advance-base grace cutoff used make_interval(days => N) — timestamptz + a day-interval shifts with the SESSION timezone across DST, while the Go legs' grace is a fixed 24h-per-day UTC window; a non-UTC session would disagree by an hour and double-bill or gap a whole period. Now hours-based. D10: all three crash-recovery arms treated any non-draft invoice found under the ms_charge_ref anchor as 'the money moved' — including VOID (support canceled the charge during an incident). Adopting a void invoice silently forgave the amount and terminally consumed the charge identity. Void now fails loudly into the auditable retried path for ops; uncollectible (charge exists, dunning) is still adopted. D7: the boundaryTotal==0 and cents==0 zero-skips marked 'invoiced' (terminal) unconditionally — under the two-daemons model a stale hasFrozen read let a zero-skip bury a concurrent reclaim's just-frozen charge forever. The terminal zero-mark is now guarded WHERE frozen_charge_cents IS NULL; losing the guard leaves the run reclaimable for the next beat to reconcile. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge.go | 29 +++++++++++++++++++++++--- internal/account/cycle/overage.go | 9 ++++++++ internal/account/cycle/proration.go | 12 +++++++++-- internal/account/cycle/service_test.go | 11 ++++++++++ internal/account/cycle/store.go | 20 +++++++++++++++++- internal/account/db/apps.sql.go | 12 +++++++---- internal/account/db/cycle.sql.go | 26 +++++++++++++++++++++++ internal/account/db/queries/apps.sql | 8 +++++-- internal/account/db/queries/cycle.sql | 17 +++++++++++++++ 9 files changed, 132 insertions(+), 12 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 61a5110..6c49fde 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -213,10 +213,19 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // charge (H8): a reclaimed run whose LIVE total collapsed to 0 (a module // uninstalled, an app deleted since the crash) still owes the mirror + mark // for the non-zero amount the crashed attempt already put through Stripe. + // The terminal zero-mark is GUARDED on the run still being unfrozen (wave 2, + // D7): our hasFrozen read is stale under the two-daemons model, and a + // concurrent reclaim may have frozen + charged since — an unguarded terminal + // 'invoiced' would bury that charge forever. Guard lost → error out; the run + // stays reclaimable and the next reclaim reconciles the frozen charge. if !hasFrozen && boundaryTotal == 0 { - if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { + ok, err := s.store.MarkBillingRunInvoicedIfUnfrozen(ctx, runID) + if err != nil { return nil, billing.Internal("mark billing run (zero arrears) failed", err) } + if !ok { + return nil, billing.Internal("zero-skip lost to a concurrent freeze — run left pending for the next reclaim to reconcile", nil) + } summary.Status = RunStatusInvoiced return summary, nil } @@ -375,9 +384,14 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri withBase = frozen.WithBase } else { if cents == 0 { - if err := s.store.MarkBillingRun(ctx, runID, RunStatusInvoiced, "", 0); err != nil { + // Same D7 guard as the zero-skip above — terminal only while unfrozen. + ok, err := s.store.MarkBillingRunInvoicedIfUnfrozen(ctx, runID) + if err != nil { return nil, billing.Internal("mark billing run (zero cents) failed", err) } + if !ok { + return nil, billing.Internal("zero-cents skip lost to a concurrent freeze — run left pending for the next reclaim to reconcile", nil) + } summary.Status = RunStatusInvoiced return summary, nil } @@ -521,8 +535,17 @@ func (s *Service) boundaryInvoice(ctx context.Context, runID uuid.UUID, custID s return billingstripe.Invoice{}, err } if ok { + if found.Status == "void" { + // A voided invoice means the charge was CANCELED (support action + // during an incident) — adopting it as 'charged' would silently + // forgive the arrears and terminally consume the run. Fail loudly + // into the auditable 'failed' path so ops decides. + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (run %s needs ops resolution)", + found.ID, boundaryChargeRef(runID), runID) + } if found.Status != "draft" { - return found, nil // already finalized — the money moved; just mirror it + return found, nil // finalized (paid/open/uncollectible) — the charge exists; mirror it } switch found.AmountDue { case 0: diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 2277805..3ba3bd3 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -442,6 +442,15 @@ func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOve if !ok { return false, nil } + if found.Status == "void" { + // The charge was CANCELED (support voided it during an incident) — + // adopting it as 'charged' would silently forgive the overage and + // terminally consume the timer's charge identity. Fail loudly into the + // sweep's retried-error path so ops decides. + return false, billing.Internal(fmt.Sprintf( + "module overage recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (timer %s needs ops resolution)", + found.ID, moduleOverageChargeRef(cand.ID), cand.ID), nil) + } proratedMicros, periodStart, _, coverageEnd, _ := moduleOverageCoverage(cand) cents, err := centsFromMicros(proratedMicros) diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 2f0f91c..6073ecc 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -376,9 +376,17 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(locked.AppID)); err != nil { return nil, billing.StripeError("proration recovery lookup failed", err) } else if ok { - if found.Status == "draft" { + switch found.Status { + case "void": + // A voided invoice means the charge was CANCELED — adopting it + // would arm the one-shot guard over money that never moved. + // Fail loudly into the sweep's retried-error path for ops. + return nil, billing.Internal(fmt.Sprintf( + "proration recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (app %s needs ops resolution)", + found.ID, appProrationChargeRef(locked.AppID), locked.AppID), nil) + case "draft": draft = found - } else { + default: inv = found recoveredFinal = true } diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index eadbced..29beaf3 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -390,6 +390,17 @@ func (f *fakeStore) MarkBillingRun(_ context.Context, runID uuid.UUID, status cy // First-write-wins, mirroring the SQL's WHERE frozen_charge_cents IS NULL: a // reclaim that already froze keeps the ORIGINAL values, so a retry can never // overwrite the amount a crashed attempt already put through Stripe. +func (f *fakeStore) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) { + // Mirrors the SQL guard: the terminal zero-mark loses to a concurrent freeze. + if _, frozen := f.frozenCharges[runID]; frozen { + return false, nil + } + if err := f.MarkBillingRun(ctx, runID, cycle.RunStatusInvoiced, "", 0); err != nil { + return false, err + } + return true, nil +} + func (f *fakeStore) FreezeBillingRunCharge(_ context.Context, runID uuid.UUID, charge cycle.FrozenBoundaryCharge) (cycle.FrozenBoundaryCharge, error) { if f.errFreezeCharge != nil { return cycle.FrozenBoundaryCharge{}, f.errFreezeCharge diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 4d84ba8..3cdb52e 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -141,6 +141,14 @@ type Store interface { // (empty → NULL), and the charged total in whole cents. MarkBillingRun(ctx context.Context, runID uuid.UUID, status BillingRunStatus, stripeInvoiceID string, totalCents int64) error + // MarkBillingRunInvoicedIfUnfrozen terminally marks a ZERO-total run + // 'invoiced' (no Stripe call happened) — guarded on the run still being + // UNFROZEN (wave 2, D7): a concurrent reclaim may have frozen + charged + // after this process's top-of-run frozen read, and an unguarded terminal + // mark would bury that charge forever. ok=false → the guard lost; the + // caller must back off and leave the run reclaimable. + MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) + // FreezeBillingRunCharge records — BEFORE the boundary run's first Stripe // charge — the exact amount + base/overage description determinant it will send // under the deterministic idem keys ii-/inv- (migration 035). @@ -919,6 +927,14 @@ func (s *pgxStore) MarkBillingRun(ctx context.Context, runID uuid.UUID, status B }) } +func (s *pgxStore) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) { + rows, err := s.q.MarkBillingRunInvoicedIfUnfrozen(ctx, runID.String()) + if err != nil { + return false, err + } + return rows > 0, nil +} + func (s *pgxStore) FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) (FrozenBoundaryCharge, error) { // WHERE frozen_charge_cents IS NULL (in the query) makes this first-write-wins: // a run that already froze (an earlier attempt, or a CONCURRENT daemon that got @@ -1333,7 +1349,9 @@ func (s *pgxStore) LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUI rows, err := s.q.LiveAppModuleCountsCreatedBefore(ctx, db.LiveAppModuleCountsCreatedBeforeParams{ AccountID: accountID.String(), CreatedBefore: createdBefore, - GraceDays: int32(graceDays), //nolint:gosec // graceDays is the small GraceDays const (3) + // hours, not days (wave 2, D5): keeps the SQL cutoff identical to the Go + // legs' fixed 24h-per-day UTC grace regardless of the session timezone. + GraceHours: int32(graceDays) * 24, //nolint:gosec // graceDays is the small GraceDays const (3) }) if err != nil { return nil, err diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index cdde886..626cc45 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -133,13 +133,13 @@ FROM ms_billing.apps WHERE account_id = $1::uuid AND deleted_at IS NULL AND created_at < $2::timestamptz - AND created_at + make_interval(days => $3::int) < $2::timestamptz + AND created_at + make_interval(hours => $3::int) < $2::timestamptz ` type LiveAppModuleCountsCreatedBeforeParams struct { AccountID string `json:"account_id"` CreatedBefore time.Time `json:"created_before"` - GraceDays int32 `json:"grace_days"` + GraceHours int32 `json:"grace_hours"` } type LiveAppModuleCountsCreatedBeforeRow struct { @@ -172,9 +172,13 @@ type LiveAppModuleCountsCreatedBeforeRow struct { // Deleted apps are excluded (D1e); an account with no rows (pre-backfill) // sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is // returned so the charge leg can write the per-app-period base snapshot -// (migration 028) it bills. +// (migration 028) it bills. The grace interval is expressed in HOURS, not +// days (wave 2, D5): timestamptz + a day-interval is evaluated in the SESSION +// timezone (DST-shifting), while the Go legs' grace is a fixed GraceDays*24h +// UTC window (moduleGraceExpiry) — a non-UTC session would disagree with them +// by an hour around DST and double-bill or gap a whole period. func (q *Queries) LiveAppModuleCountsCreatedBefore(ctx context.Context, arg LiveAppModuleCountsCreatedBeforeParams) ([]LiveAppModuleCountsCreatedBeforeRow, error) { - rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedBefore, arg.GraceDays) + rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedBefore, arg.GraceHours) if err != nil { return nil, err } diff --git a/internal/account/db/cycle.sql.go b/internal/account/db/cycle.sql.go index d4f5202..beccf19 100644 --- a/internal/account/db/cycle.sql.go +++ b/internal/account/db/cycle.sql.go @@ -421,6 +421,32 @@ func (q *Queries) MarkBillingRun(ctx context.Context, arg MarkBillingRunParams) return err } +const markBillingRunInvoicedIfUnfrozen = `-- name: MarkBillingRunInvoicedIfUnfrozen :execrows +UPDATE ms_billing.billing_runs +SET status = 'invoiced', + stripe_invoice_id = NULL, + total_amount = 0 +WHERE id = $1 + AND frozen_charge_cents IS NULL +` + +// MarkBillingRunInvoicedIfUnfrozen is the ZERO-SKIP terminal mark (review +// 2026-07-06 wave 2, D7): marking a run 'invoiced' with NO Stripe call is only +// safe while no attempt has committed to a charge. Under the two-daemons model +// (H6), a concurrent reclaim can freeze + charge between this process's +// top-of-run frozen read and its zero-skip; an unguarded terminal mark would +// bury that charge forever ('invoiced' blocks all future reclaims). The +// frozen_charge_cents IS NULL guard makes the stale zero-skip LOSE: 0 rows → +// the caller errors out, the run stays reclaimable, and the next reclaim +// reconciles the frozen charge. +func (q *Queries) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, markBillingRunInvoicedIfUnfrozen, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const periodChargedTotal = `-- name: PeriodChargedTotal :one SELECT COALESCE(SUM(ua.charged_micros), 0)::bigint AS total_micros FROM ms_billing.usage_aggregates ua diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index 78671a5..8f6a1c6 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -138,14 +138,18 @@ WHERE app_id = $1 -- Deleted apps are excluded (D1e); an account with no rows (pre-backfill) -- sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is -- returned so the charge leg can write the per-app-period base snapshot --- (migration 028) it bills. +-- (migration 028) it bills. The grace interval is expressed in HOURS, not +-- days (wave 2, D5): timestamptz + a day-interval is evaluated in the SESSION +-- timezone (DST-shifting), while the Go legs' grace is a fixed GraceDays*24h +-- UTC window (moduleGraceExpiry) — a non-UTC session would disagree with them +-- by an hour around DST and double-bill or gap a whole period. -- name: LiveAppModuleCountsCreatedBefore :many SELECT app_id, module_count FROM ms_billing.apps WHERE account_id = @account_id::uuid AND deleted_at IS NULL AND created_at < @created_before::timestamptz - AND created_at + make_interval(days => @grace_days::int) < @created_before::timestamptz; + AND created_at + make_interval(hours => @grace_hours::int) < @created_before::timestamptz; -- UpsertProrationBaseSnapshot records what RegisterApp's creation-proration -- leg billed one app for its creation period (migration 028). Keyed by the diff --git a/internal/account/db/queries/cycle.sql b/internal/account/db/queries/cycle.sql index b811b4a..07fcafa 100644 --- a/internal/account/db/queries/cycle.sql +++ b/internal/account/db/queries/cycle.sql @@ -162,6 +162,23 @@ SET status = $2, total_amount = $4 WHERE id = $1; +-- MarkBillingRunInvoicedIfUnfrozen is the ZERO-SKIP terminal mark (review +-- 2026-07-06 wave 2, D7): marking a run 'invoiced' with NO Stripe call is only +-- safe while no attempt has committed to a charge. Under the two-daemons model +-- (H6), a concurrent reclaim can freeze + charge between this process's +-- top-of-run frozen read and its zero-skip; an unguarded terminal mark would +-- bury that charge forever ('invoiced' blocks all future reclaims). The +-- frozen_charge_cents IS NULL guard makes the stale zero-skip LOSE: 0 rows → +-- the caller errors out, the run stays reclaimable, and the next reclaim +-- reconciles the frozen charge. +-- name: MarkBillingRunInvoicedIfUnfrozen :execrows +UPDATE ms_billing.billing_runs +SET status = 'invoiced', + stripe_invoice_id = NULL, + total_amount = 0 +WHERE id = $1 + AND frozen_charge_cents IS NULL; + -- FreezeBillingRunCharge records, BEFORE the boundary Stripe charge, the exact -- whole-cent amount AND the base/overage description determinant this run will -- send Stripe under the deterministic idem keys ii- / inv- (migration From 290d1908fa3d6f43679739c5c0ad039dc39a37ff Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:29:43 +0800 Subject: [PATCH 23/30] fix: boundary precharge keys on immutable cutoffs only, never on grace_resolved (wave 2: D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wave-1 predicate required grace_resolved = true — but resolution is MUTABLE and set only by the grace sweeps, and cmd/billing-cycle runs the boundary spine BEFORE the sweeps in every beat. Any timer whose grace expired in the ~24h window before a boundary (and every timer whose grace expired during a skipped_no_pm/failed outage, on the reclaim) was still unresolved when its covering boundary run executed: excluded from the precharge, run marked invoiced (terminal), and Leg 1's charge — derived from immutable timestamps — stops at the boundary. The post-boundary period was billed by NOBODY, deterministically. The predicate now uses only immutable inputs (live, over-rank, installed_at < period_end, grace_expires_at < period_end), so the precharge decision is identical whenever the run or its reclaim executes; an expired-unresolved timer counted here is later charged its own disjoint install-period coverage by Leg 1 — never a double-bill. Documented residual edge (verdict-at-boundary-time, D1e no-refunds): a rank that improves over→included between the boundary and the sweep keeps the one precharge; the next boundary excludes it by rank. Co-Authored-By: Claude Fable 5 --- .../cycle/migration033_integration_test.go | 11 +++++--- internal/account/cycle/scenarios_test.go | 25 +++++++++++++++++ internal/account/cycle/service_test.go | 7 ++--- internal/account/db/queries/module_timers.sql | 27 ++++++++++++------- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 1ea63c5..41b12b1 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -172,12 +172,17 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { // The boundary that opens the account's [Jul 4, Aug 4) period (anchor day 4). newPeriodStart := mustTime(t, "2026-07-04T00:00:00Z") - // Before any charge, none are "ongoing" (grace_resolved all false). + // The 2 over-rank timers (installed Jun 19, grace expired Jun 22) are + // "ongoing" for the Jul-4 boundary EVEN BEFORE any sweep resolves them — + // the predicate keys on immutable timestamps only (wave 2, D1: keying on + // grace_resolved made the precharge depend on cron ordering and gapped a + // period for timers resolved after their covering boundary run). ongoing, err := store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) - require.Zero(t, ongoing, "nothing resolved yet → no ongoing over-module") + require.Equal(t, 2, ongoing, "expired-but-unresolved over-modules are already ongoing") - // Charge the 2 co-created over-modules (scenario 3) → they become "ongoing". + // Charging them (scenario 3) does not change the count — resolution is not + // part of the predicate. for _, id := range over { require.NoError(t, store.MarkModuleTimerCharged(ctx, id, created.AddDate(0, 0, 3), "in_x", "ii_x")) } diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go index 5d5fedd..5f366a8 100644 --- a/internal/account/cycle/scenarios_test.go +++ b/internal/account/cycle/scenarios_test.go @@ -421,6 +421,31 @@ func TestScenario6_ReclaimedBoundaryNeverPrechargesInsidePeriodModule(t *testing "a module installed inside the new period was already covered by its own grace charge — precharging it again double-bills the period") } +// Regression (wave 2, D1): the precharge must NOT depend on the mutable +// grace_resolved flag. cmd/billing-cycle runs the boundary spine before the +// grace sweeps, so a timer whose grace expired in the ~24h before the boundary +// is still UNRESOLVED when the boundary run executes — keying on resolution +// excluded it, and (Leg 1's coverage being derived from immutable timestamps +// and stopping at the boundary) its post-boundary period was then billed by +// nobody. The predicate now uses immutable cutoffs only. +func TestScenario6_ExpiredButUnresolvedTimerStillPrecharged(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_d1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jun 25 → grace expired Jun 28, BEFORE the Jul 1 boundary — but + // no sweep has resolved it yet (the boundary runs first in the beat). + seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 25, 0)) + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "an expired-but-unresolved over-module is ongoing — its own Leg 1 charge stops at the boundary, so skipping it here gaps the new period") +} + // Regression (review 2026-07-06, C1): an over-module resolved WITHOUT charge // under the D1d period-closed posture (installed pre-activation, so its own // install period is forgiven) still owes overage for every post-activation diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 29beaf3..6a72838 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -882,11 +882,12 @@ func (f *fakeStore) CountOngoingOverModuleTimers(_ context.Context, accountID uu } // Live FIFO: the first includedModules are "included"; the rest are "over". // Count the "over" tail owed a precharge for the new period opening at - // periodEnd: installed before it, grace elapsed before it, verdict resolved - // (charged or D1d resolved-uncharged) — mirroring the SQL predicate. + // periodEnd: installed before it, grace elapsed before it — IMMUTABLE + // cutoffs only (wave 2, D1: resolution state is sweep-ordering-dependent + // and deliberately not part of the predicate), mirroring the SQL. n := 0 for rank, t := range f.liveTimersForAccountFIFO(accountID) { - if rank >= includedModules && t.graceResolved && + if rank >= includedModules && t.installedAt.Before(periodEnd) && t.graceExpiresAt.Before(periodEnd) { n++ } diff --git a/internal/account/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql index 6179c06..2c5385d 100644 --- a/internal/account/db/queries/module_timers.sql +++ b/internal/account/db/queries/module_timers.sql @@ -136,8 +136,8 @@ WHERE id = @timer_id::uuid -- predicate. "over" is re-derived LIVE, so a charged timer that has since flipped -- to "included" (an earlier install removed) is not counted. -- --- The coverage contract with the grace legs (review 2026-07-06) — a timer is --- "ongoing" for the new period iff ALL of: +-- The coverage contract with the grace legs (review 2026-07-06, tightened in +-- wave 2 D1) — a timer is "ongoing" for the new period iff BOTH of: -- * installed_at < @period_end — it existed before the new period opened. A -- module installed INSIDE the new period had that period covered by its OWN -- grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base @@ -148,22 +148,31 @@ WHERE id = @timer_id::uuid -- elapses into, so a boundary-straddling timer's new period belongs to Leg 1, -- not this precharge (counting it would double-bill; skipping the NEXT -- boundary would leave a gap — this predicate does neither). --- * grace_resolved — its grace verdict is terminal. Resolved-WITHOUT-charge --- rows (the D1d period-closed posture: installed pre-activation, so the --- install period itself is forgiven) still owe every period from the first --- post-activation boundary onward; the previous grace_charged_at IS NOT NULL --- proxy silently exempted them from ALL overage billing, forever. +-- +-- DELIBERATELY NOT a condition: grace_resolved (wave 2, D1). Resolution state +-- is MUTABLE and set only by the sweeps, so keying on it made the precharge +-- depend on cron ordering: a timer whose grace expired in the ~24h before the +-- boundary was still unresolved when the boundary run executed, got excluded, +-- and its post-boundary period was then billed by NO leg (Leg 1's coverage is +-- derived from immutable timestamps and stops at the boundary). Both cutoffs +-- above are immutable, so the precharge decision is identical whenever the run +-- (or its reclaim) executes. An expired-unresolved timer counted here is +-- charged its own install-period coverage by Leg 1 later — disjoint windows, +-- never a double-bill. D1d resolved-uncharged rows count too (only the +-- pre-activation install period is forgiven). Residual edge (accepted, +-- verdict-at-boundary-time semantics): a timer whose live rank improves +-- over→included between this run and its own sweep keeps the one precharge — +-- no refund (D1e); the next boundary excludes it by rank. -- name: CountOngoingOverModuleTimers :one SELECT COALESCE(count(*), 0)::bigint AS over_count FROM ( - SELECT installed_at, grace_expires_at, grace_resolved, + SELECT installed_at, grace_expires_at, row_number() OVER (ORDER BY installed_at, id) AS rn FROM ms_billing.app_module_overage_timers WHERE account_id = @account_id::uuid AND removed_at IS NULL ) ranked WHERE rn > @included_modules::int - AND grace_resolved = true AND installed_at < @period_end::timestamptz AND grace_expires_at < @period_end::timestamptz; From 79d6ae13cc2db0123b3d44d465e1c5d739eb040e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:36:09 +0800 Subject: [PATCH 24/30] =?UTF-8?q?fix:=20D1d=20forgives=20only=20pre-activa?= =?UTF-8?q?tion=20coverage=20=E2=80=94=20the=20straddled=20post-activation?= =?UTF-8?q?=20period=20is=20billed=20(wave=202:=20D4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The period-closed posture resolved a pre-activation install (or app creation) terminally uncharged even when its grace straddled into the FIRST post-activation period — a period the account was fully chargeable for and in which the module/app was live. The boundary predicate excludes that period too (grace_expires_at >= its start), so it was billed by nobody. The charge shape (one shared home per leg: moduleOverageChargeShape / the proration callback) now narrows a closed-creation straddle to the straddled period billed IN FULL, with the mirror window and snapshot narrowed to match; only coverage that closed ENTIRELY before activation stays forgiven and permanently skipped. Deterministic inputs only, so fresh charges, idem replays, and recovery recompute the same shape. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/overage.go | 83 +++++++++++++++--------- internal/account/cycle/overage_test.go | 55 ++++++++++++++++ internal/account/cycle/proration.go | 60 +++++++++++++---- internal/account/cycle/proration_test.go | 45 +++++++++++++ 4 files changed, 202 insertions(+), 41 deletions(-) diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 3ba3bd3..43b769b 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -104,23 +104,38 @@ type ModuleOverageResult struct { StripeInvoiceID string } -// moduleOverageCoverage resolves the deterministic coverage + amount for one -// timer under the 2026-07-06 coverage contract — install day → the END of the -// period the grace elapses into (install period prorated + the straddled -// period in full when the grace crosses the boundary). Every input -// (installed_at, grace_expires_at, activation anchor) is immutable, so the -// SAME amount is recomputed by a fresh charge, an idem-key replay, and the -// post-idem-key-window recovery path — one home for the math keeps the three -// from drifting. -func moduleOverageCoverage(cand ModuleOverageCandidate) (proratedMicros int64, periodStart, periodEnd, coverageEnd time.Time, closed bool) { - periodStart, periodEnd, closed = periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) +// moduleOverageChargeShape resolves the deterministic charge for one timer +// under the 2026-07-06 coverage contract — install day → the END of the period +// the grace elapses into (install period prorated + the straddled period in +// full when the grace crosses the boundary) — INCLUDING the D1d decision +// (wave 2, D4): the pre-activation install period is forgiven, but a grace +// that straddles into a period the account WAS activated during leaves that +// straddled period chargeable — it is billed in full with the coverage window +// narrowed to it; only when the ENTIRE coverage is pre-activation-closed is +// the timer fully forgiven. Every input (installed_at, grace_expires_at, +// activation anchor) is immutable, so a fresh charge, an idem-key replay, and +// the post-idem-key-window recovery path all recompute the identical shape — +// one home for the math keeps the three from drifting. +func moduleOverageChargeShape(cand ModuleOverageCandidate) (proratedMicros int64, coverageStart, coverageEnd time.Time, fullyForgiven bool) { + periodStart, periodEnd, closed := periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) + coverageStart = usage.ProrationCoverageStart(cand.InstalledAt, periodStart) coverageEnd = periodEnd proratedMicros = usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) if !cand.GraceExpiresAt.Before(periodEnd) { _, coverageEnd = billingperiod.AnchoredPeriodWindow(cand.GraceExpiresAt.UTC(), billingperiod.AnchorDay(cand.ActivatedAt)) proratedMicros += usage.ModuleOverageFeeMicros } - return proratedMicros, periodStart, periodEnd, coverageEnd, closed + if closed { + if coverageEnd.After(periodEnd) && cand.ActivatedAt.Before(coverageEnd) { + // D1d forgives only the install period; the straddled period is + // post-activation and owed in full. + proratedMicros = usage.ModuleOverageFeeMicros + coverageStart = periodEnd + return proratedMicros, coverageStart, coverageEnd, false + } + return 0, time.Time{}, time.Time{}, true + } + return proratedMicros, coverageStart, coverageEnd, false } // ChargeModuleOverage evaluates + (if "over") charges ONE per-module install @@ -201,11 +216,11 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // anchored period (ADR 0005 anchor from activation) — install-anchored, NOT // grace-elapse-anchored and NOT now-anchored — plus, for a grace that // STRADDLES the period boundary, the full fee for the period the grace - // elapses into (moduleOverageCoverage; the boundary precharge deliberately + // elapses into (moduleOverageChargeShape; the boundary precharge deliberately // excludes straddlers via its grace_expires_at < period_end cutoff, so that // period is THIS leg's to bill, and only from the boundary after that does // the precharge take over). - proratedMicros, periodStart, _, coverageEnd, closed := moduleOverageCoverage(cand) + proratedMicros, coverageStart, coverageEnd, fullyForgiven := moduleOverageChargeShape(cand) // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration // enforces on the creation leg, proration.go). RegisterApp synthesizes an app's @@ -213,16 +228,18 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan // yet, and the work-list only gates on activation at CHARGE time — so a timer // can sit installed + past-grace for arbitrarily long while unactivated, then // get swept the instant the account finally binds a card. If the account only - // activated AT OR AFTER this install's anchored period had already closed, the - // account was never chargeable for that whole period; charging its overage now - // — however late the sweep runs — is exactly the retroactive catch-up D1d - // forbids. Resolve the timer terminally WITHOUT charging (grace_resolved, - // first-write-wins via MarkModuleTimerIncluded) so it never resurfaces, rather - // than minting a historical, never-chargeable invoice. Compared against - // ActivatedAt, NOT `at`: an ordinary late sweep on a HEALTHY already-activated - // account (grace pushing the charge a few days past periodEnd) still charges, - // exactly like the creation leg's ActivatedBeforePeriodCloses case. - if closed { + // activated AT OR AFTER the timer's ENTIRE coverage had already closed, the + // account was never chargeable for any of it; charging now — however late the + // sweep runs — is exactly the retroactive catch-up D1d forbids. Resolve the + // timer terminally WITHOUT charging (grace_resolved, first-write-wins via + // MarkModuleTimerIncluded) so it never resurfaces, rather than minting a + // historical, never-chargeable invoice. Compared against ActivatedAt, NOT + // `at`: an ordinary late sweep on a HEALTHY already-activated account (grace + // pushing the charge a few days past periodEnd) still charges. A grace that + // straddles into a post-activation period is NOT fully forgiven (wave 2, D4) + // — the shape narrows the charge to that straddled period in full; only the + // pre-activation install period is ever forgiven. + if fullyForgiven { if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { return nil, billing.Internal("mark module timer resolved (period closed) failed", err) } @@ -341,11 +358,11 @@ func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCan AmountDueCents: inv.AmountDue, AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, - // Partial coverage window [install day (UTC midnight), coverage end) — the - // SAME window the amount above priced (coverage end extends past the install - // period only for a boundary-straddling grace), so the mirrored window and - // the charged amount agree by construction. - PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), + // The coverage window the shape priced — [install day, coverage end) in + // the normal case; narrowed to the straddled period alone under the D1d + // straddle rule — so the mirrored window and the charged amount agree by + // construction. + PeriodStart: coverageStart, PeriodEnd: coverageEnd, IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), }); err != nil { @@ -452,7 +469,13 @@ func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOve found.ID, moduleOverageChargeRef(cand.ID), cand.ID), nil) } - proratedMicros, periodStart, _, coverageEnd, _ := moduleOverageCoverage(cand) + proratedMicros, coverageStart, coverageEnd, fullyForgiven := moduleOverageChargeShape(cand) + if fullyForgiven { + // The shape is deterministic, so an attempted timer can only be fully + // forgiven if the attempt itself would have been — nothing chargeable to + // recover; the caller's fresh path resolves it period_closed. + return false, nil + } cents, err := centsFromMicros(proratedMicros) if err != nil { return false, billing.Internal("micros to cents conversion failed", err) @@ -498,7 +521,7 @@ func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOve AmountDueCents: inv.AmountDue, AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, - PeriodStart: usage.ProrationCoverageStart(cand.InstalledAt, periodStart), + PeriodStart: coverageStart, PeriodEnd: coverageEnd, IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), }); err != nil { diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index 1953599..b4aab57 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -180,6 +180,61 @@ func TestModuleOverage_RemovedWithinGraceNeverCharged(t *testing.T) { require.False(t, store.timers[over].graceCharged) } +// Regression (wave 2, D4): D1d forgives the PRE-ACTIVATION install period only. +// A pre-activation install whose grace straddles into the first post-activation +// period used to be resolved period_closed uncharged — forgiving the straddled, +// fully-chargeable period along with the install period (and the boundary +// predicate excludes it there too, so nobody billed it). The charge is now +// narrowed to the straddled period in full. +func TestModuleOverage_D1dStraddleChargesThePostActivationPeriod(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), 5) + // Installed May 2 (rank 5, over) — pre-activation, install period + // [Apr 4, May 4) closed by the May 4 activation — but its grace expires + // May 5, INSIDE the first post-activation period [May 4, Jun 4). + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "the straddled post-activation period is owed — only the install period is forgiven") + require.True(t, store.timers[over].graceCharged) + // Full $3 for the straddled period alone, window narrowed to it. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 300, sc.itemCalls[0].amountCfg) + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), inv.PeriodStart) + require.Equal(t, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd) + } +} + +// The complement: a pre-activation install whose ENTIRE coverage (install +// period AND grace) closed before activation stays fully forgiven (D1d). +func TestModuleOverage_D1dFullyPreActivationStaysForgiven(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), 5) + // Installed Apr 10 (rank 5, over) — grace expired Apr 13, both inside the + // pre-activation [Apr 4, May 4) period. + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Zero(t, res.Charged) + require.Empty(t, sc.itemCalls, "a fully pre-activation coverage is never retroactively billed") + require.True(t, store.timers[over].graceResolved, "resolved terminally (period_closed)") + require.False(t, store.timers[over].graceCharged) +} + // --- grace straddling a period boundary: Leg 1 covers the straddled period ---- // Regression (review 2026-07-06, M1): a grace window that straddles a period diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 6073ecc..3f63d9b 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -237,11 +237,24 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) // (an app created near its period boundary) — that is expected, intended // delayed billing (still the ONLY leg that ever bills this period), not a // retroactive catch-up, and must still charge normally. - if _, _, closed := periodClosedByActivation(app.CreatedAt, activatedAt); closed { - if err := s.store.SetAppProrationSkipped(ctx, appID); err != nil { - return nil, billing.Internal("mark proration permanently skipped failed", err) + // A creation grace that straddles into a period the account WAS activated + // during is NOT fully forgiven (wave 2, D4): D1d forgives the pre-activation + // creation period only — the straddled post-activation period is owed in + // full (the charge callback narrows the amount + window to it), and the + // advance leg only picks the app up from the NEXT boundary. + if _, periodEnd, closed := periodClosedByActivation(app.CreatedAt, activatedAt); closed { + graceExpiry := moduleGraceExpiry(app.CreatedAt.UTC()) + straddleChargeable := false + if !graceExpiry.Before(periodEnd) { + _, coverageEnd := billingperiod.AnchoredPeriodWindow(graceExpiry, billingperiod.AnchorDay(activatedAt)) + straddleChargeable = activatedAt.Before(coverageEnd) + } + if !straddleChargeable { + if err := s.store.SetAppProrationSkipped(ctx, appID); err != nil { + return nil, billing.Internal("mark proration permanently skipped failed", err) + } + return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil } - return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil } if s.stripe == nil { @@ -324,6 +337,18 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) _, coverageEnd = billingperiod.AnchoredPeriodWindow(moduleGraceExpiry(locked.CreatedAt.UTC()), billingperiod.AnchorDay(activatedAt)) prorated += usage.BaseFeeMicros } + // D1d straddle narrowing (wave 2, D4). With a pre-activation-closed + // creation period this point is only reachable when the grace straddles + // into a post-activation period (the outer period-closed gate permanently + // skips every other closed case): forgive the creation period, bill the + // straddled one in full, and narrow the mirror window to it. + coverageStart := usage.ProrationCoverageStart(locked.CreatedAt, periodStart) + creationPeriodClosed := !activatedAt.Before(periodEnd) + if creationPeriodClosed { + creationPeriodMicros = 0 + prorated = usage.BaseFeeMicros + coverageStart = periodEnd + } c, err := centsFromMicros(prorated) if err != nil { return nil, billing.Internal("micros to cents conversion failed", err) @@ -354,6 +379,10 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if straddle { overageMicros += usage.ModuleOverageFeeMicros } + if creationPeriodClosed { + // Same D1d narrowing as the base: only the straddled period is owed. + overageMicros = usage.ModuleOverageFeeMicros + } overageCents, err := centsFromMicros(overageMicros) if err != nil { return nil, billing.Internal("overage micros to cents conversion failed", err) @@ -481,11 +510,9 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, billing.Internal("account collection lookup failed", err) } - // Mirror the PARTIAL window [creation day, coverage end) — the same window - // the amount priced (coverage end extends past the creation period only - // when the grace straddles the boundary), so mirror and amount agree by - // construction. - partialStart := usage.ProrationCoverageStart(locked.CreatedAt, periodStart) + // Mirror the window the amount priced — [creation day, coverage end) + // normally; narrowed to the straddled period alone under the D1d straddle + // rule — so mirror and amount agree by construction. cents = c pc := &ProrationCharge{ InvoiceID: inv.ID, @@ -497,7 +524,7 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) AmountDueCents: inv.AmountDue, AmountPaidCents: inv.AmountPaid, Currency: chargeCurrency, - PeriodStart: partialStart, + PeriodStart: coverageStart, PeriodEnd: coverageEnd, IsLargeAutoCollect: flagLargeAutoCollect(prorated+overageTotalMicros, acct), }, @@ -514,7 +541,18 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) }, TimerCharges: timerCharges, } - if straddle { + if creationPeriodClosed { + // D1d straddle (wave 2, D4): only the straddled period was billed — + // its snapshot is the primary one; the forgiven creation period gets + // no row (nothing was charged for it). + pc.Snapshot = AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodEnd, + PeriodEnd: coverageEnd, + ModuleCount: locked.CreatedModuleCount, + BaseMicros: usage.BaseFeeMicros, + } + } else if straddle { // The straddled period was billed IN FULL on this same invoice — freeze // its own snapshot row so the display shows that period's base as // charged (the boundary leg excluded the app there and writes nothing). diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index c289f61..8e857a5 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -73,6 +73,51 @@ func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { require.Empty(t, store.apps[appID].ProrationInvoiceID) } +// Regression (wave 2, D4): an app created pre-activation whose creation grace +// straddles into the first post-activation period used to be PERMANENTLY +// skipped (period_closed) — forgiving the straddled, fully-chargeable period. +// D1d forgives the creation period only; the straddled period is billed in +// full, and a fully pre-activation app stays forgiven. +func TestChargeCreationProration_D1dStraddleChargesThePostActivationPeriod(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created May 2 — pre-activation (creation period [Apr 4, May 4) closes at + // activation) — grace expires May 5, inside [May 4, Jun 4). + registerMirror(t, svc, user, appID, time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2000, resp.ProrationCents, "the straddled period's FULL $20 base — the creation period is forgiven") + mirror := store.invoices[sc.invoiceID] + require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodStart, "window narrowed to the straddled period") + require.Equal(t, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the straddled period gets the snapshot") + require.EqualValues(t, 20_000_000, snap.snap.BaseMicros) + _, forgiven := store.baseSnapshots[snapKey{appID, time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC)}] + require.False(t, forgiven, "the forgiven creation period gets no snapshot") +} + +func TestChargeCreationProration_D1dFullyPreActivationStaysSkipped(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) // activated 2026-05-04 09:00 + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created Apr 10 — grace expired Apr 13, entirely pre-activation. + registerMirror(t, svc, user, appID, time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, resp.Status) + require.Empty(t, sc.itemCalls, "fully pre-activation coverage stays forgiven and permanently skipped") + require.True(t, store.apps[appID].ProrationSkipped) +} + // Regression (review 2026-07-06, H5): a creation-proration retry past Stripe's // ~24h idempotency-key window reconciles by the app's ms_charge_ref anchor — // a crashed attempt's finalized combined invoice is adopted (guard armed with From a9ccfd175d4a79449275e8994686f835cb13e7ae Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:39:42 +0800 Subject: [PATCH 25/30] fix: recovered combined draft accepts the crashed attempt's larger line set (wave 2: D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The H5 recovery validated a recovered draft against a total recomputed from the LIVE co-created timer set — which can only SHRINK between the crash and the retry (an uninstall, or a rank flip via an earlier removal). Any shrink turned a COMPLETE recovered draft into the refuse-loudly branch on every subsequent sweep, forever: the guard never armed, the deferred-to-combined guard kept Leg 1 off the timers, and the boundary excluded them — the app's creation+straddle base and all its co-created overage were permanently unbilled behind a daily error livelock. Validation is now structural: base + k overage lines with len(live set) <= k <= created_module_count is a complete deterministic line set (the crashed attempt's then-correct superset stands; D1e forbids unwinding pinned lines for since-removed timers). k below the live set — a draft genuinely crashed mid-attach — and any amount fitting no k stay refused loudly for ops, since completing them past the idem-key window risks duplicate lines. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/proration.go | 40 ++++++++++----- internal/account/cycle/proration_test.go | 62 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 3f63d9b..1bd17b8 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -441,14 +441,32 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) } } - // Attach the lines — unless a recovered draft already carries them all - // (AmountDue == the deterministic total). A partially-lined draft (some - // items landed before the crash, keys since pruned) cannot be completed - // safely — refuse loudly for ops rather than risk duplicate lines. - switch draft.AmountDue { - case expectedTotalCents: - // every line already attached — collect the marks with the known - // invoice id (item ids unrecoverable from the search projection) + // Attach the lines — unless a recovered draft already carries a + // COMPLETE deterministic line set: base + k overage lines with + // len(overTimers) ≤ k ≤ created_module_count (wave 2, D2). The live + // overTimers set can only SHRINK between the crash and the retry (an + // uninstall, or a rank flip via an earlier removal), so demanding + // equality with the LIVE recomputation livelocked every such retry + // forever — the crashed attempt's larger (then-correct) line set is + // equally valid: those timers were live and over when the lines were + // pinned, and D1e forbids unwinding them. k below the live set means + // the draft is genuinely INCOMPLETE (crashed mid-attach) — completing + // it past the idem-key window risks duplicate lines, so that (and any + // amount fitting no k) is refused loudly for ops. + completeForSomeK := false + if overageCents > 0 && draft.AmountDue >= c { + rem := draft.AmountDue - c + if rem%overageCents == 0 { + k := rem / overageCents + completeForSomeK = k >= int64(len(overTimers)) && k <= int64(locked.CreatedModuleCount) + } + } + switch { + case draft.AmountDue == expectedTotalCents || completeForSomeK: + // every line already attached — collect the marks for the LIVE + // still-unresolved timers with the known invoice id (item ids + // unrecoverable from the search projection; a timer removed since + // the crash keeps its pinned line — D1e — but needs no mark) if overageCents > 0 { for _, timerID := range overTimers { timerCharges = append(timerCharges, ModuleTimerCharge{ @@ -456,7 +474,7 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) }) } } - case 0: + case draft.AmountDue == 0: desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { return nil, billing.StripeError("proration invoice item failed", err) @@ -478,8 +496,8 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) } default: return nil, billing.Internal(fmt.Sprintf( - "proration recovery: draft %s carries %d cents but the deterministic total is %d — refusing to finalize a mismatched combined draft (app %s)", - draft.ID, draft.AmountDue, expectedTotalCents, locked.AppID), nil) + "proration recovery: draft %s carries %d cents, which is neither 0 nor base(%d)+k×overage(%d) for any k ≤ %d — refusing to finalize a corrupt combined draft (app %s)", + draft.ID, draft.AmountDue, c, overageCents, locked.CreatedModuleCount, locked.AppID), nil) } inv, err = s.stripe.FinalizeInvoice(ctx, draft.ID, appProrationFinalizeIdemKey(locked.AppID)) diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index 8e857a5..0463b51 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -73,6 +73,68 @@ func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { require.Empty(t, store.apps[appID].ProrationInvoiceID) } +// Regression (wave 2, D2): a recovered combined draft whose co-created timer +// set SHRANK between crash and retry (uninstall / rank flip) used to hit the +// refuse-loudly branch on every sweep forever — the app's proration never +// resolved and its modules were never billed by any leg. The validation now +// accepts base + k overage lines for live-set ≤ k ≤ created count. +func TestChargeCreationProration_RecoveredDraftAcceptsShrunkTimerSet(t *testing.T) { + store := newFakeStore() + user, acct := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created mid-period with 7 co-created modules → 2 over. Base 15/30 days = + // $10 (1000¢); each overage line 15/30 of $3 = $1.50 (150¢). + created := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) + registerMirror(t, svc, user, appID, created, 7) + _ = acct + + // The crashed attempt pinned base + BOTH overage lines (1000 + 2×150 = 1300¢) + // and died before finalize. Marker durable. + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + sc.findByRef = &billingstripe.Invoice{ID: "in_shrunk_draft", Status: "draft", AmountDue: 1300, Currency: "usd"} + + // Between crash and retry one co-created over-module is uninstalled — the + // live set shrinks to 1. + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: intPtr(6)}) + require.NoError(t, err) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err, "a shrunk timer set must not livelock the recovery") + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.Equal(t, "in_shrunk_draft", resp.ProrationInvoiceID) + require.Empty(t, sc.itemCalls, "the crashed attempt's complete line set is finalized as-is") + require.Len(t, sc.finalizeCalls, 1) + require.Equal(t, "in_shrunk_draft", sc.finalizeCalls[0].invoiceID) + require.Equal(t, "in_shrunk_draft", store.apps[appID].ProrationInvoiceID, "the guard arms — no livelock") +} + +// The complement: a draft carrying FEWER lines than the still-live set is +// genuinely incomplete (crashed mid-attach) — completing it past the idem-key +// window risks duplicate lines, so it is refused loudly for ops. +func TestChargeCreationProration_RecoveredDraftBelowLiveSetRefused(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + // Base + only 1 of the 2 live over-lines (1000 + 150 = 1150¢): incomplete. + sc.findByRef = &billingstripe.Invoice{ID: "in_partial_draft", Status: "draft", AmountDue: 1150, Currency: "usd"} + + _, err := svc.ChargeCreationProration(context.Background(), appID) + require.Error(t, err, "an incomplete draft is refused for ops, never silently completed or finalized") + require.Empty(t, sc.finalizeCalls) + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + // Regression (wave 2, D4): an app created pre-activation whose creation grace // straddles into the first post-activation period used to be PERMANENTLY // skipped (period_closed) — forgiving the straddled, fully-chargeable period. From b14bdf2c5fe1a22c0bb88977999a10b22d2e8b94 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:44:49 +0800 Subject: [PATCH 26/30] fix: recovery bypasses gates only when the charge actually EXISTS on Stripe (wave 2: D6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attempt/freeze markers are stamped BEFORE the first Stripe call, so 'attempted' alone never means money moved — a crash or Stripe 4xx/5xx between the marker and CreateDraftInvoice leaves nothing on Stripe, yet all three legs treated the marker as license to bypass the prepaid/PM gates on the retry and then charged FRESH when the ref lookup found nothing: a prepaid-tightened or PM-removed account got a brand-new off-session debit through the 'recovery' path. The gate-bypass now keys on what the ref lookup actually finds, looked up once next to the marker read: a FINALIZED invoice → money moved → reconcile with gates bypassed (H8 unchanged); an inert DRAFT → no money moved → finalizing is a fresh debit, every gate applies (a gate skip leaves the draft inert and the run/timer/app non-terminal); NOTHING → genuinely fresh charge, every gate applies. Search-lag safety: the Search API lags ≲1min while idem keys live ~24h, so a lag-missed invoice is re-found by key replay — a false 'nothing' cannot double-charge. Boundary re-gate skips stay non-terminal and the frozen amount survives for a post-relax reclaim. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge.go | 139 +++++++++++++++----------- internal/account/cycle/charge_test.go | 44 +++++++- internal/account/cycle/overage.go | 10 ++ internal/account/cycle/proration.go | 71 +++++++------ 4 files changed, 172 insertions(+), 92 deletions(-) diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 6c49fde..3052894 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -110,6 +110,39 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.Internal("frozen boundary charge lookup failed", err) } + // The freeze is stamped BEFORE the first Stripe call, so "frozen" alone does + // not mean money moved — a crash (or Stripe 4xx) between the freeze and + // CreateDraftInvoice leaves nothing on Stripe at all. Resolve which case + // this reclaim is NOW (wave 2, D6): an invoice found under the run's + // ms_charge_ref means a prior attempt reached Stripe and this run's only + // job is to finish it (gates bypassed, below); nothing found means the + // retry is a genuinely FRESH charge and every collection gate must apply — + // bypassing them let a prepaid-tightened / PM-removed account be charged + // fresh. (Search-lag safety: the Search API lags writes by ≲1min while idem + // keys live ~24h — a lag-missed invoice is re-found by idem-key replay, so + // a false "nothing" can never double-charge.) Skips taken on the re-gated + // path are non-terminal; the frozen amount survives for a later reclaim. + var recovered *billingstripe.Invoice + if hasFrozen { + custID, err := s.store.AccountStripeCustomer(ctx, accountID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID != "" { + if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, boundaryChargeRef(runID)); err != nil { + return nil, billing.StripeError("boundary recovery lookup failed", err) + } else if ok { + recovered = &found + } + } + } + // moneyMayHaveMoved is what actually justifies bypassing the gates: a + // FINALIZED invoice (or a void one — refused loudly downstream, D10). A + // recovered inert DRAFT moved no money yet, so finalizing it is still a + // fresh off-session debit and every gate applies; a gate skip leaves the + // draft inert and the run non-terminal for a later re-attempt. + moneyMayHaveMoved := recovered != nil && recovered.Status != "draft" + // RISK-GRADED COLLECTION GATE (PR #9, design §7-A / billing-tiers §3). Load // the account's collection state up front. The off-session arrears leg may // only ship behind this gate (the GA gate); the run row already exists, so a @@ -126,7 +159,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // settle it is a DEFERRED follow-up. Never applied over a frozen charge (H8): // a mode tightened AFTER the crashed attempt charged must not strand the // moved money unmirrored. - if !hasFrozen && acct.Mode == BillingModePrepaid { + if !moneyMayHaveMoved && acct.Mode == BillingModePrepaid { if err := s.store.MarkBillingRun(ctx, runID, RunStatusSkippedPrepaid, "", 0); err != nil { return nil, billing.Internal("mark billing run (skipped_prepaid) failed", err) } @@ -240,7 +273,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // re-attempt, keeping one-invoice-per-boundary.) Independent of mode/credit- // limit (a hard cap, not a trust judgment). Never applied over a frozen // charge (H8) — the crashed attempt's money may already have moved. - if !hasFrozen && collection.ExceedsSpendCeiling(toCollectionAccount(acct), arrears) { + if !moneyMayHaveMoved && collection.ExceedsSpendCeiling(toCollectionAccount(acct), arrears) { // skipped_ceiling, NOT skipped_prepaid: the ceiling is a per-cycle cap, not // a mode transition — the account stays in arrears mode and the next cycle // re-attempts once the ceiling is raised or the arrears net below it. The @@ -268,7 +301,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // attempt's OWN open invoice must not strand that invoice unmirrored. // TODO(#9-followup): wire a usage-spike anomaly signal + a // sustained-clean-standing window. - if !hasFrozen { + if !moneyMayHaveMoved { delinquent, err := s.store.HasUnpaidInvoice(ctx, accountID) if err != nil { return nil, billing.Internal("delinquency lookup failed", err) @@ -299,21 +332,22 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri } } - // PM gate. Fresh run: no usable default PM (or the usable-PM-implies-Customer - // anomaly) → skip (usage RETAINED), re-attempt next cycle. Frozen run (H8): - // the PM gate does NOT apply — the idem-key replay of already-created Stripe - // objects needs no fresh authorization, and a genuinely fresh finalize with - // the PM since removed fails loudly into the 'failed' (retryable, auditable) - // path below rather than being recorded as a skip over moved money. Only the - // Customer id is required. + // PM gate. Fresh run — including a frozen reclaim whose prior attempt never + // reached Stripe (D6) — no usable default PM (or the usable-PM-implies- + // Customer anomaly) → skip (usage RETAINED), re-attempt next cycle. Only + // when the prior attempt's invoice EXISTS on Stripe is the gate bypassed + // (H8): completing already-created Stripe objects needs no fresh + // authorization, and a finalize with the PM since removed fails loudly into + // the 'failed' (retryable, auditable) path below rather than being recorded + // as a skip over moved money. Only the Customer id is required there. var custID string - if hasFrozen { + if moneyMayHaveMoved { custID, err = s.store.AccountStripeCustomer(ctx, accountID) if err != nil { return nil, billing.Internal("stripe customer lookup failed", err) } if custID == "" { - return nil, billing.Internal("billing run has a frozen charge but the account has no Stripe customer id", nil) + return nil, billing.Internal("billing run has a recovered Stripe invoice but the account has no Stripe customer id", nil) } } else { var ok bool @@ -407,11 +441,12 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // Charge — or, on a frozen reclaim, RECONCILE against Stripe first (H5): the // frozen marker means a prior attempt reached its Stripe section, and past // the ~24h idempotency-key window a bare "replay" would mint brand-new - // objects and double-charge. boundaryInvoice looks the run's invoice up by - // its ms_charge_ref anchor and finishes whatever it finds; only when Stripe - // has nothing does it charge fresh. A failure after the PM gate marks the - // run 'failed' (auditable) and returns the error. - inv, err := s.boundaryInvoice(ctx, runID, custID, cents, withBase, hasFrozen) + // objects and double-charge. boundaryInvoice finishes the invoice already + // FOUND under the run's ms_charge_ref (looked up once, next to the frozen + // read — D6); with nothing recovered it charges fresh (through the gates + // above). A failure after the PM gate marks the run 'failed' (auditable) + // and returns the error. + inv, err := s.boundaryInvoice(ctx, runID, custID, cents, withBase, recovered) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -519,48 +554,40 @@ func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, ce return s.stripe.FinalizeInvoice(ctx, draft.ID, invoiceFinalizeIdemKey(runID)) } -// boundaryInvoice resolves the boundary run's Stripe invoice. reconcile=true -// (a frozen reclaim — a prior attempt reached its Stripe section) looks the -// invoice up by the run's ms_charge_ref anchor first (H5 — idem keys are -// pruned by Stripe after ~24h, so key replay alone cannot be trusted on a -// late reclaim): a finalized invoice is returned as-is (money already moved); -// an inert draft is completed — the deterministic line attached if it never -// landed, then finalized; a mismatched draft is refused loudly. Only when -// Stripe has nothing under the ref (the crashed attempt never created its -// invoice) — or on a fresh run — does it charge through s.charge. -func (s *Service) boundaryInvoice(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase, reconcile bool) (billingstripe.Invoice, error) { - if reconcile { - found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, boundaryChargeRef(runID)) - if err != nil { - return billingstripe.Invoice{}, err +// boundaryInvoice resolves the boundary run's Stripe invoice. recovered (the +// invoice RunBillingCycle already found under the run's ms_charge_ref anchor, +// next to the frozen read — H5/D6) is finished: a finalized invoice is +// returned as-is (money already moved); a VOID one is refused loudly (the +// charge was canceled — adopting it would silently forgive the arrears and +// terminally consume the run); an inert draft is completed — the +// deterministic line attached if it never landed, then finalized; a +// mismatched draft is refused loudly. recovered == nil (nothing on Stripe, or +// a fresh run) charges through s.charge — that path re-entered every +// collection gate in RunBillingCycle. +func (s *Service) boundaryInvoice(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool, recovered *billingstripe.Invoice) (billingstripe.Invoice, error) { + if recovered != nil { + found := *recovered + if found.Status == "void" { + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (run %s needs ops resolution)", + found.ID, boundaryChargeRef(runID), runID) } - if ok { - if found.Status == "void" { - // A voided invoice means the charge was CANCELED (support action - // during an incident) — adopting it as 'charged' would silently - // forgive the arrears and terminally consume the run. Fail loudly - // into the auditable 'failed' path so ops decides. - return billingstripe.Invoice{}, fmt.Errorf( - "boundary recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (run %s needs ops resolution)", - found.ID, boundaryChargeRef(runID), runID) - } - if found.Status != "draft" { - return found, nil // finalized (paid/open/uncollectible) — the charge exists; mirror it - } - switch found.AmountDue { - case 0: - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { - return billingstripe.Invoice{}, err - } - case cents: - // line already attached — nothing to add - default: - return billingstripe.Invoice{}, fmt.Errorf( - "boundary recovery: draft %s carries %d cents but the frozen amount is %d — refusing to finalize a mismatched draft (run %s)", - found.ID, found.AmountDue, cents, runID) + if found.Status != "draft" { + return found, nil // finalized (paid/open/uncollectible) — the charge exists; mirror it + } + switch found.AmountDue { + case 0: + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { + return billingstripe.Invoice{}, err } - return s.stripe.FinalizeInvoice(ctx, found.ID, invoiceFinalizeIdemKey(runID)) + case cents: + // line already attached — nothing to add + default: + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: draft %s carries %d cents but the frozen amount is %d — refusing to finalize a mismatched draft (run %s)", + found.ID, found.AmountDue, cents, runID) } + return s.stripe.FinalizeInvoice(ctx, found.ID, invoiceFinalizeIdemKey(runID)) } return s.charge(ctx, runID, custID, cents, withBase) } diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index d45ac4d..6aaf919 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -324,20 +324,58 @@ func TestRunBillingCycle_FrozenRunNotSkippedByPrepaidOrPMGates(t *testing.T) { // Between attempts: the account tightens to prepaid (possibly triggered by // the crashed attempt's own open invoice) AND its default PM is removed. + // The crashed attempt's FINALIZED invoice exists on Stripe under the ref + // (that existence — not the frozen marker alone — is what justifies + // bypassing the gates, wave 2 D6). store.collection.Mode = cycle.BillingModePrepaid store.hasPM = false store.errMarkRun = nil + sc.findByRef = &billingstripe.Invoice{ID: sc.invoiceID, Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"} // RETRY: pre-fix → skipped_prepaid (or skipped_no_pm), stranding the moved - // money unmirrored. Fixed: gates never apply over a frozen charge; the - // replay completes through the same keys. + // money unmirrored. Fixed: gates never apply over an EXISTING charge; the + // reconcile completes through the same objects. resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) require.NoError(t, err) require.Equal(t, cycle.RunStatusInvoiced, resp.Status, - "a frozen run finishes; it is never re-gated into a skip over moved money") + "a run whose charge exists on Stripe finishes; it is never re-gated into a skip over moved money") require.Len(t, store.invoices, 1) } +// Regression (wave 2, D6): the frozen marker is stamped BEFORE the first +// Stripe call, so "frozen" alone does not mean money moved. A reclaim whose +// prior attempt froze and then died BEFORE creating anything on Stripe is a +// genuinely fresh charge — the collection gates must apply, not be bypassed. +func TestRunBillingCycle_FrozenButNothingOnStripeIsReGated(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_d6" + + sc := newFakeStripe() + + // FIRST attempt: freezes, then the draft create fails — nothing on Stripe. + sc.errDraft = errors.New("stripe 5xx before any object existed") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Empty(t, sc.finalizeCalls, "no money moved") + + // Overnight the account tightens to prepaid. findByRef stays nil (nothing + // under the ref). + store.collection.Mode = cycle.BillingModePrepaid + sc.errDraft = nil + + // RECLAIM: pre-fix the frozen marker bypassed every gate and a FRESH + // draft+item+finalize fired against the prepaid account. Fixed: nothing on + // Stripe → re-gated → skipped_prepaid (non-terminal; the frozen amount + // survives for a post-relax reclaim). + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusSkippedPrepaid, resp.Status, + "frozen-but-nothing-on-Stripe is a fresh charge — the prepaid gate applies") + require.Empty(t, sc.finalizeCalls, "no off-session charge against a prepaid account") +} + // Regression (review 2026-07-06, H6): the freeze is first-write-wins AND the // charger adopts the SURVIVING value — a concurrent second daemon that // reclaimed the same run and froze first wins, so both processes send Stripe diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 43b769b..340132a 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -483,6 +483,16 @@ func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOve inv := found if found.Status == "draft" { + // An inert draft moved NO money — finalizing it is a fresh off-session + // debit, so the prepaid collection gate applies exactly as on a first + // attempt (wave 2, D6). Skip WITHOUT resolving; the draft stays inert + // and a relax back to arrears completes it through the same keys. + if permitted, err := s.offSessionChargePermitted(ctx, cand.AccountID); err != nil { + return false, err + } else if !permitted { + res.Status = ModuleOverageSkippedPrepaid + return true, nil + } // The crashed attempt never finalized. Complete ITS draft — never mint a // second one. The line either never landed (AmountDue 0 → attach it, with // the deterministic amount the crashed attempt would have used) or already diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 1bd17b8..6d7b8ae 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -261,14 +261,18 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return nil, billing.Internal("ChargeCreationProration requires a Stripe client", nil) } - // Gates — FIRST attempt only (the proration analogue of the boundary's H8 - // rule): once a prior attempt reached its Stripe section - // (proration_attempted_at set), money may already have moved, and this - // call's job is to RECONCILE — a prepaid tighten or a removed PM after the - // crash must not strand the charge unmirrored behind a transient skip. The - // attempted path resolves only the Customer id (an idem/recovery replay - // needs no fresh authorization). + // Gates + recovery resolution. Once a prior attempt reached its Stripe + // section (proration_attempted_at set), look its invoice up NOW by the + // ms_charge_ref anchor (once — the charge callback consumes the result): + // a FINALIZED invoice means money moved and this call's only job is to + // RECONCILE (gates bypassed — a prepaid tighten or removed PM after the + // crash must not strand the charge unmirrored); a VOID one is refused + // loudly (D10); an inert DRAFT or nothing at all moved NO money (wave 2, + // D6) — finalizing/minting is a fresh off-session debit and every gate + // applies, exactly as on a first attempt. var custID string + var recoveredInv *billingstripe.Invoice + moneyMayHaveMoved := false if app.ProrationAttempted { custID, err = s.store.AccountStripeCustomer(ctx, app.AccountID) if err != nil { @@ -277,11 +281,23 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if custID == "" { return nil, billing.Internal("app has an attempted proration charge but the account has no Stripe customer id", nil) } - } else { + if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(appID)); err != nil { + return nil, billing.StripeError("proration recovery lookup failed", err) + } else if ok { + if found.Status == "void" { + return nil, billing.Internal(fmt.Sprintf( + "proration recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (app %s needs ops resolution)", + found.ID, appProrationChargeRef(appID), appID), nil) + } + recoveredInv = &found + moneyMayHaveMoved = found.Status != "draft" + } + } + if !moneyMayHaveMoved { // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is // never auto-charged off-session by ANY leg. Transient skip (guard // unarmed), like no-PM — re-attempted once the account relaxes back to - // arrears. + // arrears. A recovered inert draft stays inert across the skip. if permitted, err := s.offSessionChargePermitted(ctx, app.AccountID); err != nil { return nil, err } else if !permitted { @@ -392,33 +408,22 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) expectedTotalCents += overageCents * int64(len(overTimers)) } - // CRASH RECOVERY (review 2026-07-06, H5): a set proration_attempted_at - // marker means a prior attempt reached its Stripe section — reconcile by - // the ms_charge_ref anchor before minting anything. Past Stripe's ~24h - // idempotency-key window a bare key replay would create a SECOND draft + - // items + charge. A finalized invoice found → the money moved; adopt it. - // An inert draft found → complete THAT draft below instead of creating one. + // CRASH RECOVERY (review 2026-07-06, H5): the outer gate section already + // looked the attempted charge up by its ms_charge_ref anchor (once — + // void refused there, gates re-applied when nothing/only-a-draft was + // found, D6). A finalized invoice → the money moved; adopt it. An inert + // draft → complete THAT draft below instead of creating one. Past + // Stripe's ~24h idempotency-key window a bare key replay would have + // created a SECOND draft + items + charge. var inv billingstripe.Invoice var draft billingstripe.Invoice var recoveredFinal bool - if locked.ProrationAttempted { - if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(locked.AppID)); err != nil { - return nil, billing.StripeError("proration recovery lookup failed", err) - } else if ok { - switch found.Status { - case "void": - // A voided invoice means the charge was CANCELED — adopting it - // would arm the one-shot guard over money that never moved. - // Fail loudly into the sweep's retried-error path for ops. - return nil, billing.Internal(fmt.Sprintf( - "proration recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (app %s needs ops resolution)", - found.ID, appProrationChargeRef(locked.AppID), locked.AppID), nil) - case "draft": - draft = found - default: - inv = found - recoveredFinal = true - } + if recoveredInv != nil { + if recoveredInv.Status == "draft" { + draft = *recoveredInv + } else { + inv = *recoveredInv + recoveredFinal = true } } From ba17d2f32e651c48d3f20f30a3d65bf0048d8d21 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:48:45 +0800 Subject: [PATCH 27/30] fix: reconcile derives target/deleted under the lock; deletion is one locked transaction (wave 2: D8/D9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D8: ReconcileModuleTimersToTarget took a caller-supplied target read OUTSIDE the advisory lock — a late RegisterApp retry whose mirror read predated a concurrent SyncAppModules commit reconciled to the stale count, LIFO-shrinking away genuinely-installed modules (never billed, never re-synthesized: count and timers permanently diverged). The target, owning account, and deleted state are now read from the roster row INSIDE the locked transaction; a stale caller cannot impose anything. D9: the deleted-app resurrection guard was a read-then-act race (checked outside the lock) and the delete path took no lock at all. Deletion + timer soft-removal now commit in ONE transaction under the SAME per-app advisory lock (MarkAppDeletedAndRemoveTimers), and a deleted row reconciles to ZERO — a late synthesis retry removes orphans instead of resurrecting timers, in every interleaving. Integration test extended: concurrent reconcile invariant, stale-target immunity, locked delete, post-delete reconcile-to-zero. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/apps.go | 66 +++++-------- .../cycle/migration033_integration_test.go | 22 ++++- internal/account/cycle/service_test.go | 27 +++-- internal/account/cycle/store.go | 99 +++++++++++++++---- 4 files changed, 144 insertions(+), 70 deletions(-) diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 98c4d0e..7ad0bea 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -163,19 +163,15 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg // Synthesize the app's per-module install timers (migration 033), all anchored // at the app's created_at (the read-back mirror's stable first-registration // value) — the RPC carries only an integer module_count, so K identical timer - // rows stand in for the K co-created module instances. The reconcile runs - // under a per-app advisory lock (H7 — a concurrent retry can never - // double-insert the deficit) and makes a fire-and-forget retry a no-op once - // the first registration already synthesized them. NEVER for a deleted app - // (review 2026-07-06, H4): a late retry — or a billing-backfill - // re-registration — that lands after the app was deleted must not resurrect - // live timers for it (deletion freezes module_count, so the deficit would - // look real and the phantom timers would charge forever). - if !app.Deleted { - if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AccountID, app.AppID, - app.ModuleCount, app.CreatedAt, moduleGraceExpiry(app.CreatedAt), s.nowFn().UTC()); err != nil { - return nil, billing.Internal("reconcile module overage timers failed", err) - } + // rows stand in for the K co-created module instances. The reconcile derives + // the target count, owning account, AND deleted state from the roster row + // INSIDE its per-app advisory-locked transaction (H7 + wave 2 D8/D9): a + // concurrent retry can never double-insert the deficit, a late retry whose + // mirror read is stale can never shrink to an outdated count, and a retry + // landing after deletion removes orphans instead of resurrecting timers. + if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AppID, + app.CreatedAt, moduleGraceExpiry(app.CreatedAt), s.nowFn().UTC()); err != nil { + return nil, billing.Internal("reconcile module overage timers failed", err) } return resp, nil } @@ -221,25 +217,17 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) now := s.nowFn().UTC() if req.Deleted { - if !app.Deleted { - if err := s.store.MarkAppDeleted(ctx, req.AppID); err != nil { - return nil, billing.Internal("mark app deleted failed", err) - } - app.Deleted = true - } - // Deletion soft-removes ALL the app's still-live install timers — they drop - // out of the FIFO and the Leg 1 sweep, so a module deleted with its app is - // never charged its overage (no refund of anything already charged, D1e). - // Re-fired on EVERY delete signal, not only the first (review 2026-07-06, - // H4): the two writes are not transactional, so a crash between them - // leaves the app deleted with live orphaned timers — and a retry gated on - // !app.Deleted would skip this block forever, leaving the orphans in the - // FIFO (demoting other apps' modules to "over") and chargeable. The - // soft-remove is idempotent (WHERE removed_at IS NULL), so the re-fire is - // free on the happy path and self-heals the crashed one. - if err := s.store.SoftRemoveAllModuleTimersForApp(ctx, req.AppID, now); err != nil { - return nil, billing.Internal("soft-remove app module timers failed", err) + // Deletion + timer soft-removal commit in ONE advisory-locked transaction + // (wave 2, D9): no crash window can leave the app deleted with live + // orphaned timers, no concurrent synthesis retry can interleave a + // resurrection, and a re-fired delete signal self-heals idempotently + // (first deletion instant kept; already-removed timers affected 0 times). + // A module deleted with its app is never charged its overage; no refund + // of anything already charged (D1e). + if err := s.store.MarkAppDeletedAndRemoveTimers(ctx, req.AppID, now); err != nil { + return nil, billing.Internal("mark app deleted + remove timers failed", err) } + app.Deleted = true } // Count update — no-op once deleted (frozen tier, D1e). req.Deleted in the @@ -249,14 +237,14 @@ func (s *Service) SyncAppModules(ctx context.Context, req SyncAppModulesRequest) return nil, billing.Internal("set app module count failed", err) } app.ModuleCount = *req.ModuleCount - // Reconcile the app's live install timers to the new count under the - // per-app advisory lock (H7): grow inserts (anchored at now — genuine new - // installs), shrink LIFO-removes the newest. Reconciling against the LIVE - // timer count (not the app's prior module_count) makes it idempotent - // across SyncAppModules retries and self-healing after a crash between - // the count write and the timer write. - if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AccountID, req.AppID, - *req.ModuleCount, now, moduleGraceExpiry(now), now); err != nil { + // Reconcile the app's live install timers under the per-app advisory + // lock (H7): grow inserts (anchored at now — genuine new installs), + // shrink LIFO-removes the newest. The target is read from the roster row + // INSIDE the locked transaction (wave 2, D8) — the count write above is + // already committed, so the reconcile sees it (or a NEWER one, which is + // equally correct), never a stale caller value. + if err := s.store.ReconcileModuleTimersToTarget(ctx, req.AppID, + now, moduleGraceExpiry(now), now); err != nil { return nil, billing.Internal("reconcile module overage timers failed", err) } } diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 41b12b1..45c186b 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -115,7 +115,7 @@ func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t errs := make(chan error, workers) for i := 0; i < workers; i++ { go func() { - errs <- store.ReconcileModuleTimersToTarget(ctx, acct, app, 7, created, created.AddDate(0, 0, 3), created) + errs <- store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), created) }() } for i := 0; i < workers; i++ { @@ -126,12 +126,26 @@ func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t require.NoError(t, err) require.Equal(t, 7, n, "concurrent reconciles must never double-insert the deficit") - // And a concurrent grow/shrink mix still converges to the LAST target - // applied — each reconcile is atomic, so no interleaving can overshoot. - require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, acct, app, 3, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-20T00:00:00Z"))) + // The target is the roster row's CURRENT count read under the lock (wave 2, + // D8): shrink the row, reconcile, and the set converges — a stale caller + // can no longer impose an outdated target. + require.NoError(t, store.SetAppModuleCount(ctx, app, 3)) + require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-20T00:00:00Z"))) n, err = store.LiveModuleTimerCountForApp(ctx, app) require.NoError(t, err) require.Equal(t, 3, n) + + // D9: a deleted row reconciles to ZERO — a late synthesis retry removes + // orphans instead of resurrecting timers, and the locked delete removes + // everything atomically to begin with. + require.NoError(t, store.MarkAppDeletedAndRemoveTimers(ctx, app, mustTime(t, "2026-06-21T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n) + require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-21T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n, "a deleted app's reconcile target is zero — no resurrection") } // Stage B: the row_number()-windowed reads backing scenario 3 (CoCreatedOverModuleTimers), diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 6a72838..3437e8b 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -726,27 +726,42 @@ func (f *fakeStore) SoftRemoveNewestModuleTimers(_ context.Context, appID uuid.U return nil } -func (f *fakeStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error { +func (f *fakeStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error { if f.errReconcileTimers != nil { return f.errReconcileTimers } - // Mirrors the pgx locked reconcile: count live, insert the deficit anchored - // at installedAt/graceExpiresAt, or LIFO-remove the surplus at removedAt. - // (Unit tests are single-threaded; the advisory-lock serialization itself is - // exercised by the integration test.) + // Mirrors the pgx locked reconcile (wave 2, D8/D9): the target, account, and + // deleted state come from the CURRENT roster row — never the caller; a + // deleted row reconciles to zero. (Unit tests are single-threaded; the + // advisory-lock serialization itself is exercised by the integration test.) + app, ok := f.apps[appID] + if !ok { + return nil + } + target := app.ModuleCount + if app.Deleted { + target = 0 + } live, err := f.LiveModuleTimerCountForApp(ctx, appID) if err != nil { return err } switch { case target > live: - return f.InsertModuleOverageTimers(ctx, accountID, appID, installedAt, graceExpiresAt, target-live) + return f.InsertModuleOverageTimers(ctx, app.AccountID, appID, installedAt, graceExpiresAt, target-live) case target < live: return f.SoftRemoveNewestModuleTimers(ctx, appID, live-target, removedAt) } return nil } +func (f *fakeStore) MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + if err := f.MarkAppDeleted(ctx, appID); err != nil { + return err + } + return f.SoftRemoveAllModuleTimersForApp(ctx, appID, removedAt) +} + func (f *fakeStore) SoftRemoveAllModuleTimersForApp(_ context.Context, appID uuid.UUID, removedAt time.Time) error { if f.errRemoveAllTimers != nil { return f.errRemoveAllTimers diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3cdb52e..82ec996 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -327,16 +327,28 @@ type Store interface { // creation-proration leg — first-write-wins, never cleared. MarkAppProrationAttempted(ctx context.Context, appID uuid.UUID, at time.Time) error - // ReconcileModuleTimersToTarget brings an app's live install-timer set to - // exactly target, ATOMICALLY under a per-app advisory transaction lock - // (review 2026-07-06, H7): a grow inserts the deficit anchored at + // ReconcileModuleTimersToTarget brings an app's live install-timer set into + // line with its CURRENT roster row, ATOMICALLY under a per-app advisory + // transaction lock (review 2026-07-06, H7; hardened in wave 2, D8/D9): the + // target count, owning account, and deleted state are all read from the + // apps row INSIDE the locked transaction — never caller-supplied — so a + // late fire-and-forget retry can neither shrink timers to a stale + // module_count (D8) nor resurrect timers for an app deleted after its + // mirror read (D9: a deleted row reconciles to zero, removing any live + // orphans instead of inserting). A grow inserts the deficit anchored at // installedAt/graceExpiresAt, a shrink LIFO-soft-removes the surplus at - // removedAt. The lock is what makes the count-then-write reconcile safe - // against the fire-and-forget-with-retry RPC environment — two concurrent - // RegisterApp/SyncAppModules executions for the same app used to both read - // the same live count and both insert the full deficit, minting phantom - // timers that were then wrongfully charged $3 each at every boundary. - ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error + // removedAt. The lock also serializes concurrent executions so two retries + // can never both insert the full deficit (phantom $3 timers). + ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error + + // MarkAppDeletedAndRemoveTimers soft-deletes the roster row AND soft-removes + // every still-live install timer in ONE transaction under the SAME per-app + // advisory lock the reconcile takes (wave 2, D9) — a crash can no longer + // separate the two writes, and a concurrent synthesis retry serializes + // behind (or ahead of, then gets corrected by) the deletion. Idempotent: + // re-fire keeps the first deletion instant and affects already-removed + // timers zero times. + MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error // ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install // timers whose grace window has elapsed as of `at`, on chargeable (activated) @@ -1435,12 +1447,20 @@ func (s *pgxStore) MarkAppProrationAttempted(ctx context.Context, appID uuid.UUI }) } -// ReconcileModuleTimersToTarget — count + write in ONE transaction serialized -// by a per-app advisory xact lock, so concurrent RegisterApp/SyncAppModules -// retries can never both observe the same live count and double-insert (H7). -// The lock key is derived from the app id; pg_advisory_xact_lock releases on -// commit/rollback automatically. -func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, appID uuid.UUID, target int, installedAt, graceExpiresAt, removedAt time.Time) error { +// lockModuleTimers takes the per-app advisory xact lock every timer-set writer +// serializes on. Released automatically on commit/rollback. +func lockModuleTimers(ctx context.Context, tx pgx.Tx, appID uuid.UUID) error { + _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "module-timers:"+appID.String()) + return err +} + +// ReconcileModuleTimersToTarget — roster read + count + write in ONE +// transaction serialized by the per-app advisory xact lock, so concurrent +// RegisterApp/SyncAppModules retries can never both observe the same live +// count and double-insert (H7), a stale caller can never impose an outdated +// target (D8 — the target is the row's CURRENT module_count, read under the +// lock), and a deleted row reconciles to zero instead of resurrecting (D9). +func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error { tx, err := s.pool.Begin(ctx) if err != nil { return err @@ -1448,29 +1468,40 @@ func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, defer deferredRollback(ctx, tx) qtx := s.q.WithTx(tx) - if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "module-timers:"+appID.String()); err != nil { + if err := lockModuleTimers(ctx, tx, appID); err != nil { + return err + } + row, err := qtx.SelectAppMirror(ctx, appID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return nil // no roster row — nothing to reconcile against + } + if err != nil { return err } + target := int64(row.ModuleCount) + if row.DeletedAt.Valid { + target = 0 // deleted apps hold no live timers — remove orphans, never insert + } live, err := qtx.LiveModuleTimerCountForApp(ctx, appID.String()) if err != nil { return err } switch { - case int64(target) > live: + case target > live: if err := qtx.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ - AccountID: accountID.String(), + AccountID: row.AccountID, AppID: appID.String(), InstalledAt: installedAt, GraceExpiresAt: graceExpiresAt, - Count: int32(int64(target) - live), //nolint:gosec // bounded by maxModuleCount (100000) + Count: int32(target - live), //nolint:gosec // bounded by maxModuleCount (100000) }); err != nil { return err } - case int64(target) < live: + case target < live: if err := qtx.SoftRemoveNewestModuleTimers(ctx, db.SoftRemoveNewestModuleTimersParams{ AppID: appID.String(), RemovedAt: removedAt, - LimitCount: int32(live - int64(target)), //nolint:gosec // bounded by maxModuleCount (100000) + LimitCount: int32(live - target), //nolint:gosec // bounded by maxModuleCount (100000) }); err != nil { return err } @@ -1478,6 +1509,32 @@ func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, accountID, return tx.Commit(ctx) } +// MarkAppDeletedAndRemoveTimers — the deletion write and the timer soft-remove +// in ONE transaction under the SAME advisory lock (wave 2, D9): no crash +// window between them, and no interleaving with a concurrent reconcile. +func (s *pgxStore) MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) + + if err := lockModuleTimers(ctx, tx, appID); err != nil { + return err + } + if _, err := qtx.MarkAppDeleted(ctx, appID.String()); err != nil { + return err + } + if err := qtx.SoftRemoveAllModuleTimersForApp(ctx, db.SoftRemoveAllModuleTimersForAppParams{ + AppID: appID.String(), + RemovedAt: pgtype.Timestamptz{Time: removedAt, Valid: true}, + }); err != nil { + return err + } + return tx.Commit(ctx) +} + func (s *pgxStore) SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error { if n <= 0 { return nil From 234524f11dc644018a8d2d50842189be336e28d2 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:53:42 +0800 Subject: [PATCH 28/30] fix: an app deleted AFTER its grace elapsed still pays the creation charge (wave 2: D11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every deleted-app skip on the creation-proration leg treated 'deleted' as 'never charged' — but the spec's free-delete window is the GRACE: an app deleted after the grace elapsed survived it and owes the creation charge (grace only delays WHEN it fires). Combined with the H2 boundary exclusion (no other leg backstops the straddled period), 'delete in the grace-elapse→daily-sweep window' had become a user-timable ~$22 dodge (creation proration + the straddled month + $3 per co-created over-module), repeatable per app. The work list, the cheap early-out, and the locked re-check now skip only deletions stamped BEFORE the grace expiry (grace in hours, D5); a post-grace deletion stays pending and charges normally. Test fixtures that logically deleted 'within grace' but were clocked past it now delete with an in-grace service clock. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/proration.go | 11 +++++--- internal/account/cycle/proration_test.go | 33 +++++++++++++++++++++--- internal/account/cycle/scenarios_test.go | 7 +++-- internal/account/cycle/service_test.go | 24 ++++++++++++----- internal/account/cycle/store.go | 13 ++++++++-- internal/account/db/apps.sql.go | 33 +++++++++++++++--------- internal/account/db/module_timers.sql.go | 27 ++++++++++++------- internal/account/db/queries/apps.sql | 24 ++++++++++------- 8 files changed, 124 insertions(+), 48 deletions(-) diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index 6d7b8ae..f361e2f 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -205,9 +205,14 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) if app.ProrationSkipped { return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil } - // Deleted within grace (or after) → never charged (D1e). The locked section - // re-checks this authoritatively; this is the cheap early-out. - if app.Deleted { + // Deleted WITHIN grace → never charged (scenario 1). Deleted AFTER the + // grace elapsed SURVIVED it and still owes the creation charge (wave 2, + // D11) — grace only delays WHEN the charge fires, and the H2 boundary + // exclusion leaves no other leg as a backstop, so skipping any deleted app + // was a user-timable ~$22 dodge in the grace-elapse→sweep window. The + // locked section re-checks this authoritatively; this is the cheap + // early-out. + if app.Deleted && app.DeletedAt.Before(moduleGraceExpiry(app.CreatedAt.UTC())) { return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil } diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index 0463b51..dd53e6c 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -486,15 +486,16 @@ func TestSweep_ChargesOnlyPastGraceLiveUnchargedApps(t *testing.T) { past := uuid.New() // past grace, live, uncharged → charged young := uuid.New() // within grace → skipped - gone := uuid.New() // past grace but deleted → skipped + gone := uuid.New() // deleted WITHIN grace → skipped (D11: only in-grace deletes are free) registerMirror(t, svc, user, past, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) registerMirror(t, svc, user, young, time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC), 0) registerMirror(t, svc, user, gone, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) - _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: gone, Deleted: true}) + svcDay1 := cycle.NewService(store, sc).WithNow(func() time.Time { return time.Date(2026, 6, 21, 8, 0, 0, 0, time.UTC) }) + _, err := svcDay1.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: gone, Deleted: true}) require.NoError(t, err) // Sweep as of Jun 30 → cutoff Jun 27: past (Jun 20) qualifies; young (Jun 29) - // is within grace; gone is deleted. + // is within grace; gone was deleted inside its grace. res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) require.Equal(t, 1, res.Pending) @@ -504,6 +505,32 @@ func TestSweep_ChargesOnlyPastGraceLiveUnchargedApps(t *testing.T) { require.Empty(t, store.apps[gone].ProrationInvoiceID) } +// Regression (wave 2, D11): an app deleted AFTER its grace elapsed SURVIVED +// the grace — the creation charge is owed (grace only delays WHEN it fires). +// Pre-fix any deleted app was skipped, which — combined with the H2 boundary +// exclusion — made "delete in the grace-elapse→sweep window" a user-timable +// ~$22 dodge (creation proration + the straddled month), repeatable per app. +func TestSweep_AppDeletedAfterGraceStillPaysCreationCharge(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created Jul 1 08:00 → grace ends Jul 4 08:00 (straddles the Jul 4 anchor + // boundary). Deleted Jul 4 12:00 — AFTER grace, BEFORE the daily sweep. + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + svcPostGrace := cycle.NewService(store, sc).WithNow(func() time.Time { return time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) }) + _, err := svcPostGrace.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, + "a post-grace delete survived the grace — the creation (+straddle) charge is owed, not dodgeable") + require.NotEmpty(t, store.apps[appID].ProrationInvoiceID) + require.Len(t, sc.finalizeCalls, 1) +} + // --- FINDING 1: the creation-proration charge must price off the module count // FROZEN at RegisterApp time, never whatever module_count SyncAppModules moves // it to during (or after) the grace window ---------------------------------- diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go index 5f366a8..735d114 100644 --- a/internal/account/cycle/scenarios_test.go +++ b/internal/account/cycle/scenarios_test.go @@ -56,8 +56,11 @@ func TestScenario1_CreatedThenDeletedInGraceNeverCharged(t *testing.T) { require.Empty(t, sc.itemCalls, "no charge at creation (scenario 1)") require.Equal(t, 3, liveTimerCount(store, appID)) - // Deleted WITHIN grace (day 1) → the app + all its install timers drop out. - _, err := svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + // Deleted WITHIN grace (day 1 — the delete must be clocked inside the + // grace window: D11 makes a post-grace delete chargeable) → the app + all + // its install timers drop out. + svcDay1 := cycle.NewService(store, sc).WithNow(func() time.Time { return scenarioCreatedAt.AddDate(0, 0, 1) }) + _, err := svcDay1.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) require.NoError(t, err) require.Equal(t, 0, liveTimerCount(store, appID), "delete soft-removes all timers") diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 3437e8b..1397f80 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -552,11 +552,13 @@ func (f *fakeStore) AppsPendingProration(_ context.Context, createdBefore time.T return nil, f.errPendingProration } // Mirrors the SQL: created_at <= cutoff AND proration_invoice_id IS NULL AND - // deleted_at IS NULL AND proration_skipped_at IS NULL. Sorted by created_at - // for a deterministic sweep order. + // (deleted_at IS NULL OR deleted after the grace elapsed — D11: a survivor + // still owes) AND proration_skipped_at IS NULL. Sorted by created_at for a + // deterministic sweep order. var out []uuid.UUID for id, app := range f.apps { - if app.ProrationInvoiceID == "" && !app.Deleted && !app.ProrationSkipped && !app.CreatedAt.After(createdBefore) { + deletedInGrace := app.Deleted && app.DeletedAt.Before(app.CreatedAt.UTC().AddDate(0, 0, 3)) + if app.ProrationInvoiceID == "" && !deletedInGrace && !app.ProrationSkipped && !app.CreatedAt.After(createdBefore) { out = append(out, id) } } @@ -579,8 +581,8 @@ func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, ch if !ok { return cycle.ProrationLockedNotFound, "", nil } - if app.Deleted { - return cycle.ProrationLockedDeleted, "", nil + if app.Deleted && app.DeletedAt.Before(app.CreatedAt.UTC().AddDate(0, 0, 3)) { + return cycle.ProrationLockedDeleted, "", nil // deleted WITHIN grace only (D11) } if app.ProrationInvoiceID != "" { return cycle.ProrationLockedAlreadyCharged, app.ProrationInvoiceID, nil @@ -756,8 +758,16 @@ func (f *fakeStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uui } func (f *fakeStore) MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { - if err := f.MarkAppDeleted(ctx, appID); err != nil { - return err + if f.errMarkDeleted != nil { + return f.errMarkDeleted + } + // DeletedAt = the service clock's removedAt (the real SQL uses now() in the + // same transaction) — NOT the test host's wall clock, which would misplace + // the deletion relative to the fixtures' grace windows (D11 pivots on it). + if app, ok := f.apps[appID]; ok && !app.Deleted { + app.Deleted = true + app.DeletedAt = removedAt + f.apps[appID] = app } return f.SoftRemoveAllModuleTimersForApp(ctx, appID, removedAt) } diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 82ec996..e74664d 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -1128,7 +1128,12 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b } func (s *pgxStore) AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) { - rows, err := s.q.AppsPendingProration(ctx, createdBefore) + rows, err := s.q.AppsPendingProration(ctx, db.AppsPendingProrationParams{ + CreatedBefore: createdBefore, + // hours, not days (D5): the SQL grace cutoff must match the Go legs' + // fixed 24h-per-day UTC window regardless of the session timezone. + GraceHours: usage.GraceDays * 24, + }) if err != nil { return nil, err } @@ -1172,7 +1177,11 @@ func (s *pgxStore) lockAndReadChargeableApp(ctx context.Context, appID uuid.UUID if err != nil { return AppMirror{}, 0, "", false, err } - if row.DeletedAt.Valid { + // Deleted WITHIN grace = never charged (scenario 1). Deleted AFTER the + // grace elapsed SURVIVED it (wave 2, D11) — the creation charge is owed + // (grace only delays WHEN it fires; the H2 boundary exclusion means no + // other leg has a backstop for this window), so the charge proceeds. + if row.DeletedAt.Valid && row.DeletedAt.Time.Before(moduleGraceExpiry(row.CreatedAt.UTC())) { return AppMirror{}, ProrationLockedDeleted, "", false, nil } if row.ProrationInvoiceID.Valid { diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 626cc45..8131a40 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -15,24 +15,33 @@ import ( const appsPendingProration = `-- name: AppsPendingProration :many SELECT app_id FROM ms_billing.apps -WHERE created_at <= $1 +WHERE created_at <= $1::timestamptz AND proration_invoice_id IS NULL - AND deleted_at IS NULL + AND (deleted_at IS NULL + OR deleted_at >= created_at + make_interval(hours => $2::int)) AND proration_skipped_at IS NULL ORDER BY created_at ` +type AppsPendingProrationParams struct { + CreatedBefore time.Time `json:"created_before"` + GraceHours int32 `json:"grace_hours"` +} + // AppsPendingProration is the creation-proration sweep's work list: apps that -// have survived the grace window ($1 = now() − GraceDays) and were never charged -// their creation-period base. proration_invoice_id IS NULL is the one-shot guard -// (an already-charged app drops out); deleted_at IS NULL excludes apps -// soft-deleted within grace (they are NEVER charged); proration_skipped_at IS -// NULL excludes apps permanently skipped as a would-be retroactive catch-up -// (migration 031, D1d). Ordered by created_at so the oldest pending app -// charges first. Backed by the partial index apps_pending_proration_idx -// (migrations 029/031). -func (q *Queries) AppsPendingProration(ctx context.Context, createdAt time.Time) ([]string, error) { - rows, err := q.db.Query(ctx, appsPendingProration, createdAt) +// have survived the grace window (@created_before = now() − GraceDays) and were +// never charged their creation-period base. proration_invoice_id IS NULL is the +// one-shot guard (an already-charged app drops out); the deleted_at predicate +// excludes ONLY apps soft-deleted WITHIN their grace (never charged, scenario +// 1) — an app deleted AFTER its grace elapsed SURVIVED it and still owes the +// creation charge (wave 2, D11: grace only delays WHEN the charge fires, and +// the H2 boundary exclusion leaves no other leg as a backstop; pre-fix this +// was a user-timable ~$22 dodge in the grace-elapse→sweep window). Grace in +// HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes +// apps permanently skipped as a would-be retroactive catch-up (migration 031, +// D1d). Ordered by created_at so the oldest pending app charges first. +func (q *Queries) AppsPendingProration(ctx context.Context, arg AppsPendingProrationParams) ([]string, error) { + rows, err := q.db.Query(ctx, appsPendingProration, arg.CreatedBefore, arg.GraceHours) if err != nil { return nil, err } diff --git a/internal/account/db/module_timers.sql.go b/internal/account/db/module_timers.sql.go index 0c331be..5e640c2 100644 --- a/internal/account/db/module_timers.sql.go +++ b/internal/account/db/module_timers.sql.go @@ -94,14 +94,13 @@ func (q *Queries) CountLiveModuleTimersForAccount(ctx context.Context, accountID const countOngoingOverModuleTimers = `-- name: CountOngoingOverModuleTimers :one SELECT COALESCE(count(*), 0)::bigint AS over_count FROM ( - SELECT installed_at, grace_expires_at, grace_resolved, + SELECT installed_at, grace_expires_at, row_number() OVER (ORDER BY installed_at, id) AS rn FROM ms_billing.app_module_overage_timers WHERE account_id = $1::uuid AND removed_at IS NULL ) ranked WHERE rn > $2::int - AND grace_resolved = true AND installed_at < $3::timestamptz AND grace_expires_at < $3::timestamptz ` @@ -121,8 +120,8 @@ type CountOngoingOverModuleTimersParams struct { // predicate. "over" is re-derived LIVE, so a charged timer that has since flipped // to "included" (an earlier install removed) is not counted. // -// The coverage contract with the grace legs (review 2026-07-06) — a timer is -// "ongoing" for the new period iff ALL of: +// The coverage contract with the grace legs (review 2026-07-06, tightened in +// wave 2 D1) — a timer is "ongoing" for the new period iff BOTH of: // - installed_at < @period_end — it existed before the new period opened. A // module installed INSIDE the new period had that period covered by its OWN // grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base @@ -133,11 +132,21 @@ type CountOngoingOverModuleTimersParams struct { // elapses into, so a boundary-straddling timer's new period belongs to Leg 1, // not this precharge (counting it would double-bill; skipping the NEXT // boundary would leave a gap — this predicate does neither). -// - grace_resolved — its grace verdict is terminal. Resolved-WITHOUT-charge -// rows (the D1d period-closed posture: installed pre-activation, so the -// install period itself is forgiven) still owe every period from the first -// post-activation boundary onward; the previous grace_charged_at IS NOT NULL -// proxy silently exempted them from ALL overage billing, forever. +// +// DELIBERATELY NOT a condition: grace_resolved (wave 2, D1). Resolution state +// is MUTABLE and set only by the sweeps, so keying on it made the precharge +// depend on cron ordering: a timer whose grace expired in the ~24h before the +// boundary was still unresolved when the boundary run executed, got excluded, +// and its post-boundary period was then billed by NO leg (Leg 1's coverage is +// derived from immutable timestamps and stops at the boundary). Both cutoffs +// above are immutable, so the precharge decision is identical whenever the run +// (or its reclaim) executes. An expired-unresolved timer counted here is +// charged its own install-period coverage by Leg 1 later — disjoint windows, +// never a double-bill. D1d resolved-uncharged rows count too (only the +// pre-activation install period is forgiven). Residual edge (accepted, +// verdict-at-boundary-time semantics): a timer whose live rank improves +// over→included between this run and its own sweep keeps the one precharge — +// no refund (D1e); the next boundary excludes it by rank. func (q *Queries) CountOngoingOverModuleTimers(ctx context.Context, arg CountOngoingOverModuleTimersParams) (int64, error) { row := q.db.QueryRow(ctx, countOngoingOverModuleTimers, arg.AccountID, arg.IncludedModules, arg.PeriodEnd) var over_count int64 diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index 8f6a1c6..f0f5a3b 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -43,20 +43,24 @@ WHERE app_id = $1 FOR UPDATE; -- AppsPendingProration is the creation-proration sweep's work list: apps that --- have survived the grace window ($1 = now() − GraceDays) and were never charged --- their creation-period base. proration_invoice_id IS NULL is the one-shot guard --- (an already-charged app drops out); deleted_at IS NULL excludes apps --- soft-deleted within grace (they are NEVER charged); proration_skipped_at IS --- NULL excludes apps permanently skipped as a would-be retroactive catch-up --- (migration 031, D1d). Ordered by created_at so the oldest pending app --- charges first. Backed by the partial index apps_pending_proration_idx --- (migrations 029/031). +-- have survived the grace window (@created_before = now() − GraceDays) and were +-- never charged their creation-period base. proration_invoice_id IS NULL is the +-- one-shot guard (an already-charged app drops out); the deleted_at predicate +-- excludes ONLY apps soft-deleted WITHIN their grace (never charged, scenario +-- 1) — an app deleted AFTER its grace elapsed SURVIVED it and still owes the +-- creation charge (wave 2, D11: grace only delays WHEN the charge fires, and +-- the H2 boundary exclusion leaves no other leg as a backstop; pre-fix this +-- was a user-timable ~$22 dodge in the grace-elapse→sweep window). Grace in +-- HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes +-- apps permanently skipped as a would-be retroactive catch-up (migration 031, +-- D1d). Ordered by created_at so the oldest pending app charges first. -- name: AppsPendingProration :many SELECT app_id FROM ms_billing.apps -WHERE created_at <= $1 +WHERE created_at <= @created_before::timestamptz AND proration_invoice_id IS NULL - AND deleted_at IS NULL + AND (deleted_at IS NULL + OR deleted_at >= created_at + make_interval(hours => @grace_hours::int)) AND proration_skipped_at IS NULL ORDER BY created_at; From e500d7580d464b1f43def5cd0bfc2a64a20ffced Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 12:54:48 +0800 Subject: [PATCH 29/30] test: key the fake FindInvoiceByRef by ref (wave 2 critic) The unkeyed fake returned its seeded invoice for ANY ref, so the recovery tests could not detect a leg reconciling against another leg's charge identity. Seeds now register under an exact ref (setFindByRef); a wrong-ref lookup misses. Co-Authored-By: Claude Fable 5 --- internal/account/cycle/charge_test.go | 29 ++++++++++++++++-------- internal/account/cycle/overage_test.go | 4 ++-- internal/account/cycle/proration_test.go | 6 ++--- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 6aaf919..427cdd7 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -40,9 +40,11 @@ type fakeStripe struct { errInvoice error // injected on FinalizeInvoice — the money-moving step errFindByRef error - // crash-recovery lookup (FindInvoiceByRef): the invoice "found" on Stripe - // under any ref, and the refs queried. - findByRef *billingstripe.Invoice + // crash-recovery lookup (FindInvoiceByRef): invoices "found" on Stripe + // KEYED BY REF (wave 2 critic finding — an unkeyed fake could not detect a + // cross-leg charge-ref mixup), and the refs queried. Tests seed via + // setFindByRef. + findByRefByRef map[string]billingstripe.Invoice findByRefCalls []string // onCreateInvoice, when set, runs INSIDE FinalizeInvoice right before it // returns success — modeling a concurrent account mutation (e.g. a @@ -97,16 +99,23 @@ func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID, invoiceID stri return billingstripe.InvoiceItem{ID: "ii_test_" + uuid.NewString()}, nil } -// findByRef, when set, is what FindInvoiceByRef returns — models the crash- -// recovery lookup finding a crashed attempt's invoice on Stripe. nil = not -// found (the default: nothing ever reached Stripe under the ref). +// setFindByRef seeds the invoice the recovery lookup finds under EXACTLY the +// given ref — a lookup with any other ref misses, so a leg reconciling against +// another leg's charge identity fails its test. +func (f *fakeStripe) setFindByRef(ref string, inv billingstripe.Invoice) { + if f.findByRefByRef == nil { + f.findByRefByRef = map[string]billingstripe.Invoice{} + } + f.findByRefByRef[ref] = inv +} + func (f *fakeStripe) FindInvoiceByRef(_ context.Context, _, ref string) (billingstripe.Invoice, bool, error) { f.findByRefCalls = append(f.findByRefCalls, ref) if f.errFindByRef != nil { return billingstripe.Invoice{}, false, f.errFindByRef } - if f.findByRef != nil { - return *f.findByRef, true, nil + if inv, ok := f.findByRefByRef[ref]; ok { + return inv, true, nil } return billingstripe.Invoice{}, false, nil } @@ -330,7 +339,7 @@ func TestRunBillingCycle_FrozenRunNotSkippedByPrepaidOrPMGates(t *testing.T) { store.collection.Mode = cycle.BillingModePrepaid store.hasPM = false store.errMarkRun = nil - sc.findByRef = &billingstripe.Invoice{ID: sc.invoiceID, Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"} + sc.setFindByRef(sc.invoiceCalls[0].ref, billingstripe.Invoice{ID: sc.invoiceID, Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"}) // RETRY: pre-fix → skipped_prepaid (or skipped_no_pm), stranding the moved // money unmirrored. Fixed: gates never apply over an EXISTING charge; the @@ -425,7 +434,7 @@ func TestRunBillingCycle_LateReclaimAdoptsFoundInvoiceWithoutNewObjects(t *testi // The reclaim lands DAYS later — keys pruned — but the crashed attempt's // invoice is findable under run:. - sc.findByRef = &billingstripe.Invoice{ID: "in_prior_boundary", Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"} + sc.setFindByRef(sc.invoiceCalls[0].ref, billingstripe.Invoice{ID: "in_prior_boundary", Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"}) store.errMarkRun = nil resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go index b4aab57..5bc9fd5 100644 --- a/internal/account/cycle/overage_test.go +++ b/internal/account/cycle/overage_test.go @@ -323,7 +323,7 @@ func TestModuleOverage_RetryAfterCrashRecoversChargeEvenWhenRankFlipped(t *testi // Attempt 1 reached the Stripe section (marker set), charged, and crashed // before the mark. Its finalized invoice sits on Stripe under the ref. store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) - sc.findByRef = &billingstripe.Invoice{ID: "in_crashed", Status: "paid", AmountDue: 240, AmountPaid: 240, Currency: "usd"} + sc.setFindByRef("timer:"+x.String(), billingstripe.Invoice{ID: "in_crashed", Status: "paid", AmountDue: 240, AmountPaid: 240, Currency: "usd"}) // Between crash and retry an EARLIER module is removed — x's live rank // improves to 4 ("included"). @@ -362,7 +362,7 @@ func TestModuleOverage_RetryCompletesCrashedDraftInsteadOfMintingSecond(t *testi seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) - sc.findByRef = &billingstripe.Invoice{ID: "in_orphan_draft", Status: "draft", AmountDue: 0, Currency: "usd"} + sc.setFindByRef("timer:"+x.String(), billingstripe.Invoice{ID: "in_orphan_draft", Status: "draft", AmountDue: 0, Currency: "usd"}) res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) require.NoError(t, err) diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index dd53e6c..e76e817 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -95,7 +95,7 @@ func TestChargeCreationProration_RecoveredDraftAcceptsShrunkTimerSet(t *testing. app := store.apps[appID] app.ProrationAttempted = true store.apps[appID] = app - sc.findByRef = &billingstripe.Invoice{ID: "in_shrunk_draft", Status: "draft", AmountDue: 1300, Currency: "usd"} + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_shrunk_draft", Status: "draft", AmountDue: 1300, Currency: "usd"}) // Between crash and retry one co-created over-module is uninstalled — the // live set shrinks to 1. @@ -127,7 +127,7 @@ func TestChargeCreationProration_RecoveredDraftBelowLiveSetRefused(t *testing.T) app.ProrationAttempted = true store.apps[appID] = app // Base + only 1 of the 2 live over-lines (1000 + 150 = 1150¢): incomplete. - sc.findByRef = &billingstripe.Invoice{ID: "in_partial_draft", Status: "draft", AmountDue: 1150, Currency: "usd"} + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_partial_draft", Status: "draft", AmountDue: 1150, Currency: "usd"}) _, err := svc.ChargeCreationProration(context.Background(), appID) require.Error(t, err, "an incomplete draft is refused for ops, never silently completed or finalized") @@ -197,7 +197,7 @@ func TestChargeCreationProration_LateRetryAdoptsFoundInvoice(t *testing.T) { app := store.apps[appID] app.ProrationAttempted = true store.apps[appID] = app - sc.findByRef = &billingstripe.Invoice{ID: "in_prior_combined", Status: "paid", AmountDue: 1000, AmountPaid: 1000, Currency: "usd"} + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_prior_combined", Status: "paid", AmountDue: 1000, AmountPaid: 1000, Currency: "usd"}) resp, err := svc.ChargeCreationProration(context.Background(), appID) require.NoError(t, err) From 78016fa0ea02b06ef7e9e79592621aeb43bf2981 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 13:00:30 +0800 Subject: [PATCH 30/30] test: align integration fixtures with wave-2 semantics (D1 ordering-independent count, D11 grace-relative deletes) Co-Authored-By: Claude Fable 5 --- .../cycle/migration029_integration_test.go | 41 ++++++++++++------- .../cycle/migration033_integration_test.go | 34 ++++++--------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/internal/account/cycle/migration029_integration_test.go b/internal/account/cycle/migration029_integration_test.go index 119188b..5a77452 100644 --- a/internal/account/cycle/migration029_integration_test.go +++ b/internal/account/cycle/migration029_integration_test.go @@ -5,6 +5,7 @@ package cycle_test import ( "context" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -25,22 +26,30 @@ func TestAppsPendingProration_Integration_Filter(t *testing.T) { ctx := context.Background() acct := seedAccount(t, pool) - cutoff := mustTime(t, "2026-06-27T00:00:00Z") // now − GraceDays for a sweep on Jun 30 - - pending := uuid.New() // created before cutoff, live, unarmed → returned - young := uuid.New() // created after cutoff (within grace) → excluded - deleted := uuid.New() // before cutoff but soft-deleted → excluded - charged := uuid.New() // before cutoff but guard armed → excluded - require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) - require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, mustTime(t, "2026-06-29T08:00:00Z"))) - require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) - require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, mustTime(t, "2026-06-20T08:00:00Z"))) - require.NoError(t, store.MarkAppDeleted(ctx, deleted)) + // MarkAppDeleted stamps the DB's now(), so the deleted fixtures' created_at + // must be RELATIVE to the real clock for the D11 within-/after-grace split + // to be what each case intends. + now := time.Now().UTC() + cutoff := now.Add(time.Hour) // a sweep cutoff admitting everything created up to now + + pending := uuid.New() // past cutoff, live, unarmed → returned + young := uuid.New() // created after cutoff → excluded + 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.MarkAppDeleted(ctx, deletedIn)) + require.NoError(t, store.MarkAppDeleted(ctx, deletedAfter)) require.NoError(t, store.SetAppProrationInvoice(ctx, charged, "in_already")) ids, err := store.AppsPendingProration(ctx, cutoff) require.NoError(t, err) - require.Equal(t, []uuid.UUID{pending}, ids, "only the past-grace, live, unarmed app is pending") + require.Equal(t, []uuid.UUID{pending, deletedAfter}, ids, + "past-grace live unarmed apps are pending — including one deleted AFTER its grace (D11); a within-grace delete stays excluded") } func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { @@ -99,10 +108,12 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { require.Equal(t, cycle.ProrationLockedAlreadyCharged, outcome) require.Equal(t, "in_live", invID) - // Deleted app → Deleted, callback NOT invoked (a within-grace delete is never - // charged, and a delete that won the race no-ops the charge). + // App deleted WITHIN its grace → Deleted, callback NOT invoked (never + // charged; a delete that won the race no-ops the charge). created_at is + // 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, mustTime(t, "2026-07-01T08:00:00Z"))) + 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) { diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index 45c186b..96943e2 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -217,8 +217,10 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.NoError(t, err) require.Zero(t, ongoing, "a module installed inside the new period is never precharged for it") - // appB adds one LATER over-module (rank 7) still in its own grace (unresolved): - // live count rises to 8, but the ongoing count stays 2 (unresolved excluded). + // appB adds one LATER over-module (rank 7) whose grace ELAPSED before the + // Jul 4 boundary (installed Jun 28, expires Jul 1): under the wave-2 D1 + // predicate (immutable cutoffs only) it is ongoing even while UNRESOLVED — + // resolution state is deliberately not part of the count. late := mustTime(t, "2026-06-28T00:00:00Z") require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, late, late.AddDate(0, 0, 3), 1)) liveN, err = usageStore.LiveModuleTimerCountForAccount(ctx, acct) @@ -226,12 +228,12 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.Equal(t, 8, liveN) ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) - require.Equal(t, 2, ongoing, "the in-grace (unresolved) over-module is NOT ongoing") + require.Equal(t, 3, ongoing, "an expired-but-unresolved over-module IS ongoing (D1 — the count never depends on sweep ordering)") // D1d resolved-WITHOUT-charge (review 2026-07-06, C1): resolving the rank-7 - // timer uncharged (the period-closed posture) must still make it "ongoing" - // from the next boundary on — the old grace_charged_at IS NOT NULL proxy - // exempted such modules from ALL overage billing forever. + // timer uncharged (the period-closed posture) keeps it "ongoing" — the old + // grace_charged_at IS NOT NULL proxy exempted such modules from ALL overage + // billing forever. lateCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-02T00:00:00Z")) require.NoError(t, err) var rank7 cycle.ModuleOverageCandidate @@ -246,23 +248,13 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { require.NoError(t, err) require.Equal(t, 3, ongoing, "a resolved-uncharged (D1d) over-module still owes every later period") - // grace_expires_at < period_end cutoff (review 2026-07-06, M1): a charged - // over-module whose grace STRADDLES the boundary (installed Jul 2, expiry - // Jul 5 >= Jul 4) is excluded — its own Leg 1 charge covers the new period + // grace_expires_at < period_end cutoff (review 2026-07-06, M1): an + // over-module whose grace STRADDLES the boundary (installed Jul 3, expiry + // Jul 6 >= Jul 4) is excluded — its own Leg 1 charge covers the new period // (coverage runs through the END of the period its grace elapses into). - straddle := mustTime(t, "2026-07-02T00:00:00Z") + straddle := mustTime(t, "2026-07-03T00:00:00Z") require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, straddle, straddle.AddDate(0, 0, 3), 1)) - straddleCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-06T00:00:00Z")) - require.NoError(t, err) - var straddleTimer cycle.ModuleOverageCandidate - for _, c := range straddleCands { - if c.InstalledAt.Equal(straddle) { - straddleTimer = c - } - } - require.NotEqual(t, uuid.Nil, straddleTimer.ID, "the Jul-2 straddle timer is in the late work list") - require.NoError(t, store.MarkModuleTimerCharged(ctx, straddleTimer.ID, mustTime(t, "2026-07-05T00:00:00Z"), "in_straddle", "ii_straddle")) ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) require.NoError(t, err) - require.Equal(t, 3, ongoing, "a boundary-straddling grace is Leg 1's coverage, never the precharge's") + require.Equal(t, 3, ongoing, "a boundary-straddling grace is Leg 1's coverage, never the precharge's — resolved or not") }