diff --git a/internal/account/collection/collection.go b/internal/account/collection/collection.go index ddaaad9..efcb30c 100644 --- a/internal/account/collection/collection.go +++ b/internal/account/collection/collection.go @@ -204,6 +204,83 @@ 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. +// +// 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 { + 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 // 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..7a45e9b 100644 --- a/internal/account/collection/collection_test.go +++ b/internal/account/collection/collection_test.go @@ -200,3 +200,92 @@ 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 + // 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(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") +} diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index 6952799..89290e3 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,36 @@ 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) — 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) + } + // 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..4226907 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -289,15 +289,38 @@ 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. + // + // 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, - 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, 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 5bdf808..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, @@ -470,3 +482,164 @@ 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") +} + +// 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") +} 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.';