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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions internal/account/collection/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
89 changes: 89 additions & 0 deletions internal/account/collection/collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
34 changes: 26 additions & 8 deletions internal/account/cycle/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand Down
39 changes: 31 additions & 8 deletions internal/account/cycle/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading