Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b0421ed
feat: disclose large auto-collected charges (migration 031)
I-am-nothing Jul 4, 2026
041e034
fix: 3-day creation grace before charging an app's base fee
I-am-nothing Jul 4, 2026
46e6684
feat: account-wide pooled module overage (migration 030)
I-am-nothing Jul 4, 2026
2c81bbc
fix: compare post-rounding cents, not raw micros, in IsLargeAutoCollect
I-am-nothing Jul 4, 2026
8c103f1
fix: resolve auto-collect threshold at the same point in both charge …
I-am-nothing Jul 4, 2026
ee5043c
fix: account-overage cross-leg double-charge + retry livelock (review…
I-am-nothing Jul 4, 2026
8ebde0d
fix: creation-grace retroactive-billing + lock-hold review findings
I-am-nothing Jul 4, 2026
78fc858
Merge account-wide overage (#47) into creation-grace (#46); renumber …
I-am-nothing Jul 5, 2026
d3e4b3b
Merge auto-collect disclosure (#45) into unified base-fee/overage; re…
I-am-nothing Jul 5, 2026
64eebfa
feat: per-module-instance overage timers + Leg 1 grace charge (migrat…
I-am-nothing Jul 5, 2026
8ae0798
feat: Stage B — boundary overage (Leg 2), combined creation invoice (…
I-am-nothing Jul 5, 2026
11472c9
fix: three per-module overage rebuild review findings (D1d catch-up, …
I-am-nothing Jul 5, 2026
ff0abd3
refactor: dedupe PM-gate/customer resolution and D1d period-closed ch…
I-am-nothing Jul 5, 2026
889fc93
fix: persist IsLargeAutoCollect on the combined creation invoice
I-am-nothing Jul 6, 2026
966e03a
fix: rewrite the Leg-2 ongoing-over-module predicate (double-charge +…
I-am-nothing Jul 6, 2026
a487747
fix: Leg 1 covers the straddled period when a module's grace crosses …
I-am-nothing Jul 6, 2026
3e6f26c
fix: apps still in creation grace at a boundary are excluded from the…
I-am-nothing Jul 6, 2026
0570a9f
fix: pin invoice items to their own draft invoice (kill cross-leg pen…
I-am-nothing Jul 6, 2026
6dff73d
fix: reconcile the frozen boundary charge before every gate; atomic f…
I-am-nothing Jul 6, 2026
c22a197
fix: timer lifecycle — deleted-app guard, delete-retry self-heal, loc…
I-am-nothing Jul 6, 2026
3f05525
fix: prepaid accounts are never auto-charged by the creation or modul…
I-am-nothing Jul 6, 2026
405f2ae
fix: crash recovery beyond Stripe's idempotency-key window + charge-t…
I-am-nothing Jul 6, 2026
5272214
test: select integration candidates by app/install instead of asserti…
I-am-nothing Jul 6, 2026
3e7879d
fix: DST-safe grace cutoff, void-invoice refusal, guarded zero-skip (…
I-am-nothing Jul 6, 2026
290d190
fix: boundary precharge keys on immutable cutoffs only, never on grac…
I-am-nothing Jul 6, 2026
79d6ae1
fix: D1d forgives only pre-activation coverage — the straddled post-a…
I-am-nothing Jul 6, 2026
a9ccfd1
fix: recovered combined draft accepts the crashed attempt's larger li…
I-am-nothing Jul 6, 2026
b14bdf2
fix: recovery bypasses gates only when the charge actually EXISTS on …
I-am-nothing Jul 6, 2026
ba17d2f
fix: reconcile derives target/deleted under the lock; deletion is one…
I-am-nothing Jul 6, 2026
234524f
fix: an app deleted AFTER its grace elapsed still pays the creation c…
I-am-nothing Jul 6, 2026
e500d75
test: key the fake FindInvoiceByRef by ref (wave 2 critic)
I-am-nothing Jul 6, 2026
78016fa
test: align integration fixtures with wave-2 semantics (D1 ordering-i…
I-am-nothing Jul 6, 2026
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
3 changes: 3 additions & 0 deletions cmd/account-api/infra_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ func (stubUsageStore) AppIDsWithUsage(context.Context, uuid.UUID, time.Time, tim
func (stubUsageStore) MirroredAppIDs(context.Context, uuid.UUID, time.Time, time.Time) ([]uuid.UUID, error) {
return nil, nil
}
func (stubUsageStore) LiveModuleTimerCountForAccount(context.Context, uuid.UUID) (int, error) {
return 0, nil
}

func newRouterForTest(t *testing.T) http.Handler {
t.Helper()
Expand Down
78 changes: 71 additions & 7 deletions cmd/billing-cycle/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,47 @@ func main() {
}

// Local one-shot run: close every card-bound account's just-ended anchored
// period as of now, then exit. No HTTP listener — the dev cycle is a single
// batch invocation.
res := runCycle(context.Background(), svc, time.Now().UTC())
// period as of now, sweep the creation-proration grace queue (scenario 3 —
// combined creation invoice), then sweep the per-module overage grace queue
// (Leg 1), then exit. No HTTP listener — the dev cycle is a single batch.
at := time.Now().UTC()
res := runCycle(context.Background(), svc, at)
// Proration BEFORE the overage sweep: the creation-proration charge folds an
// app's co-created over-modules onto ONE combined invoice (scenario 3), then
// the overage sweep (Leg 1) finds them already resolved and skips them — so a
// co-created over-module is billed on the combined invoice, not a second one.
sweepFailed := runProrationSweep(context.Background(), svc, at)
runOverageSweep(context.Background(), svc, at, &res)
slog.Info("billing-cycle local run complete",
"as_of", res.AsOf,
"activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged,
"skipped_no_pm", res.SkippedNoPM, "zero_arrears", res.ZeroArrears,
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed)
if res.Failed > 0 {
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed,
"overage_candidates", res.OverageCandidates, "overage_charged", res.OverageCharged,
"overage_skipped", res.OverageSkipped, "overage_failed", res.OverageFailed)
if res.Failed > 0 || sweepFailed {
os.Exit(1)
}
}

// runProrationSweep charges the creation-period base for every app that has
// survived the grace window as of `at` (the second leg of the cycle job,
// alongside the per-account boundary loop). Reports whether any per-app charge
// failed so the caller can set a non-zero exit code; a failure is retried on the
// next sweep and never aborts the batch. Logged here so both transports share
// the shape.
func runProrationSweep(ctx context.Context, svc *cycle.Service, at time.Time) bool {
sweep, err := svc.SweepCreationProrations(ctx, at)
if err != nil {
slog.ErrorContext(ctx, "creation-proration sweep failed", "as_of", at, "error", err)
return true
}
slog.InfoContext(ctx, "creation-proration sweep complete",
"as_of", at, "pending", sweep.Pending, "charged", sweep.Charged,
"skipped", sweep.Skipped, "failed", sweep.Failed)
return sweep.Failed > 0
}

// buildService wires the pgxpool + Stripe client into the cycle Service. The
// Stripe secret is required (the charge leg cannot run without it).
func buildService() *cycle.Service {
Expand All @@ -103,12 +131,20 @@ func handler(svc *cycle.Service) func(context.Context, events.CloudWatchEvent) e
at = time.Now().UTC()
}
res := runCycle(ctx, svc, at.UTC())
// Proration BEFORE the overage sweep (see main()): the combined creation
// invoice (scenario 3) resolves an app's co-created over-modules first, so
// the overage sweep (Leg 1) skips them — one combined invoice, not two.
runProrationSweep(ctx, svc, at.UTC())
runOverageSweep(ctx, svc, at.UTC(), &res)
slog.InfoContext(ctx, "billing-cycle lambda run complete",
"as_of", res.AsOf,
"activated", res.Activated, "rolled_up", res.RolledUp, "processed", res.Processed, "charged", res.Charged,
"skipped_no_pm", res.SkippedNoPM, "zero_arrears", res.ZeroArrears,
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed)
// A per-account charge failure is recorded (billing_runs status='failed')
"already_run", res.AlreadyRun, "failed_runs", res.FailedRuns, "failed", res.Failed,
"overage_candidates", res.OverageCandidates, "overage_charged", res.OverageCharged,
"overage_skipped", res.OverageSkipped, "overage_failed", res.OverageFailed)
// A per-account charge failure (or a per-app proration failure) is recorded
// (billing_runs status='failed')
// and does NOT fail the batch — the next cycle retries it. The handler
// returns nil so EventBridge doesn't replay the whole batch.
return nil
Expand All @@ -127,6 +163,12 @@ type cycleResult struct {
AlreadyRun int
FailedRuns int // per-account charge runs that ended status='failed'
Failed int // errors (rollup error, charge error, or list error)

// Mid-period per-module overage grace sweep (migration 033, Leg 1).
OverageCandidates int // install timers past their grace window this sweep evaluated
OverageCharged int // "over" installs whose overage was invoiced mid-period
OverageSkipped int // evaluated but not charged (resolved-included / no PM / 0 cents)
OverageFailed int // per-timer overage-charge errors (counted, never abort)
}

// runCycle closes every card-bound account's just-ended ANCHORED period as of
Expand Down Expand Up @@ -225,6 +267,28 @@ func runCycle(ctx context.Context, svc *cycle.Service, at time.Time) cycleResult
return res
}

// runOverageSweep runs Leg 1's per-module overage grace sweep as of `at`. A
// single timer's error is logged + counted inside the sweep but never aborts the
// batch (the next sweep retries it through the same deterministic Stripe keys).
func runOverageSweep(ctx context.Context, svc *cycle.Service, at time.Time, res *cycleResult) {
sweep, err := svc.SweepModuleOverage(ctx, at)
if err != nil {
slog.ErrorContext(ctx, "module overage sweep failed", "as_of", at, "error", err)
res.Failed++
return
}
res.OverageCandidates = sweep.Pending
res.OverageCharged = sweep.Charged
// "Skipped" folds the resolved-as-included verdicts and the transient no-PM /
// zero-cent skips — everything evaluated but not charged this sweep.
res.OverageSkipped = sweep.Included + sweep.Skipped
res.OverageFailed = sweep.Failed
res.Failed += sweep.Failed
slog.InfoContext(ctx, "module overage sweep complete",
"as_of", at, "pending", sweep.Pending, "charged", sweep.Charged,
"included", sweep.Included, "skipped", sweep.Skipped, "failed", sweep.Failed)
}

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

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

Expand Down
17 changes: 13 additions & 4 deletions internal/account/billing/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,25 @@ func (f *fakeStripe) SetDefaultPaymentMethod(_ context.Context, stripeCustomerID
return nil
}

// CreateInvoiceItem / CreateInvoice are the PR #6 charge methods. The billing
// CreateDraftInvoice / CreateInvoiceItem / FinalizeInvoice are the charge
// methods (PR #6, draft→pinned-items→finalize since the C2 fix). The billing
// package never calls them (the charge cycle lives in internal/account/cycle),
// so these are panic stubs present only to keep this fake satisfying the
// widened billingstripe.Client interface.
func (f *fakeStripe) CreateInvoiceItem(context.Context, string, int64, string, string, string) (billingstripe.InvoiceItem, error) {
func (f *fakeStripe) CreateDraftInvoice(context.Context, string, string, string) (billingstripe.Invoice, error) {
panic("CreateDraftInvoice must not be called by the billing package")
}

func (f *fakeStripe) CreateInvoiceItem(context.Context, string, string, int64, string, string, string) (billingstripe.InvoiceItem, error) {
panic("CreateInvoiceItem must not be called by the billing package")
}

func (f *fakeStripe) CreateInvoice(context.Context, string, bool, string) (billingstripe.Invoice, error) {
panic("CreateInvoice must not be called by the billing package")
func (f *fakeStripe) FinalizeInvoice(context.Context, string, string) (billingstripe.Invoice, error) {
panic("FinalizeInvoice must not be called by the billing package")
}

func (f *fakeStripe) FindInvoiceByRef(context.Context, string, string) (billingstripe.Invoice, bool, error) {
panic("FindInvoiceByRef must not be called by the billing package")
}

// --- tests ----------------------------------------------------------------
Expand Down
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 034) 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")
}
Loading
Loading