From b0421ede6471baf5ca4838072d655c4eebbd0318 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 05:57:11 +0800 Subject: [PATCH 1/3] 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 2c81bbc8d94908adeab02eda295b255ca456a29e Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Sun, 5 Jul 2026 06:54:14 +0800 Subject: [PATCH 2/3] 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 3/3] 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") +}