diff --git a/cmd/account-api/infra_route_test.go b/cmd/account-api/infra_route_test.go index f25aa33..180d8d0 100644 --- a/cmd/account-api/infra_route_test.go +++ b/cmd/account-api/infra_route_test.go @@ -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() diff --git a/cmd/billing-cycle/main.go b/cmd/billing-cycle/main.go index fc70e75..7591d04 100644 --- a/cmd/billing-cycle/main.go +++ b/cmd/billing-cycle/main.go @@ -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 { @@ -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 @@ -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 @@ -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 diff --git a/cmd/infra-egress-sync/main_test.go b/cmd/infra-egress-sync/main_test.go index a5bef57..5e4ffc0 100644 --- a/cmd/infra-egress-sync/main_test.go +++ b/cmd/infra-egress-sync/main_test.go @@ -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) } diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index 5c0f8a4..cf38952 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -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 ---------------------------------------------------------------- diff --git a/internal/account/collection/collection.go b/internal/account/collection/collection.go index ddaaad9..fb480c7 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 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: 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..7ad0bea 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -4,25 +4,21 @@ package cycle // v1, DESIGN.md "Base fee — v1 spec", owner spec 2026-07-05, D1c). Called by // api-platform's applications-service fire-and-forget (with retry) on app // create / module install / uninstall / app delete, so BOTH RPCs are -// idempotent end to end: a retry can re-register or re-sync without a second -// charge or a moved timestamp. +// idempotent end to end: a retry can re-register or re-sync without a moved +// timestamp. // -// They live in the cycle package because RegisterApp's creation-proration -// charge IS a charge-spine leg: it reuses the SAME Stripe invoice plumbing -// (CreateInvoiceItem + CreateInvoice with deterministic Idempotency-Keys), -// the SAME micros→cents boundary (centsFromMicros), and the SAME invoice -// mirror (UpsertInvoice) as RunBillingCycle — never a second Stripe path. +// Neither RPC charges Stripe. The creation-period base is charged by the grace +// sweep (proration.go) once an app survives GraceDays — see ChargeCreationProration +// for the charge leg that reuses the SAME Stripe plumbing, micros→cents boundary, +// and invoice mirror as RunBillingCycle. import ( "context" - "fmt" "time" "github.com/google/uuid" "github.com/mirrorstack-ai/billing-engine/internal/account/billing" - "github.com/mirrorstack-ai/billing-engine/internal/account/usage" - "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" ) // maxModuleCount bounds the installed-module count BOTH mirror RPCs accept. @@ -53,11 +49,12 @@ type RegisterAppRequest struct { CreatedAt time.Time `json:"created_at,omitempty"` } -// RegisterAppResponse reports the mirror write + what the proration leg did. -// ProrationInvoiceID is "" when no proration invoice exists (unactivated -// account, no usable PM, a creation period that already closed — the boundary -// advance leg owns every later period — or the amount rounded to 0 cents; all -// legitimate no-charge outcomes, D1d). +// RegisterAppResponse reports the mirror write. RegisterApp charges nothing +// (creation grace); the creation-proration invoice is minted later by the sweep +// (proration.go). ProrationInvoiceID is therefore "" for a fresh registration +// and carries the armed guard's invoice id only for a retry that lands AFTER the +// sweep already charged (idempotent visibility). ProrationCents stays 0 here (no +// charge happens in this RPC) — the fields are retained for wire back-compat. type RegisterAppResponse struct { AppID uuid.UUID `json:"app_id"` AccountID uuid.UUID `json:"account_id"` @@ -81,8 +78,12 @@ type SyncAppModulesResponse struct { Deleted bool `json:"deleted"` } -// RegisterApp mirrors a freshly created platform app into ms_billing.apps and -// charges the creation-period proration (owner spec 2026-07-05): +// RegisterApp mirrors a freshly created platform app into ms_billing.apps. It +// charges NOTHING (creation grace, owner spec 2026-07-05, D1e follow-up): a +// newly created app enters a grace window and is charged its creation-period +// base only later, by the sweep (ChargeCreationProration / SweepCreationProrations +// in proration.go), and only if it SURVIVES grace — so an app deleted within +// grace is never billed. RegisterApp: // // 1. resolve-or-create the owner's billing account via the SAME // advisory-locked get-or-create billing.Ensure uses (no Stripe Customer is @@ -91,41 +92,21 @@ type SyncAppModulesResponse struct { // // 2. insert the roster row idempotently (ON CONFLICT (app_id) DO NOTHING — // a retry keeps the FIRST registration's created_at / module_count, the -// stable proration anchor), then read the row back; +// stable proration anchor the sweep later prices from), then read it back. // -// 3. proration leg, gated exactly like the spine (D1d): the account must be -// ACTIVATED (activated_at set — migration 025) AND have a usable default -// PM; otherwise the row is recorded and NO invoice is created (no -// retroactive catch-up on activation in v1). The one-shot guard -// proration_invoice_id short-circuits a retry that already charged. -// Amount = ProratedBaseMicros(AppBaseFeeMicros(base, module_count), -// created_at, the anchored period CONTAINING created_at) — whole UTC -// days, creation day inclusive, round-half-up — converted micros → whole -// cents at the Stripe boundary like every other charge. 0 cents → row -// recorded, no invoice. +// The response echoes the resolved account id and — for a retry that lands after +// the sweep already charged — the armed guard's invoice id (idempotent +// visibility). ProrationCents is never set here (no charge happens in this RPC). +// api-platform fires this fire-and-forget with retry; every step is idempotent. // -// INVARIANT (charge-leg ownership): the proration window is derived from -// the read-back mirror row's created_at (the stable first-registration -// anchor), NEVER from "now" — and if that creation period has already -// ENDED, the proration charge is SKIPPED entirely (charge 0, guard left -// unarmed), deterministically on this and every future retry. Every -// period after the creation period belongs to the boundary advance leg -// (which bills the apps that existed before the period opened), so a -// late retry recomputing against a newer window would double-charge a -// period the boundary already billed — and v1 does no retroactive -// catch-up either way (D1d). -// -// 4. charge via the spine's plumbing with app-scoped deterministic -// Idempotency-Keys (app-ii-/app-inv- — the app id is the stable -// charge identity here, as the run id is for the boundary leg), mirror the -// invoice with the PARTIAL window [creation day, period end), write the -// per-app-period base snapshot (migration 028, source='proration' — the -// frozen display value for the creation period), then arm the one-shot -// guard. -// -// A Stripe/mirror/snapshot failure after the row insert returns an error with -// the guard UNARMED — the platform's retry re-attempts the charge through the -// same idem keys, so the failure mode is "retry-safe", never "double-charge". +// Finally, AFTER the mirror insert, it synthesizes K per-module install timers +// (migration 033) anchored at created_at — one row per co-created module, each on +// its own independent overage grace timer. Reconcile-to-target (insert only the +// deficit vs. the app's live timer count) keeps it idempotent across +// fire-and-forget retries and self-heals a crashed first attempt. The timers are +// charged (if "over") only later, by the Leg 1 sweep (overage.go), and only after +// each survives its OWN grace — independent of the deferred creation-proration +// base charge (proration.go). func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*RegisterAppResponse, error) { if req.OwnerUserID == uuid.Nil && req.OwnerOrgID == uuid.Nil { return nil, billing.InvalidInput("owner_user_id or owner_org_id required") @@ -163,9 +144,9 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg return nil, billing.Internal("insert app mirror failed", err) } - // Read the row BACK: a retry must prorate from the FIRST registration's - // created_at / module_count (the insert's ON CONFLICT DO NOTHING preserved - // them), and the one-shot guard state decides whether to charge at all. + // Read the row BACK so the response reflects the FIRST registration's stable + // account + guard state (the insert's ON CONFLICT DO NOTHING preserved them): + // a retry that lands after the sweep charged echoes the armed invoice id. app, found, err := s.store.AppMirror(ctx, req.AppID) if err != nil { return nil, billing.Internal("app mirror lookup failed", err) @@ -173,128 +154,25 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg if !found { return nil, billing.Internal("app mirror row missing immediately after insert", nil) } - resp := &RegisterAppResponse{AppID: app.AppID, AccountID: app.AccountID} - - // One-shot guard: a prior attempt already charged (or a concurrent one - // won). Idempotent success — NEVER a second invoice. - if app.ProrationInvoiceID != "" { - resp.ProrationInvoiceID = app.ProrationInvoiceID - return resp, nil - } - - // Activation gate (D1d): unactivated accounts are never charged — the row - // is recorded and the guard stays unarmed (no retroactive catch-up in v1). - activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("account activation lookup failed", err) - } - if !activated { - return resp, nil - } - hasPM, err := s.store.HasUsableDefaultPM(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("usable PM check failed", err) - } - if !hasPM { - return resp, nil // same skipped_no_pm posture as the spine — row kept, no invoice - } - - // The creation period is the anchored window CONTAINING the app's - // created_at (ADR 0005 anchor from the activation day) — windowed from the - // read-back mirror row's created_at, the stable first-registration anchor, - // NEVER from now: a retry landing after the boundary must not recompute - // against the NEW period (ProratedBaseMicros would return the FULL base for - // a window the boundary advance leg already billed → double charge, and the - // legitimate creation-period proration would be lost). The proration bills - // the window's remaining whole UTC days, creation day inclusive. - // TODO(plan): plan-resolved base once a plan resolver exists (v1 = default). - periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(app.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) - - // INVARIANT: the proration leg bills ONLY the creation period. If that - // window has already ended, skip the charge entirely — charge 0, guard - // left UNARMED — deterministically on this and every future retry. Every - // later period is owned by the boundary advance leg (it bills the apps - // that existed before the period opened), and v1 does no retroactive - // catch-up (D1d), so a late retry must never invoice anything here. - if !s.nowFn().UTC().Before(periodEnd) { - return resp, nil - } - - prorated := usage.ProratedBaseMicros( - usage.AppBaseFeeMicros(usage.BaseFeeMicros, app.ModuleCount), - app.CreatedAt, periodStart, periodEnd, - ) - cents, err := centsFromMicros(prorated) - if err != nil { - return nil, billing.Internal("micros to cents conversion failed", err) - } - if cents == 0 { - // Rounded to 0 cents — nothing to invoice; the row stands and the - // boundary leg picks the app up from the next period on. - return resp, nil - } - - if s.stripe == nil { - return nil, billing.Internal("RegisterApp proration requires a Stripe client", nil) - } - custID, err := s.store.AccountStripeCustomer(ctx, app.AccountID) - if err != nil { - return nil, billing.Internal("stripe customer lookup failed", err) - } - if custID == "" { - // A usable PM implies a Customer (same anomaly posture as the spine). - return nil, billing.Internal("account has a usable PM but no Stripe customer id", nil) - } - - desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", app.AppID) - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, appProrationItemIdemKey(app.AppID)); err != nil { - return nil, billing.StripeError("proration invoice item failed", err) - } - inv, err := s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, appProrationInvoiceIdemKey(app.AppID)) - if err != nil { - return nil, billing.StripeError("proration invoice failed", err) + resp := &RegisterAppResponse{ + AppID: app.AppID, + AccountID: app.AccountID, + ProrationInvoiceID: app.ProrationInvoiceID, // "" until the sweep charges + } + + // Synthesize the app's per-module install timers (migration 033), all anchored + // at the app's created_at (the read-back mirror's stable first-registration + // value) — the RPC carries only an integer module_count, so K identical timer + // rows stand in for the K co-created module instances. The reconcile derives + // the target count, owning account, AND deleted state from the roster row + // INSIDE its per-app advisory-locked transaction (H7 + wave 2 D8/D9): a + // concurrent retry can never double-insert the deficit, a late retry whose + // mirror read is stale can never shrink to an outdated count, and a retry + // landing after deletion removes orphans instead of resurrecting timers. + if err := s.store.ReconcileModuleTimersToTarget(ctx, app.AppID, + app.CreatedAt, moduleGraceExpiry(app.CreatedAt), s.nowFn().UTC()); err != nil { + return nil, billing.Internal("reconcile module overage timers failed", err) } - - // Mirror with the PARTIAL window: [creation day (UTC midnight), period - // end) — the SAME coverage-start instant ProratedBaseMicros priced, so - // the mirrored window and the charged amount agree by construction. - partialStart := usage.ProrationCoverageStart(app.CreatedAt, periodStart) - if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: app.AccountID, - StripeInvoiceID: inv.ID, - Status: inv.Status, - AmountDueCents: inv.AmountDue, - AmountPaidCents: inv.AmountPaid, - Currency: chargeCurrency, - PeriodStart: partialStart, - PeriodEnd: periodEnd, - }); err != nil { - return nil, billing.Internal("invoice mirror upsert failed", err) - } - - // Freeze what this charge actually billed (migration 028) BEFORE arming the - // guard: the snapshot is the display's authoritative base for the creation - // period, so a later SyncAppModules can never drift the shown base away - // from this invoice. Keyed by the FULL anchored period_start (the display - // identity); BaseMicros is the prorated partial-window amount. Idempotent — - // a retry that failed between here and the guard re-upserts identical - // values through the same Stripe idem keys. - if err := s.store.UpsertProrationBaseSnapshot(ctx, AppBaseSnapshot{ - AppID: app.AppID, - PeriodStart: periodStart, - PeriodEnd: periodEnd, - ModuleCount: app.ModuleCount, - BaseMicros: prorated, - }); err != nil { - return nil, billing.Internal("proration base snapshot upsert failed", err) - } - - if err := s.store.SetAppProrationInvoice(ctx, app.AppID, inv.ID); err != nil { - return nil, billing.Internal("arm proration guard failed", err) - } - - resp.ProrationInvoiceID = inv.ID - resp.ProrationCents = cents return resp, nil } @@ -308,8 +186,15 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg // — there is no future base for the tier to move); // - an unknown app_id is NOT_FOUND (the platform must RegisterApp first). // -// Count changes take effect at the NEXT boundary charge (D1b — no mid-period -// micro-invoices for module #6, no mid-period refunds for uninstalls). +// After any module_count / delete write it synthesizes the app's per-module +// install timers (migration 033): a grow (M>N) inserts M−N new timers anchored +// at now (each a genuine new install on its OWN overage grace timer); a shrink +// (M 0 + + // FREEZE-OR-REUSE the boundary Stripe request (crash-safe idempotency, + // migration 035). The idem keys inv-/ii-/fin- are STABLE across a + // reclaim of this run, so the request sent under them must be stable too. + // + // - Frozen (read at the very top, before every gate — H8): a prior attempt + // already committed to a Stripe request; REUSE the frozen (cents, + // withBase) verbatim rather than the values just recomputed from LIVE + // state — drift between the crash and this retry (a module uninstalled + // flipping an over-module to included, an app deleted) could have moved + // the live total, and re-sending the same idem key with a different + // amount/description is the permanent Stripe idempotency-conflict stall + // this guards against (the bug ee5043c fixed once for the account-wide + // model, whose freeze migration 033 dropped). + // - Fresh: the cents==0 sub-half-cent short-circuit applies (never call + // Stripe for $0 — an advance base/overage, when present, is always ≥ $3 + // and can never round to 0; nothing was ever put through Stripe for this + // run), then freeze BEFORE the first Stripe call. The freeze is + // first-write-wins AND returns the SURVIVING row value (H6): a concurrent + // second daemon that reclaimed the same run and froze first wins, and + // THIS process adopts the winner's amount — two racing processes can + // never send different bodies under the shared idem keys. + if hasFrozen { + cents = frozen.Cents + withBase = frozen.WithBase + } else { + if cents == 0 { + // Same D7 guard as the zero-skip above — terminal only while unfrozen. + ok, err := s.store.MarkBillingRunInvoicedIfUnfrozen(ctx, runID) + if err != nil { + return nil, billing.Internal("mark billing run (zero cents) failed", err) + } + if !ok { + return nil, billing.Internal("zero-cents skip lost to a concurrent freeze — run left pending for the next reclaim to reconcile", nil) + } + summary.Status = RunStatusInvoiced + return summary, nil + } + surviving, err := s.store.FreezeBillingRunCharge(ctx, runID, FrozenBoundaryCharge{Cents: cents, WithBase: withBase}) + if err != nil { + return nil, billing.Internal("freeze boundary charge failed", err) + } + cents = surviving.Cents + withBase = surviving.WithBase + } summary.ChargedCents = cents - // Charge. A failure after the PM gate marks the run 'failed' (auditable) and - // returns the error. - inv, err := s.charge(ctx, runID, custID, cents, advanceBase > 0) + // Charge — or, on a frozen reclaim, RECONCILE against Stripe first (H5): the + // frozen marker means a prior attempt reached its Stripe section, and past + // the ~24h idempotency-key window a bare "replay" would mint brand-new + // objects and double-charge. boundaryInvoice finishes the invoice already + // FOUND under the run's ms_charge_ref (looked up once, next to the frozen + // read — D6); with nothing recovered it charges fresh (through the gates + // above). A failure after the PM gate marks the run 'failed' (auditable) + // and returns the error. + inv, err := s.boundaryInvoice(ctx, runID, custID, cents, withBase, recovered) if err != nil { if markErr := s.store.MarkBillingRun(ctx, runID, RunStatusFailed, "", 0); markErr != nil { // Both failed: surface the original charge error; the failed-mark is @@ -289,15 +457,50 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return nil, billing.StripeError("charge failed", err) } + // Post-hoc large-charge disclosure (migration 034, scenario 5): the charge + // SUCCEEDED above; flag it as "large" iff the amount just charged (netted + // usage arrears + advance base + advance overage, in micros — the SAME + // boundaryTotal converted to cents above) exceeds the account's threshold + // resolved AT CHARGE TIME (its per-account override, or the platform default + // when NULL) via the shared flagLargeAutoCollect helper. Pure disclosure — it + // changes NO charging behaviour, only surfaces the already-successful debit. + // + // The threshold is RE-RESOLVED HERE — immediately after the Stripe call + // succeeded — rather than reusing `acct` loaded at the top of this + // 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) + } + // The flag describes the money that MOVED (review 2026-07-06, M4). On the + // fresh path that is boundaryTotal (whose cents conversion is exactly what + // was charged — sub-cent precision preserved for the threshold comparison). + // When the charged cents came from a frozen/surviving value that DIFFERS + // from the live recompute (a reclaim after roster drift, or a lost freeze + // race), the live total describes a charge that never happened — flag the + // amount actually sent to Stripe instead. + chargedMicros := boundaryTotal + if cents != liveCents { + chargedMicros = cents * microsPerCent + } if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ - AccountID: accountID, - StripeInvoiceID: inv.ID, - 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: flagLargeAutoCollect(chargedMicros, postChargeAcct), }); err != nil { return nil, billing.Internal("invoice mirror upsert failed", err) } @@ -315,7 +518,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri PeriodStart: periodEnd, // the new period opens where the closed one ends PeriodEnd: newPeriodEnd, ModuleCount: a.ModuleCount, - BaseMicros: usage.AppBaseFeeMicros(usage.BaseFeeMicros, a.ModuleCount), + BaseMicros: usage.BaseFeeMicros, // FLAT per-app base (module overage rides per-module timers, migration 033) }); err != nil { return nil, billing.Internal("advance base snapshot insert failed", err) } @@ -330,22 +533,74 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri return summary, nil } -// charge creates the Stripe invoice item + draft invoice for the boundary -// total (usage arrears + advance base), with the two deterministic -// Idempotency-Keys (ii-, inv-) so a re-run reuses the same Stripe -// objects. withBase only widens the line DESCRIPTION when the total includes -// an advance base fee — a pure-usage invoice keeps the historical line text. -// Returns the created invoice projection (id/status/amounts) for the mirror -// upsert. +// charge runs the boundary total (usage arrears + advance base + advance +// overage) through the draft→pinned-item→finalize flow with three +// deterministic Idempotency-Keys (inv-, ii-, fin-) so a re-run +// reuses the same Stripe objects. The item is PINNED to this run's own draft +// (never a floating pending item another leg's invoice could sweep up — C2), +// and only the finalize step moves money. withBase only widens the line +// DESCRIPTION when the total includes an advance base fee and/or +// ongoing-module overage — a pure-usage invoice keeps the historical line +// text. Returns the finalized invoice projection (id/status/amounts) for the +// mirror upsert. func (s *Service) charge(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool) (billingstripe.Invoice, error) { - desc := fmt.Sprintf("MirrorStack usage — run %s", runID) - if withBase { - desc = fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, boundaryChargeRef(runID), invoiceIdemKey(runID)) + if err != nil { + return billingstripe.Invoice{}, err } - if _, err := s.stripe.CreateInvoiceItem(ctx, custID, cents, chargeCurrency, desc, invoiceItemIdemKey(runID)); err != nil { + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { return billingstripe.Invoice{}, err } - return s.stripe.CreateInvoice(ctx, custID, true /* autoAdvance */, invoiceIdemKey(runID)) + return s.stripe.FinalizeInvoice(ctx, draft.ID, invoiceFinalizeIdemKey(runID)) +} + +// boundaryInvoice resolves the boundary run's Stripe invoice. recovered (the +// invoice RunBillingCycle already found under the run's ms_charge_ref anchor, +// next to the frozen read — H5/D6) is finished: a finalized invoice is +// returned as-is (money already moved); a VOID one is refused loudly (the +// charge was canceled — adopting it would silently forgive the arrears and +// terminally consume the run); an inert draft is completed — the +// deterministic line attached if it never landed, then finalized; a +// mismatched draft is refused loudly. recovered == nil (nothing on Stripe, or +// a fresh run) charges through s.charge — that path re-entered every +// collection gate in RunBillingCycle. +func (s *Service) boundaryInvoice(ctx context.Context, runID uuid.UUID, custID string, cents int64, withBase bool, recovered *billingstripe.Invoice) (billingstripe.Invoice, error) { + if recovered != nil { + found := *recovered + if found.Status == "void" { + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (run %s needs ops resolution)", + found.ID, boundaryChargeRef(runID), runID) + } + if found.Status != "draft" { + return found, nil // finalized (paid/open/uncollectible) — the charge exists; mirror it + } + switch found.AmountDue { + case 0: + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, boundaryChargeDesc(runID, withBase), invoiceItemIdemKey(runID)); err != nil { + return billingstripe.Invoice{}, err + } + case cents: + // line already attached — nothing to add + default: + return billingstripe.Invoice{}, fmt.Errorf( + "boundary recovery: draft %s carries %d cents but the frozen amount is %d — refusing to finalize a mismatched draft (run %s)", + found.ID, found.AmountDue, cents, runID) + } + return s.stripe.FinalizeInvoice(ctx, found.ID, invoiceFinalizeIdemKey(runID)) + } + return s.charge(ctx, runID, custID, cents, withBase) +} + +// boundaryChargeRef is the deterministic ms_charge_ref metadata anchor for one +// boundary run's invoice — what FindInvoiceByRef recovers by. +func boundaryChargeRef(runID uuid.UUID) string { return "run:" + runID.String() } + +func boundaryChargeDesc(runID uuid.UUID, withBase bool) string { + if withBase { + return fmt.Sprintf("MirrorStack usage + app base fees — run %s", runID) + } + return fmt.Sprintf("MirrorStack usage — run %s", runID) } // AccountsWithUsageEvents returns the accounts with raw usage_events in the @@ -383,15 +638,16 @@ func (s *Service) AccountsWithUnbilledUsage(ctx context.Context, periodStart, pe // cycle's gate for running the boundary charge on a NO-USAGE period: an // account with live pre-existing apps still owes the advance base fee, while // a no-usage, no-apps (pre-backfill) account keeps the historical skip (no -// billing_run at all). Apps created INSIDE the new period don't arm the gate: -// their new-period base is RegisterApp's proration leg's, and they join the -// advance leg at the NEXT boundary — running a boundary for them here would -// only mint a zero-charge run row. +// billing_run at all). Apps created INSIDE the new period — or still inside +// their creation grace at the boundary (H2, same rule as the advance leg +// itself) — don't arm the gate: their new-period base is the creation- +// proration leg's, and they join the advance leg at the NEXT boundary — +// running a boundary for them here would only mint a zero-charge run row. func (s *Service) AccountHasLiveApps(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) (bool, error) { if accountID == uuid.Nil { return false, billing.InvalidInput("account_id required") } - apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, createdBefore) + apps, err := s.store.LiveAppsCreatedBefore(ctx, accountID, createdBefore, usage.GraceDays) if err != nil { return false, billing.Internal("live app roster read failed", err) } @@ -423,14 +679,31 @@ func (s *Service) LatestClosedPeriodEnd(ctx context.Context, accountID uuid.UUID return end, found, nil } -// invoiceItemIdemKey / invoiceIdemKey build the deterministic per-run Stripe -// Idempotency-Keys. The run id is the stable charge identity, so a re-fire -// (same run row) produces the SAME keys and Stripe returns the original objects -// instead of creating duplicates. The arrears charge is a SINGLE pooled line -// per run (Σ charged across all metrics), so the item key is ii- (not -// per-metric) — matching the single combined invoice item this leg creates. -func invoiceItemIdemKey(runID uuid.UUID) string { return "ii-" + runID.String() } -func invoiceIdemKey(runID uuid.UUID) string { return "inv-" + runID.String() } +// invoiceItemIdemKey / invoiceIdemKey / invoiceFinalizeIdemKey build the +// deterministic per-run Stripe Idempotency-Keys for the boundary leg's +// draft→item→finalize flow. The run id is the stable charge identity, so a +// re-fire (same run row) produces the SAME keys and Stripe returns the +// original objects instead of creating duplicates. The arrears charge is a +// SINGLE pooled line per run (Σ charged across all metrics), so the item key +// is ii- (not per-metric) — matching the single combined invoice item +// this leg creates. +func invoiceItemIdemKey(runID uuid.UUID) string { return "ii-" + runID.String() } +func invoiceIdemKey(runID uuid.UUID) string { return "inv-" + runID.String() } +func invoiceFinalizeIdemKey(runID uuid.UUID) string { return "fin-" + runID.String() } + +// flagLargeAutoCollect is the ONE large-charge disclosure resolver (scenario 5, +// migration 034), called from EVERY off-session charge site — the boundary leg +// (charge.go), the creation/combined leg (proration.go), and the per-module grace +// leg (overage.go / Leg 1) — so the "large auto-collect" flag on a mirrored +// invoice row is computed identically everywhere and never reimplemented per leg. +// chargedMicros is the RAW pre-cents-conversion amount that just successfully +// charged; acct MUST be the account state read immediately AFTER the Stripe call +// succeeded (its per-account threshold override, or the platform default when +// nil), so a threshold edit landing concurrently with the charge is honored the +// same way at every site. +func flagLargeAutoCollect(chargedMicros int64, acct AccountCollection) bool { + return collection.IsLargeAutoCollect(chargedMicros, acct.AutoCollectThresholdMicros) +} // toCollectionAccount maps the cycle store's AccountCollection to the pure-policy // collection.Account the risk-judge reasons over. Kept here so the collection diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 5bdf808..427cdd7 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" @@ -23,8 +24,9 @@ import ( type fakeStripe struct { // recorded calls - itemCalls []itemCall - invoiceCalls []invoiceCall + itemCalls []itemCall + invoiceCalls []invoiceCall // draft creations (one per invoice, C2 flow) + finalizeCalls []finalizeCall // returns invoiceID string @@ -33,12 +35,29 @@ type fakeStripe struct { invoiceAmountPaid int64 // injected errors - errItem error - errInvoice error + errItem error + errDraft error + errInvoice error // injected on FinalizeInvoice — the money-moving step + errFindByRef error + + // crash-recovery lookup (FindInvoiceByRef): invoices "found" on Stripe + // KEYED BY REF (wave 2 critic finding — an unkeyed fake could not detect a + // cross-leg charge-ref mixup), and the refs queried. Tests seed via + // setFindByRef. + findByRefByRef map[string]billingstripe.Invoice + findByRefCalls []string + // onCreateInvoice, when set, runs INSIDE FinalizeInvoice right before it + // returns success — modeling a concurrent account mutation (e.g. a + // 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 { custID string + invoiceID string amountCfg int64 currency string desc string @@ -46,9 +65,14 @@ type itemCall struct { } type invoiceCall struct { - custID string - autoAdvance bool - idemKey string + custID string + ref string + idemKey string +} + +type finalizeCall struct { + invoiceID string + idemKey string } func newFakeStripe() *fakeStripe { @@ -59,21 +83,53 @@ func newFakeStripe() *fakeStripe { } } -func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID string, amountCents int64, currency, desc, idemKey string) (billingstripe.InvoiceItem, error) { - f.itemCalls = append(f.itemCalls, itemCall{custID, amountCents, currency, desc, idemKey}) +func (f *fakeStripe) CreateDraftInvoice(_ context.Context, custID, ref, idemKey string) (billingstripe.Invoice, error) { + f.invoiceCalls = append(f.invoiceCalls, invoiceCall{custID, ref, idemKey}) + if f.errDraft != nil { + return billingstripe.Invoice{}, f.errDraft + } + return billingstripe.Invoice{ID: f.invoiceID, Status: "draft", Currency: "usd"}, nil +} + +func (f *fakeStripe) CreateInvoiceItem(_ context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (billingstripe.InvoiceItem, error) { + f.itemCalls = append(f.itemCalls, itemCall{custID, invoiceID, amountCents, currency, desc, idemKey}) if f.errItem != nil { return billingstripe.InvoiceItem{}, f.errItem } return billingstripe.InvoiceItem{ID: "ii_test_" + uuid.NewString()}, nil } -func (f *fakeStripe) CreateInvoice(_ context.Context, custID string, autoAdvance bool, idemKey string) (billingstripe.Invoice, error) { - f.invoiceCalls = append(f.invoiceCalls, invoiceCall{custID, autoAdvance, idemKey}) +// setFindByRef seeds the invoice the recovery lookup finds under EXACTLY the +// given ref — a lookup with any other ref misses, so a leg reconciling against +// another leg's charge identity fails its test. +func (f *fakeStripe) setFindByRef(ref string, inv billingstripe.Invoice) { + if f.findByRefByRef == nil { + f.findByRefByRef = map[string]billingstripe.Invoice{} + } + f.findByRefByRef[ref] = inv +} + +func (f *fakeStripe) FindInvoiceByRef(_ context.Context, _, ref string) (billingstripe.Invoice, bool, error) { + f.findByRefCalls = append(f.findByRefCalls, ref) + if f.errFindByRef != nil { + return billingstripe.Invoice{}, false, f.errFindByRef + } + if inv, ok := f.findByRefByRef[ref]; ok { + return inv, true, nil + } + return billingstripe.Invoice{}, false, nil +} + +func (f *fakeStripe) FinalizeInvoice(_ context.Context, invoiceID, idemKey string) (billingstripe.Invoice, error) { + f.finalizeCalls = append(f.finalizeCalls, finalizeCall{invoiceID, idemKey}) if f.errInvoice != nil { return billingstripe.Invoice{}, f.errInvoice } + if f.onCreateInvoice != nil { + f.onCreateInvoice() + } return billingstripe.Invoice{ - ID: f.invoiceID, + ID: invoiceID, Status: f.invoiceStatus, AmountDue: f.invoiceAmountDue, AmountPaid: f.invoiceAmountPaid, @@ -135,7 +191,7 @@ func TestRunBillingCycle_ChargesArrears(t *testing.T) { require.Equal(t, "cus_test_1", sc.itemCalls[0].custID) require.EqualValues(t, 123, sc.itemCalls[0].amountCfg) require.Equal(t, "usd", sc.itemCalls[0].currency) - require.True(t, sc.invoiceCalls[0].autoAdvance) + require.Len(t, sc.finalizeCalls, 1, "the draft is finalized (auto_advance) — the money-moving step") // Invoice mirrored + run marked invoiced. require.Len(t, store.invoices, 1) @@ -152,6 +208,244 @@ func TestRunBillingCycle_ChargesArrears(t *testing.T) { } } +// --- FINDING 3: a reclaimed boundary run reuses its FROZEN charge amount, never +// a freshly-recomputed live total, so the stable Stripe idem key never conflicts - + +func TestRunBillingCycle_ReclaimReusesFrozenBoundaryChargeAmount(t *testing.T) { + // Reproduces the exact failure scenario. Account X's boundary run computes + // arrears $1 + advance base $40 (2 apps) + advance overage $6 (2 ongoing + // over-modules) = $47 (4700¢), calls Stripe under ii-/inv- (the money + // moves), but crashes before MarkBillingRun commits — the run stays 'pending'. + // Before the retry a customer uninstalls one over-module, so a LIVE recompute + // would now yield only $44 (4400¢). InsertBillingRun RECLAIMS the SAME run id + // (same idem keys). Pre-fix, the retry re-sent those keys with the recomputed + // $44 — a mismatched body under a used idem key — which Stripe rejects, + // permanently stalling the run. Fixed: the retry REUSES the frozen $47 under the + // same keys and completes. + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 usage arrears + store.hasPM = true + store.stripeCustomer = "cus_f3" + + // 2 live apps created before the new period → $40 advance base. + seedApp(store, chargeAccount, 0, false) + app2 := seedApp(store, chargeAccount, 0, false) + // 5 included (ranks 0-4) + 2 ongoing over-modules already charged in a prior + // period (ranks 5-6) → overCount 2 → $6 advance overage. + seedIncluded(store, chargeAccount, app2, timeUTC(2026, 5, 1, 0), 5) + o1 := seedTimer(store, chargeAccount, app2, timeUTC(2026, 5, 10, 0)) + o2 := seedTimer(store, chargeAccount, app2, timeUTC(2026, 5, 11, 0)) + for _, id := range []uuid.UUID{o1, o2} { + store.timers[id].graceResolved = true + store.timers[id].graceCharged = true // charged in a prior period → ongoing + } + + sc := newFakeStripe() + + // FIRST attempt: Stripe charges $47, but MarkBillingRun fails (Lambda timed out + // before commit) → the run stays 'pending', the frozen $47 is durable. + store.errMarkRun = errors.New("lambda timeout before MarkBillingRun commit") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err, "the mark failed → the run is left pending, resumable") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 4700, sc.itemCalls[0].amountCfg, "$1 + $40 + 2×$3 = $47") + firstIdem := sc.itemCalls[0].idemKey + + // Between attempts: a customer uninstalls one over-module → a LIVE recompute + // would now yield overCount 1 → $44, NOT $47. + store.timers[o2].removed = true + store.timers[o2].removedAt = timeUTC(2026, 6, 15, 0) + store.errMarkRun = nil // the retry's mark succeeds + + // RETRY: reclaims the SAME run id (same idem keys). It must charge the FROZEN + // $47, never the recomputed $44 — otherwise Stripe rejects the reused key. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.True(t, resp.FirstRun, "a reclaimed non-terminal run is a fresh charge attempt") + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.Len(t, sc.itemCalls, 2) + require.Equal(t, firstIdem, sc.itemCalls[1].idemKey, + "the reclaim reuses the same run id → the same Stripe idem key") + require.EqualValues(t, 4700, sc.itemCalls[1].amountCfg, + "so the amount under that key must be the frozen $47, not the recomputed $44") + require.EqualValues(t, 4700, resp.ChargedCents) + for _, m := range store.markedRuns { + require.Equal(t, cycle.RunStatusInvoiced, m.status) + require.EqualValues(t, 4700, m.totalCents) + } +} + +// Regression (review 2026-07-06, H8): every early-out — the zero-skip and the +// prepaid/ceiling/risk/PM gates — used to run BEFORE the frozen-charge lookup. +// A reclaimed run whose prior attempt already put money through Stripe could be +// marked skipped/invoiced WITHOUT mirroring that charge: unmirrored money now, +// a fresh double-charge after the idem keys age out. The frozen lookup now runs +// FIRST, and a frozen run's only job is to finish. +func TestRunBillingCycle_FrozenRunChargesEvenWhenLiveTotalCollapsesToZero(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 arrears + store.hasPM = true + store.stripeCustomer = "cus_h8" + app := seedApp(store, chargeAccount, 0, false) // + $20 base = $21 + + sc := newFakeStripe() + + // FIRST attempt: Stripe charges $21, crash before MarkBillingRun. + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Len(t, sc.finalizeCalls, 1, "the money moved") + + // Between attempts the LIVE total collapses to zero: the app is deleted and + // the arrears vanish (e.g. an aggregates correction). + a := store.apps[app] + a.Deleted = true + store.apps[app] = a + store.chargedTotal = 0 + store.errMarkRun = nil + + // RETRY: pre-fix the boundaryTotal==0 zero-skip marked the run 'invoiced' + // with NO mirror of the $21 already charged. Fixed: the frozen charge is + // reconciled first — replayed through the same keys, mirrored, marked. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 2100, resp.ChargedCents, "the frozen $21, not the collapsed live $0") + require.Len(t, store.invoices, 1, "the crashed attempt's charge is mirrored") + for _, m := range store.markedRuns { + require.EqualValues(t, 2100, m.totalCents) + } +} + +func TestRunBillingCycle_FrozenRunNotSkippedByPrepaidOrPMGates(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h8b" + + sc := newFakeStripe() + + // FIRST attempt charges $1, crash before mark. + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Len(t, sc.finalizeCalls, 1) + + // Between attempts: the account tightens to prepaid (possibly triggered by + // the crashed attempt's own open invoice) AND its default PM is removed. + // The crashed attempt's FINALIZED invoice exists on Stripe under the ref + // (that existence — not the frozen marker alone — is what justifies + // bypassing the gates, wave 2 D6). + store.collection.Mode = cycle.BillingModePrepaid + store.hasPM = false + store.errMarkRun = nil + sc.setFindByRef(sc.invoiceCalls[0].ref, billingstripe.Invoice{ID: sc.invoiceID, Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"}) + + // RETRY: pre-fix → skipped_prepaid (or skipped_no_pm), stranding the moved + // money unmirrored. Fixed: gates never apply over an EXISTING charge; the + // reconcile completes through the same objects. + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status, + "a run whose charge exists on Stripe finishes; it is never re-gated into a skip over moved money") + require.Len(t, store.invoices, 1) +} + +// Regression (wave 2, D6): the frozen marker is stamped BEFORE the first +// Stripe call, so "frozen" alone does not mean money moved. A reclaim whose +// prior attempt froze and then died BEFORE creating anything on Stripe is a +// genuinely fresh charge — the collection gates must apply, not be bypassed. +func TestRunBillingCycle_FrozenButNothingOnStripeIsReGated(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_d6" + + sc := newFakeStripe() + + // FIRST attempt: freezes, then the draft create fails — nothing on Stripe. + sc.errDraft = errors.New("stripe 5xx before any object existed") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + require.Empty(t, sc.finalizeCalls, "no money moved") + + // Overnight the account tightens to prepaid. findByRef stays nil (nothing + // under the ref). + store.collection.Mode = cycle.BillingModePrepaid + sc.errDraft = nil + + // RECLAIM: pre-fix the frozen marker bypassed every gate and a FRESH + // draft+item+finalize fired against the prepaid account. Fixed: nothing on + // Stripe → re-gated → skipped_prepaid (non-terminal; the frozen amount + // survives for a post-relax reclaim). + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusSkippedPrepaid, resp.Status, + "frozen-but-nothing-on-Stripe is a fresh charge — the prepaid gate applies") + require.Empty(t, sc.finalizeCalls, "no off-session charge against a prepaid account") +} + +// Regression (review 2026-07-06, H6): the freeze is first-write-wins AND the +// charger adopts the SURVIVING value — a concurrent second daemon that +// reclaimed the same run and froze first wins, so both processes send Stripe +// the same body under the shared idem keys. +func TestRunBillingCycle_LostFreezeRaceAdoptsWinnersAmount(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // this process computes $1 → 100¢ + store.hasPM = true + store.stripeCustomer = "cus_h6" + + sc := newFakeStripe() + + // A concurrent daemon B froze $47 in the race window between this process's + // top-of-run frozen read (empty) and its own freeze attempt. + store.onFreezeCharge = func(runID uuid.UUID) { + if _, exists := store.frozenCharges[runID]; !exists { + store.frozenCharges[runID] = cycle.FrozenBoundaryCharge{Cents: 4700, WithBase: true} + } + } + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, 4700, resp.ChargedCents, + "the loser of the freeze race must charge the winner's frozen amount, never its own") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 4700, sc.itemCalls[0].amountCfg) +} + +// Regression (review 2026-07-06, H5): a frozen reclaim past Stripe's ~24h +// idempotency-key window can no longer trust key replay — a bare re-send would +// mint a SECOND draft+item+charge. The reclaim now reconciles by the run's +// ms_charge_ref anchor first: the crashed attempt's finalized invoice is +// adopted (mirrored + marked) with NO new Stripe objects. +func TestRunBillingCycle_LateReclaimAdoptsFoundInvoiceWithoutNewObjects(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h5" + + sc := newFakeStripe() + + // FIRST attempt charges $1, crash before mark (frozen marker durable). + store.errMarkRun = errors.New("crash before mark") + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.Error(t, err) + drafts, finalizes := len(sc.invoiceCalls), len(sc.finalizeCalls) + + // The reclaim lands DAYS later — keys pruned — but the crashed attempt's + // invoice is findable under run:. + sc.setFindByRef(sc.invoiceCalls[0].ref, billingstripe.Invoice{ID: "in_prior_boundary", Status: "paid", AmountDue: 100, AmountPaid: 100, Currency: "usd"}) + store.errMarkRun = nil + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.Len(t, sc.invoiceCalls, drafts, "no second draft on a recovered reclaim") + require.Len(t, sc.finalizeCalls, finalizes, "no second finalize — the money moved once") + _, mirrored := store.invoices["in_prior_boundary"] + require.True(t, mirrored, "the crashed attempt's invoice is mirrored") +} + func TestRunBillingCycle_CentsRoundHalfUp(t *testing.T) { // 5_000 micros = 0.5 cents → round-half-up → 1 cent. store := newFakeStore() @@ -470,3 +764,171 @@ func TestRunBillingCycle_PropagatesStoreErrors(t *testing.T) { }) } } + +// --- large auto-collect disclosure flag (migration 034) ------------------- + +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 TestChargeCreationProration_ConcurrentThresholdEditMidChargeResolvesPostCharge(t *testing.T) { + // The creation-proration leg (the grace-sweep charge, proration.go) resolves + // its large-charge disclosure threshold at the SAME point relative to the + // actual charge as the boundary leg above: immediately AFTER the Stripe call + // succeeds, never from a pre-charge snapshot. Under the unified model this + // leg bills the FLAT per-app base only ($20 — module overage is no longer + // folded in), so the straddle here is a $20 charge between a $10 pre-charge + // threshold and a $30 mid-charge override. + store := newFakeStore() + user, _ := registeredAccount(store) + // Pre-charge threshold $10: were it resolved BEFORE the charge, the $20 base + // would flag "large" ($20 > $10). + stale := int64(10_000_000) + store.collection.AutoCollectThresholdMicros = &stale + sc := newFakeStripe() + sc.onCreateInvoice = func() { + // The concurrent edit lands WHILE the Stripe call is in flight, raising + // the threshold to $30 — above the $20 base. + override := int64(30_000_000) + store.collection.AutoCollectThresholdMicros = &override + } + svc := appsSvc(store, sc) + appID := uuid.New() + // CreatedAt on the anchored period boundary (day 4, matching registeredAccount's + // May-4 activation) → the FULL flat $20 base, no proration dampening. + registerMirror(t, svc, user, appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), 3) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2000, resp.ProrationCents, "flat $20 base, charged in full") + 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 $30 edit governs — $20 is not flagged") +} diff --git a/internal/account/cycle/migration027_integration_test.go b/internal/account/cycle/migration027_integration_test.go index e2b6328..6adb500 100644 --- a/internal/account/cycle/migration027_integration_test.go +++ b/internal/account/cycle/migration027_integration_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" ) @@ -94,7 +95,7 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { // proration leg, never this boundary's advance sum. require.NoError(t, store.InsertAppMirror(ctx, late, acct, 4, mustTime(t, "2026-07-01T10:00:00Z"))) - apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart) + apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) counts := make([]int, 0, len(apps)) for _, a := range apps { @@ -104,10 +105,20 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { require.ElementsMatch(t, []int{0, 6}, counts, "deleted apps, other accounts' apps, and apps created inside the new period never enter the advance-base sum") - // At the NEXT boundary the late app pre-exists the newer period and joins. - apps, err = store.LiveAppsCreatedBefore(ctx, acct, mustTime(t, "2026-08-01T00:00:00Z")) + // Created inside period A but still IN GRACE at the boundary (H2, review + // 2026-07-06): excluded too — it hasn't survived grace, and its creation + // charge covers the straddled period. + inGrace := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, inGrace, acct, 1, mustTime(t, "2026-06-29T00:00:00Z"))) + apps, err = store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) - require.Len(t, apps, 3) + require.Len(t, apps, 2, "an app whose creation grace straddles the boundary joins only at the NEXT boundary") + + // At the NEXT boundary the late + in-grace apps pre-exist the newer period + // (grace long elapsed) and join. + apps, err = store.LiveAppsCreatedBefore(ctx, acct, mustTime(t, "2026-08-01T00:00:00Z"), usage.GraceDays) + require.NoError(t, err) + require.Len(t, apps, 4) // EnsureAccountForUser: get-or-create resolves the SAME account twice. userID := uuid.New() diff --git a/internal/account/cycle/migration029_integration_test.go b/internal/account/cycle/migration029_integration_test.go new file mode 100644 index 0000000..5a77452 --- /dev/null +++ b/internal/account/cycle/migration029_integration_test.go @@ -0,0 +1,138 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 029 (apps_pending_proration_idx) — verifies the SQL-level semantics +// the unit fakes only model for the creation-grace sweep: the AppsPendingProration +// work-list filter (past grace AND live AND unarmed) and the ChargeProrationLocked +// FOR UPDATE section (deleted / already-armed short-circuits, and the atomic +// charge → mirror → snapshot → arm on a live unarmed app). + +func TestAppsPendingProration_Integration_Filter(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + // MarkAppDeleted stamps the DB's now(), so the deleted fixtures' created_at + // must be RELATIVE to the real clock for the D11 within-/after-grace split + // to be what each case intends. + now := time.Now().UTC() + cutoff := now.Add(time.Hour) // a sweep cutoff admitting everything created up to now + + pending := uuid.New() // past cutoff, live, unarmed → returned + young := uuid.New() // created after cutoff → excluded + deletedIn := uuid.New() // deleted WITHIN its grace → excluded (never charged) + deletedAfter := uuid.New() // deleted AFTER its grace elapsed → returned (D11: survived, still owes) + charged := uuid.New() // guard armed → excluded + require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, now.Add(-10*24*time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, now.Add(2*time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, deletedIn, acct, 0, now.Add(-time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, deletedAfter, acct, 0, now.Add(-9*24*time.Hour))) + require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, now.Add(-8*24*time.Hour))) + require.NoError(t, store.MarkAppDeleted(ctx, deletedIn)) + require.NoError(t, store.MarkAppDeleted(ctx, deletedAfter)) + require.NoError(t, store.SetAppProrationInvoice(ctx, charged, "in_already")) + + ids, err := store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{pending, deletedAfter}, ids, + "past-grace live unarmed apps are pending — including one deleted AFTER its grace (D11); a within-grace delete stays excluded") +} + +func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + periodStart := mustTime(t, "2026-06-04T00:00:00Z") + periodEnd := mustTime(t, "2026-07-04T00:00:00Z") + + // mkCharge builds the payload the service's charge callback would return. + mkCharge := func(appID uuid.UUID, invID string) *cycle.ProrationCharge { + return &cycle.ProrationCharge{ + InvoiceID: invID, + Cents: 200, + Invoice: cycle.InvoiceMirror{ + AccountID: acct, StripeInvoiceID: invID, Status: "open", + AmountDueCents: 200, Currency: "usd", + PeriodStart: mustTime(t, "2026-07-01T00:00:00Z"), PeriodEnd: periodEnd, + }, + Snapshot: cycle.AppBaseSnapshot{ + AppID: appID, PeriodStart: periodStart, PeriodEnd: periodEnd, + ModuleCount: 0, BaseMicros: 2_000_000, + }, + } + } + + // Live, unarmed app → the callback fires, and the invoice + snapshot + guard + // commit atomically. + live := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, live, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + called := false + outcome, invID, err := store.ChargeProrationLocked(ctx, live, func(l cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + require.Equal(t, live, l.AppID) + return mkCharge(live, "in_live"), nil + }) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, cycle.ProrationLockedCharged, outcome) + require.Equal(t, "in_live", invID) + app, _, err := store.AppMirror(ctx, live) + require.NoError(t, err) + require.Equal(t, "in_live", app.ProrationInvoiceID, "the guard is armed under the lock") + + // Re-run on the now-armed app → AlreadyCharged, callback NOT invoked, no + // second invoice. + called = false + outcome, invID, err = store.ChargeProrationLocked(ctx, live, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + return mkCharge(live, "in_second"), nil + }) + require.NoError(t, err) + require.False(t, called, "an armed guard short-circuits before the Stripe charge") + require.Equal(t, cycle.ProrationLockedAlreadyCharged, outcome) + require.Equal(t, "in_live", invID) + + // App deleted WITHIN its grace → Deleted, callback NOT invoked (never + // charged; a delete that won the race no-ops the charge). created_at is + // relative to the real clock because MarkAppDeleted stamps now() — a fixed + // past date would make this a post-grace delete, which D11 charges. + deleted := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, time.Now().UTC().Add(-time.Hour))) + require.NoError(t, store.MarkAppDeleted(ctx, deleted)) + called = false + outcome, _, err = store.ChargeProrationLocked(ctx, deleted, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + called = true + return mkCharge(deleted, "in_deleted"), nil + }) + require.NoError(t, err) + require.False(t, called, "a deleted app is never charged") + require.Equal(t, cycle.ProrationLockedDeleted, outcome) + + // Callback declines (0 cents) → NoCharge, guard stays unarmed, nothing persisted. + zero := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, zero, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + outcome, _, err = store.ChargeProrationLocked(ctx, zero, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + return nil, nil + }) + require.NoError(t, err) + require.Equal(t, cycle.ProrationLockedNoCharge, outcome) + app, _, err = store.AppMirror(ctx, zero) + require.NoError(t, err) + require.Empty(t, app.ProrationInvoiceID, "a declined charge leaves the guard unarmed") +} diff --git a/internal/account/cycle/migration030_integration_test.go b/internal/account/cycle/migration030_integration_test.go new file mode 100644 index 0000000..803ac27 --- /dev/null +++ b/internal/account/cycle/migration030_integration_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 030 (apps.created_module_count) — verifies at the SQL layer (the +// unit fakes only model this in Go) that the frozen creation-time module count +// survives a live module_count write untouched: InsertAppMirror stamps BOTH +// columns from the same value, and SetAppModuleCount (SyncAppModules' writer) +// touches ONLY module_count. + +func TestCreatedModuleCount_Integration_FrozenAcrossSetAppModuleCount(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, mustTime(t, "2026-07-01T08:00:00Z"))) + + app, found, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, 2, app.ModuleCount) + require.Equal(t, 2, app.CreatedModuleCount, "created_module_count starts equal to the registered count") + + // SyncAppModules' writer (module install/uninstall) — must move ONLY the + // live column. + require.NoError(t, store.SetAppModuleCount(ctx, appID, 9)) + + app, found, err = store.AppMirror(ctx, appID) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, 9, app.ModuleCount, "the live count moved") + require.Equal(t, 2, app.CreatedModuleCount, "the frozen count must NOT move — no query writes it after insert") + + // A second live-count write (another install/uninstall cycle) still never + // touches the frozen column. + require.NoError(t, store.SetAppModuleCount(ctx, appID, 0)) + app, _, err = store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 0, app.ModuleCount) + require.Equal(t, 2, app.CreatedModuleCount) +} + +func TestCreatedModuleCount_Integration_RetryKeepsFirstRegistrationsFrozenCount(t *testing.T) { + // InsertAppMirror's ON CONFLICT (app_id) DO NOTHING means a RegisterApp + // retry with a DIFFERENT module_count must not move either column — the + // FIRST registration's frozen count is the stable proration anchor. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + created := mustTime(t, "2026-07-01T08:00:00Z") + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 3, created)) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 12, created)) // retry, different count + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 3, app.ModuleCount) + require.Equal(t, 3, app.CreatedModuleCount) +} diff --git a/internal/account/cycle/migration031_integration_test.go b/internal/account/cycle/migration031_integration_test.go new file mode 100644 index 0000000..d957940 --- /dev/null +++ b/internal/account/cycle/migration031_integration_test.go @@ -0,0 +1,78 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 031 (apps.proration_skipped_at) — verifies at the SQL layer that +// the permanent-skip marker (D1d, no retroactive catch-up) is a one-shot, +// first-write-wins guard, and that AppsPendingProration's work-list filter +// excludes a permanently-skipped app forever (the same partial-index +// predicate the migration re-defines). + +func TestProrationSkipped_Integration_OneShotAndExcludedFromPending(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + cutoff := mustTime(t, "2026-04-05T00:00:00Z") + + skipped := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, skipped, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"))) + + // Past grace, unarmed, not yet skipped → pending. + ids, err := store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Equal(t, []uuid.UUID{skipped}, ids) + + // Mark permanently skipped (what ChargeCreationProration does once it + // determines the account activated after this app's period had closed). + require.NoError(t, store.SetAppProrationSkipped(ctx, skipped)) + + // Excluded from the pending work list from now on — a later sweep must + // never resurface it (it would otherwise sit pending forever, since + // proration_invoice_id stays NULL for a skipped charge). + ids, err = store.AppsPendingProration(ctx, cutoff) + require.NoError(t, err) + require.Empty(t, ids, "a permanently-skipped app is excluded from every future sweep") + + // One-shot: a second SetAppProrationSkipped call is a harmless no-op — it + // does not need to be idempotent-with-error, just idempotent-with-effect. + require.NoError(t, store.SetAppProrationSkipped(ctx, skipped)) + + app, _, err := store.AppMirror(ctx, skipped) + require.NoError(t, err) + require.True(t, app.ProrationSkipped) + require.Empty(t, app.ProrationInvoiceID, "skipped, never charged") +} + +func TestProrationSkipped_Integration_RefusesToSkipAnAlreadyChargedApp(t *testing.T) { + // Defensive belt-and-suspenders: SetAppProrationSkipped's WHERE clause also + // requires proration_invoice_id IS NULL, so it can never clobber a genuine + // charge that raced in first. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"))) + require.NoError(t, store.SetAppProrationInvoice(ctx, appID, "in_already_charged")) + + require.NoError(t, store.SetAppProrationSkipped(ctx, appID)) // no-op, not an error + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.False(t, app.ProrationSkipped, "an already-charged app must never be marked skipped") + require.Equal(t, "in_already_charged", app.ProrationInvoiceID) +} diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go new file mode 100644 index 0000000..96943e2 --- /dev/null +++ b/internal/account/cycle/migration033_integration_test.go @@ -0,0 +1,260 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// Migration 033 (ms_billing.app_module_overage_timers) — exercises the per-module +// install-timer SQL against a real Postgres: the generate_series bulk insert, the +// live-count reconcile input, the FIFO rank row-comparison, the past-grace sweep +// work-list join (activated_at gate), the terminal-mark first-write-wins guards, +// and LIFO / all soft-removal. +func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + // Activate so the sweep's activated_at IS NOT NULL gate passes. + _, err := pool.Exec(ctx, `UPDATE ms_billing.accounts SET activated_at = $2 WHERE id = $1`, + acct.String(), mustTime(t, "2026-05-04T00:00:00Z")) + require.NoError(t, err) + + app := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, app, acct, 0, mustTime(t, "2026-06-01T00:00:00Z"))) + + // 5 "included" installs anchored early + 1 "over" install anchored June 10. + early := mustTime(t, "2026-05-04T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, early, early.AddDate(0, 0, 3), 5)) + over := mustTime(t, "2026-06-10T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, over, over.AddDate(0, 0, 3), 1)) + + n, err := store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 6, n) + + // Work list as of June 20 (all graces elapsed): all 6 candidates, activated. + cands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-06-20T00:00:00Z")) + require.NoError(t, err) + require.Len(t, cands, 6) + + // LIVE FIFO rank: the 5 early installs rank 0-4 (included); the June-10 install + // ranks 5 (over) — verifying the (installed_at, id) row-comparison in SQL. + var overCand cycle.ModuleOverageCandidate + included := 0 + for _, c := range cands { + rank, err := store.LiveModuleTimerRankBefore(ctx, c.AccountID, c.ID, c.InstalledAt) + require.NoError(t, err) + if rank < usage.IncludedModules { + included++ + } else { + overCand = c + } + } + require.Equal(t, usage.IncludedModules, included) + require.True(t, over.Equal(overCand.InstalledAt), "the June-10 install is the over one") + + // Terminal marks drop rows out of the work list. + require.NoError(t, store.MarkModuleTimerCharged(ctx, overCand.ID, mustTime(t, "2026-06-20T00:00:00Z"), "in_real_stripe", "ii_real_stripe")) + for _, c := range cands { + if c.ID != overCand.ID { + require.NoError(t, store.MarkModuleTimerIncluded(ctx, c.ID)) + } + } + cands2, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-06-20T00:00:00Z")) + require.NoError(t, err) + require.Empty(t, cands2, "resolved timers drop out of the work list") + + // Resolved rows stay LIVE (removed_at NULL) — resolution ≠ removal. + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 6, n) + + // LIFO soft-removal: add 3 more, remove the newest 2 → 6 + 3 − 2 = 7 live. + late := mustTime(t, "2026-07-01T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, app, late, late.AddDate(0, 0, 3), 3)) + require.NoError(t, store.SoftRemoveNewestModuleTimers(ctx, app, 2, late)) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 7, n) + + // SoftRemoveAll clears every remaining live timer. + require.NoError(t, store.SoftRemoveAllModuleTimersForApp(ctx, app, late)) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n) +} + +// Regression (review 2026-07-06, H7): timer synthesis is a count-then-insert +// reconcile in a fire-and-forget-with-retry RPC environment — two CONCURRENT +// executions for the same app used to both read the same live count and both +// insert the full deficit, minting phantom timers wrongfully charged $3 each at +// every boundary. ReconcileModuleTimersToTarget serializes per app under a +// pg_advisory_xact_lock; hammer it concurrently and assert the invariant. +func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + app := uuid.New() + created := mustTime(t, "2026-06-19T12:00:00Z") + require.NoError(t, store.InsertAppMirror(ctx, app, acct, 7, created)) + + const workers = 8 + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + go func() { + errs <- store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), created) + }() + } + for i := 0; i < workers; i++ { + require.NoError(t, <-errs) + } + + n, err := store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 7, n, "concurrent reconciles must never double-insert the deficit") + + // The target is the roster row's CURRENT count read under the lock (wave 2, + // D8): shrink the row, reconcile, and the set converges — a stale caller + // can no longer impose an outdated target. + require.NoError(t, store.SetAppModuleCount(ctx, app, 3)) + require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-20T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Equal(t, 3, n) + + // D9: a deleted row reconciles to ZERO — a late synthesis retry removes + // orphans instead of resurrecting timers, and the locked delete removes + // everything atomically to begin with. + require.NoError(t, store.MarkAppDeletedAndRemoveTimers(ctx, app, mustTime(t, "2026-06-21T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n) + require.NoError(t, store.ReconcileModuleTimersToTarget(ctx, app, created, created.AddDate(0, 0, 3), mustTime(t, "2026-06-21T00:00:00Z"))) + n, err = store.LiveModuleTimerCountForApp(ctx, app) + require.NoError(t, err) + require.Zero(t, n, "a deleted app's reconcile target is zero — no resurrection") +} + +// Stage B: the row_number()-windowed reads backing scenario 3 (CoCreatedOverModuleTimers), +// scenario 6 / Leg 2 (CountOngoingOverModuleTimers), and the display +// (CountLiveModuleTimersForAccount) — validated against real Postgres, since the +// unit fakes only approximate the FIFO window function in Go. +func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + _, err := pool.Exec(ctx, `UPDATE ms_billing.accounts SET activated_at = $2 WHERE id = $1`, + acct.String(), mustTime(t, "2026-05-04T00:00:00Z")) + require.NoError(t, err) + + appA, appB := uuid.New(), uuid.New() + created := mustTime(t, "2026-06-19T12:00:00Z") + require.NoError(t, store.InsertAppMirror(ctx, appA, acct, 7, created)) + require.NoError(t, store.InsertAppMirror(ctx, appB, acct, 0, created)) + + // appA: 7 co-created install timers at created_at → FIFO ranks 0-6, so 2 are + // "over" (rank ≥ IncludedModules=5). + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appA, created, created.AddDate(0, 0, 3), 7)) + + // CountLiveModuleTimersForAccount (via the usage store, the display's real + // consumer) = the account-overage over-count input (7 live). + usageStore := usage.NewStore(pool) + liveN, err := usageStore.LiveModuleTimerCountForAccount(ctx, acct) + require.NoError(t, err) + require.Equal(t, 7, liveN) + + // CoCreatedOverModuleTimers: the 2 co-created over-modules (rank ≥ 5), unresolved. + over, err := store.CoCreatedOverModuleTimers(ctx, acct, appA, created, usage.IncludedModules) + require.NoError(t, err) + require.Len(t, over, 2, "7 co-created → 2 over the included 5") + + // The boundary that opens the account's [Jul 4, Aug 4) period (anchor day 4). + newPeriodStart := mustTime(t, "2026-07-04T00:00:00Z") + + // The 2 over-rank timers (installed Jun 19, grace expired Jun 22) are + // "ongoing" for the Jul-4 boundary EVEN BEFORE any sweep resolves them — + // the predicate keys on immutable timestamps only (wave 2, D1: keying on + // grace_resolved made the precharge depend on cron ordering and gapped a + // period for timers resolved after their covering boundary run). + ongoing, err := store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 2, ongoing, "expired-but-unresolved over-modules are already ongoing") + + // Charging them (scenario 3) does not change the count — resolution is not + // part of the predicate. + for _, id := range over { + require.NoError(t, store.MarkModuleTimerCharged(ctx, id, created.AddDate(0, 0, 3), "in_x", "ii_x")) + } + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 2, ongoing, "the 2 charged over-modules are ongoing") + // And they're no longer co-created candidates (grace_resolved now true). + over, err = store.CoCreatedOverModuleTimers(ctx, acct, appA, created, usage.IncludedModules) + require.NoError(t, err) + require.Empty(t, over) + + // installed_at < period_end cutoff (review 2026-07-06, H1): at a RECLAIMED + // earlier boundary (new period opening Jun 4, BEFORE these Jun-19 installs) + // the charged over-modules must NOT be counted — their own grace charge + // covered the period they were installed into; precharging it again is the + // reclaimed skipped_no_pm/failed-run double-charge. + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, mustTime(t, "2026-06-04T00:00:00Z")) + require.NoError(t, err) + require.Zero(t, ongoing, "a module installed inside the new period is never precharged for it") + + // appB adds one LATER over-module (rank 7) whose grace ELAPSED before the + // Jul 4 boundary (installed Jun 28, expires Jul 1): under the wave-2 D1 + // predicate (immutable cutoffs only) it is ongoing even while UNRESOLVED — + // resolution state is deliberately not part of the count. + late := mustTime(t, "2026-06-28T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, late, late.AddDate(0, 0, 3), 1)) + liveN, err = usageStore.LiveModuleTimerCountForAccount(ctx, acct) + require.NoError(t, err) + require.Equal(t, 8, liveN) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 3, ongoing, "an expired-but-unresolved over-module IS ongoing (D1 — the count never depends on sweep ordering)") + + // D1d resolved-WITHOUT-charge (review 2026-07-06, C1): resolving the rank-7 + // timer uncharged (the period-closed posture) keeps it "ongoing" — the old + // grace_charged_at IS NOT NULL proxy exempted such modules from ALL overage + // billing forever. + lateCands, err := store.ModuleOverageTimersPastGrace(ctx, mustTime(t, "2026-07-02T00:00:00Z")) + require.NoError(t, err) + var rank7 cycle.ModuleOverageCandidate + for _, c := range lateCands { + if c.AppID == appB { + rank7 = c + } + } + require.NotEqual(t, uuid.Nil, rank7.ID, "the rank-7 appB timer is in the late work list") + require.NoError(t, store.MarkModuleTimerIncluded(ctx, rank7.ID)) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 3, ongoing, "a resolved-uncharged (D1d) over-module still owes every later period") + + // grace_expires_at < period_end cutoff (review 2026-07-06, M1): an + // over-module whose grace STRADDLES the boundary (installed Jul 3, expiry + // Jul 6 >= Jul 4) is excluded — its own Leg 1 charge covers the new period + // (coverage runs through the END of the period its grace elapses into). + straddle := mustTime(t, "2026-07-03T00:00:00Z") + require.NoError(t, store.InsertModuleOverageTimers(ctx, acct, appB, straddle, straddle.AddDate(0, 0, 3), 1)) + ongoing, err = store.CountOngoingOverModuleTimers(ctx, acct, usage.IncludedModules, newPeriodStart) + require.NoError(t, err) + require.Equal(t, 3, ongoing, "a boundary-straddling grace is Leg 1's coverage, never the precharge's — resolved or not") +} diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go new file mode 100644 index 0000000..340132a --- /dev/null +++ b/internal/account/cycle/overage.go @@ -0,0 +1,564 @@ +package cycle + +// Per-module-instance overage — Leg 1: the per-module grace charge (migration +// 033, DESIGN.md "Base fee — v2: creation grace + per-module overage timers"). +// This REPLACES the account-wide single-timer pooled overage sweep entirely. +// +// The model: overage is no longer ONE account-level grace timer tiering on +// SUM(module_count); it is ONE independently-anchored 3-day grace timer per +// module INSTALL EVENT (ms_billing.app_module_overage_timers), each priced from +// its OWN install date. RegisterApp / SyncAppModules synthesize the timer rows +// (the RPC layer carries only an integer module_count); this file owns the +// mid-period sweep that charges them: +// +// - SweepModuleOverage lists the live, unresolved timers whose grace has +// elapsed (ModuleOverageTimersPastGrace) and runs ChargeModuleOverage on each. +// - ChargeModuleOverage determines, LIVE and fresh at every grace-check (never +// cached), whether the install is "included" or "over": across the account's +// currently-live timers ordered (installed_at ASC, id ASC), the first +// IncludedModules (5) are included, the rest are over. +// * included → mark grace_resolved, never charge. Monotonicity (a new +// install always gets the latest installed_at, so an existing row's rank +// can only improve over→included, never included→over) makes this a +// PERMANENT verdict — the row is never re-evaluated. +// * over → charge ModuleOverageFeeMicros ($3) prorated from the install's +// UTC day to the install period's end (install-anchored — the correction +// vs. the prior account-wide attempt, which anchored to grace-elapse), +// via a per-timer Stripe invoice with deterministic idem keys derived +// from the timer id, then stamp grace_charged_at / grace_resolved and the +// GENUINE Stripe ids. +// +// The boundary per-module overage precharge for ongoing modules (scenario 6) and +// the combined creation-invoice overage line (scenario 3) are Stage B follow-ups. + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" +) + +// moduleOverageGraceWindow is the per-install grace window: a module's own timer +// starts at its install instant and its overage is only charged once this window +// has elapsed (owner spec 2026-07-05: 3 days, the SAME GraceDays as the creation +// grace). A module removed before its own grace elapses is never charged. +const moduleOverageGraceWindow = usage.GraceDays * 24 * time.Hour + +// moduleGraceExpiry is the single home of the "grace_expires_at = installed_at + +// window" rule, used by RegisterApp / SyncAppModules when they synthesize timer +// rows and (implicitly, via the stored column) by the sweep's work-list. +func moduleGraceExpiry(installedAt time.Time) time.Time { + return installedAt.Add(moduleOverageGraceWindow) +} + +// ModuleOverageStatus is the terminal classification of one ChargeModuleOverage +// attempt, for the sweep's tally + per-timer log line. +type ModuleOverageStatus string + +const ( + // ModuleOverageCharged: the install was "over" and its overage was invoiced. + ModuleOverageCharged ModuleOverageStatus = "charged" + // ModuleOverageIncluded: the live FIFO put the install within the included + // IncludedModules — resolved permanently, never charged. + ModuleOverageIncluded ModuleOverageStatus = "included" + // ModuleOverageSkippedNoPM: "over" but no usable default PM — left unresolved, + // re-attempted on the next sweep through the SAME per-timer idem keys. + ModuleOverageSkippedNoPM ModuleOverageStatus = "skipped_no_pm" + // ModuleOverageSkippedPrepaid: "over" but the account is in PREPAID collection + // mode — off-session auto-charges are not permitted (H10, the same gate the + // boundary spine applies). Left unresolved and re-attempted: a webhook-driven + // relax back to arrears lets the deferred charge fire through the same keys. + ModuleOverageSkippedPrepaid ModuleOverageStatus = "skipped_prepaid" + // ModuleOverageSkippedZeroCents: "over" but the prorated overage rounded to 0 + // cents (unreachable for a real ≥1-day over module at $3) — resolved with no + // charge so it never re-sweeps forever. + ModuleOverageSkippedZeroCents ModuleOverageStatus = "zero_cents" + // ModuleOveragePeriodClosed: "over" but the account only activated AT OR AFTER + // this install's anchored period had already closed — charging now would be a + // retroactive catch-up (D1d), so it is resolved terminally WITH NO charge (the + // per-module analogue of ProrationStatusPeriodClosed on the creation leg). + ModuleOveragePeriodClosed ModuleOverageStatus = "period_closed" + // ModuleOverageDeferredToCombined: "over" AND co-created with an app whose + // creation proration has not yet resolved — this timer's overage belongs on the + // app's ONE combined creation invoice (scenario 3), not a separate Leg 1 one, so + // it is DEFERRED (left unresolved) for the proration sweep to charge and mark. + ModuleOverageDeferredToCombined ModuleOverageStatus = "deferred_to_combined" + // ModuleOverageSkippedStale: the charge-time re-verification (M2) found the + // timer no longer live/unresolved — removed, or resolved by a concurrent + // sweep — between the work-list read and this candidate's turn. Nothing done. + ModuleOverageSkippedStale ModuleOverageStatus = "skipped_stale" +) + +// ModuleOverageResult reports what one ChargeModuleOverage call did. +type ModuleOverageResult struct { + TimerID uuid.UUID + Status ModuleOverageStatus + ChargedCents int64 + // StripeInvoiceID is set only when Status == ModuleOverageCharged. + StripeInvoiceID string +} + +// moduleOverageChargeShape resolves the deterministic charge for one timer +// under the 2026-07-06 coverage contract — install day → the END of the period +// the grace elapses into (install period prorated + the straddled period in +// full when the grace crosses the boundary) — INCLUDING the D1d decision +// (wave 2, D4): the pre-activation install period is forgiven, but a grace +// that straddles into a period the account WAS activated during leaves that +// straddled period chargeable — it is billed in full with the coverage window +// narrowed to it; only when the ENTIRE coverage is pre-activation-closed is +// the timer fully forgiven. Every input (installed_at, grace_expires_at, +// activation anchor) is immutable, so a fresh charge, an idem-key replay, and +// the post-idem-key-window recovery path all recompute the identical shape — +// one home for the math keeps the three from drifting. +func moduleOverageChargeShape(cand ModuleOverageCandidate) (proratedMicros int64, coverageStart, coverageEnd time.Time, fullyForgiven bool) { + periodStart, periodEnd, closed := periodClosedByActivation(cand.InstalledAt, cand.ActivatedAt) + coverageStart = usage.ProrationCoverageStart(cand.InstalledAt, periodStart) + coverageEnd = periodEnd + proratedMicros = usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, cand.InstalledAt, periodStart, periodEnd) + if !cand.GraceExpiresAt.Before(periodEnd) { + _, coverageEnd = billingperiod.AnchoredPeriodWindow(cand.GraceExpiresAt.UTC(), billingperiod.AnchorDay(cand.ActivatedAt)) + proratedMicros += usage.ModuleOverageFeeMicros + } + if closed { + if coverageEnd.After(periodEnd) && cand.ActivatedAt.Before(coverageEnd) { + // D1d forgives only the install period; the straddled period is + // post-activation and owed in full. + proratedMicros = usage.ModuleOverageFeeMicros + coverageStart = periodEnd + return proratedMicros, coverageStart, coverageEnd, false + } + return 0, time.Time{}, time.Time{}, true + } + return proratedMicros, coverageStart, coverageEnd, false +} + +// ChargeModuleOverage evaluates + (if "over") charges ONE per-module install +// timer whose grace has elapsed. Gated on the collection mode and a usable +// default PM exactly like the proration leg (the candidate account is already +// activated — the work-list query filters activated_at IS NOT NULL). +// Idempotent + race-safe WITHOUT a lock, via three layers: +// +// - the grace_resolved first-write-wins guard records the terminal verdict; +// - the deterministic per-timer Stripe Idempotency-Keys dedupe the charge +// across SHORT-window retries (a crash between Stripe succeeding and the +// mark committing resumes through the SAME keys); +// - the migration-036 charge_attempted_at marker + the ms_charge_ref +// metadata anchor cover retries PAST Stripe's ~24h idempotency-key window +// (H5): an attempted candidate reconciles against what Stripe actually has +// BEFORE recomputing any live verdict — so money moved by a crashed +// attempt is mirrored+marked even if the timer's FIFO rank has since +// improved to "included" (H9), and a pruned-key retry never mints a second +// set of Stripe objects. +func (s *Service) ChargeModuleOverage(ctx context.Context, cand ModuleOverageCandidate, at time.Time) (*ModuleOverageResult, error) { + if cand.ID == uuid.Nil { + return nil, billing.InvalidInput("timer id required") + } + if s.stripe == nil { + return nil, billing.Internal("ChargeModuleOverage requires a Stripe client", nil) + } + res := &ModuleOverageResult{TimerID: cand.ID} + + // CHARGE-TIME RE-VERIFICATION (review 2026-07-06, M2): the sweep's work list + // was read once and this candidate may be minutes stale — re-check live + + // unresolved immediately before acting, so a module removed (or resolved by + // a concurrent sweep) mid-batch is not charged. + pending, err := s.store.ModuleTimerStillPending(ctx, cand.ID) + if err != nil { + return nil, billing.Internal("module timer pending re-check failed", err) + } + if !pending { + res.Status = ModuleOverageSkippedStale + return res, nil + } + + // CRASH RECOVERY (review 2026-07-06, H5/H9) — BEFORE the live FIFO verdict. + // A set charge_attempted_at means a prior attempt reached its Stripe section + // and may have moved money before crashing short of the mark. Reconcile + // against Stripe by the ms_charge_ref anchor: whatever is found is finished + // (mirrored + marked) regardless of what the timer's rank says NOW — a rank + // that improved over→included since the crash must not orphan a real charge. + // Nothing found ⇒ the crashed attempt never created its invoice; fall + // through and charge fresh. + if !cand.ChargeAttemptedAt.IsZero() { + recovered, err := s.recoverModuleOverageCharge(ctx, cand, at, res) + if err != nil { + return nil, err + } + if recovered { + return res, nil + } + } + + // LIVE FIFO determination, computed fresh (never cached): this install's rank + // among the account's currently-live timers ordered (installed_at, id). + rank, err := s.store.LiveModuleTimerRankBefore(ctx, cand.AccountID, cand.ID, cand.InstalledAt) + if err != nil { + return nil, billing.Internal("module timer FIFO rank lookup failed", err) + } + if rank < usage.IncludedModules { + // Included → PERMANENT verdict (monotonicity: an existing row's rank only + // ever improves over→included, never the reverse). Mark resolved, never + // charge, never re-check. + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer included failed", err) + } + res.Status = ModuleOverageIncluded + return res, nil + } + + // "Over": price $3 prorated from the install's UTC day over the install's own + // anchored period (ADR 0005 anchor from activation) — install-anchored, NOT + // grace-elapse-anchored and NOT now-anchored — plus, for a grace that + // STRADDLES the period boundary, the full fee for the period the grace + // elapses into (moduleOverageChargeShape; the boundary precharge deliberately + // excludes straddlers via its grace_expires_at < period_end cutoff, so that + // period is THIS leg's to bill, and only from the boundary after that does + // the precharge take over). + proratedMicros, coverageStart, coverageEnd, fullyForgiven := moduleOverageChargeShape(cand) + + // D1d — no retroactive catch-up (the SAME posture ChargeCreationProration + // enforces on the creation leg, proration.go). RegisterApp synthesizes an app's + // timers at install time regardless of whether the owning account has activated + // yet, and the work-list only gates on activation at CHARGE time — so a timer + // can sit installed + past-grace for arbitrarily long while unactivated, then + // get swept the instant the account finally binds a card. If the account only + // activated AT OR AFTER the timer's ENTIRE coverage had already closed, the + // account was never chargeable for any of it; charging now — however late the + // sweep runs — is exactly the retroactive catch-up D1d forbids. Resolve the + // timer terminally WITHOUT charging (grace_resolved, first-write-wins via + // MarkModuleTimerIncluded) so it never resurfaces, rather than minting a + // historical, never-chargeable invoice. Compared against ActivatedAt, NOT + // `at`: an ordinary late sweep on a HEALTHY already-activated account (grace + // pushing the charge a few days past periodEnd) still charges. A grace that + // straddles into a post-activation period is NOT fully forgiven (wave 2, D4) + // — the shape narrows the charge to that straddled period in full; only the + // pre-activation install period is ever forgiven. + if fullyForgiven { + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer resolved (period closed) failed", err) + } + res.Status = ModuleOveragePeriodClosed + return res, nil + } + + // Scenario 3 combined-invoice ownership guard. A co-created over-module timer + // (installed AT the app's created_at) whose app's creation proration is still + // UNRESOLVED is the COMBINED-invoice path's responsibility (proration.go), NOT + // Leg 1's: the creation-proration charge pins this timer's overage line onto + // the app's ONE creation invoice (app-inv-) using the SHARED per-timer + // item key. cmd/billing-cycle runs the proration sweep BEFORE this one so the + // happy path resolves these timers first (they never reach here). But if that + // sweep's persist phase FAILED after its Stripe calls already finalized the + // combined invoice (money moved, lines pinned), the timer is still unresolved + // when this sweep runs in the SAME process — and minting our OWN invoice + // (mod-overage-inv-) here would double-charge overage the combined + // invoice already collected. So DEFER (skip WITHOUT resolving): the proration + // sweep retries every cycle and converges on the SAME combined invoice via + // the deterministic keys, then marks this timer resolved, dropping it from + // this work list. A LATER install (installed_at != created_at) is never + // co-created, so it charges here normally. + app, found, err := s.store.AppMirror(ctx, cand.AppID) + if err != nil { + return nil, billing.Internal("app mirror lookup failed", err) + } + if found && cand.InstalledAt.Equal(app.CreatedAt) && + app.ProrationInvoiceID == "" && !app.ProrationSkipped && !app.Deleted { + res.Status = ModuleOverageDeferredToCombined + return res, nil + } + + // Coverage (review 2026-07-06 contract, moduleOverageCoverage): install day → + // the END of the period the grace ELAPSES INTO — install period prorated + + // the straddled period in full when the grace crosses the boundary. Both + // inputs (installed_at, activation anchor) are immutable, so the amount stays + // deterministic across retries — the per-timer Stripe idem keys stay stable. + // The precharge picks the timer up from the FIRST boundary after its grace + // elapsed, so coverage is complete and disjoint by construction. + cents, err := centsFromMicros(proratedMicros) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) + } + if cents == 0 { + // Rounds to 0 cents — resolve with no charge so the sweep never revisits it + // forever (grace_resolved would otherwise stay false). + if err := s.store.MarkModuleTimerIncluded(ctx, cand.ID); err != nil { + return nil, billing.Internal("mark module timer resolved (zero cents) failed", err) + } + res.Status = ModuleOverageSkippedZeroCents + return res, nil + } + res.ChargedCents = cents + + // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is never + // auto-charged off-session by ANY leg. Skip WITHOUT resolving — a relax back + // to arrears re-attempts through the same keys. + if permitted, err := s.offSessionChargePermitted(ctx, cand.AccountID); err != nil { + return nil, err + } else if !permitted { + res.Status = ModuleOverageSkippedPrepaid + return res, nil + } + + // PM gate (same posture as the proration leg): no usable PM → skip WITHOUT + // resolving, re-attempted next sweep (the per-timer idem keys stay stable). + custID, ok, err := s.resolveChargeableCustomer(ctx, cand.AccountID) + if err != nil { + return nil, err + } + if !ok { + res.Status = ModuleOverageSkippedNoPM + return res, nil + } + + // Stamp the migration-036 recovery marker BEFORE the first Stripe call + // (first-write-wins): from here on, any retry — however late — reconciles + // against Stripe rather than trusting a recomputed live verdict. + if err := s.store.MarkModuleTimerChargeAttempted(ctx, cand.ID, at.UTC()); err != nil { + return nil, billing.Internal("mark module timer charge attempted failed", err) + } + + // Charge via a per-timer draft→pinned-item→finalize flow with deterministic + // idem keys derived from the timer id (the stable charge identity — each + // install charges at most once, the grace_resolved guard), so a crash-retry + // reuses the SAME Stripe objects. The item is PINNED to this timer's own + // draft (C2 — a floating pending item could be swept onto another leg's + // invoice); only the finalize step moves money. + desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) + draft, err := s.stripe.CreateDraftInvoice(ctx, custID, moduleOverageChargeRef(cand.ID), moduleOverageInvoiceIdemKey(cand.ID)) + if err != nil { + return nil, billing.StripeError("module overage draft invoice failed", err) + } + item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)) + if err != nil { + return nil, billing.StripeError("module overage invoice item failed", err) + } + inv, err := s.stripe.FinalizeInvoice(ctx, draft.ID, moduleOverageFinalizeIdemKey(cand.ID)) + if err != nil { + return nil, billing.StripeError("module overage invoice finalize failed", err) + } + + // Resolve the large-charge disclosure threshold AT CHARGE TIME, immediately + // after Stripe confirms (scenario 5 / migration 034) — the SAME resolution + // point every off-session charge site uses. + acct, err := s.store.AccountCollection(ctx, cand.AccountID) + if err != nil { + return nil, billing.Internal("account collection lookup failed", err) + } + + if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ + AccountID: cand.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + // The coverage window the shape priced — [install day, coverage end) in + // the normal case; narrowed to the straddled period alone under the D1d + // straddle rule — so the mirrored window and the charged amount agree by + // construction. + PeriodStart: coverageStart, + PeriodEnd: coverageEnd, + IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), + }); err != nil { + return nil, billing.Internal("invoice mirror upsert failed", err) + } + + // Stamp the terminal "over and charged" verdict with the GENUINE Stripe ids + // (item.ID — never the idempotency-key string). + if err := s.store.MarkModuleTimerCharged(ctx, cand.ID, at.UTC(), inv.ID, item.ID); err != nil { + return nil, billing.Internal("mark module timer charged failed", err) + } + + res.Status = ModuleOverageCharged + res.StripeInvoiceID = inv.ID + return res, nil +} + +// SweepModuleOverageResult tallies one SweepModuleOverage batch for the +// cmd/billing-cycle log line + exit code. +type SweepModuleOverageResult struct { + Pending int // live unresolved timers past grace (the work-list size) + Charged int // over-modules invoiced this sweep + Included int // resolved-as-included (no charge) + Skipped int // no-PM / 0-cent (left for next sweep, or resolved zero) + Failed int // per-timer errors — retried next sweep, never abort the batch +} + +// SweepModuleOverage charges (or resolves) every per-module install timer whose +// grace has elapsed as of `at`. Idempotent + resumable: a resolved timer drops +// out of the work list (grace_resolved), and a per-timer failure is counted but +// never aborts the batch (the next sweep retries it through the same +// deterministic Stripe keys). +func (s *Service) SweepModuleOverage(ctx context.Context, at time.Time) (*SweepModuleOverageResult, error) { + if at.IsZero() { + return nil, billing.InvalidInput("sweep instant required") + } + cands, err := s.store.ModuleOverageTimersPastGrace(ctx, at.UTC()) + if err != nil { + return nil, billing.Internal("list module overage timers past grace failed", err) + } + res := &SweepModuleOverageResult{Pending: len(cands)} + for _, c := range cands { + r, err := s.ChargeModuleOverage(ctx, c, at) + if err != nil { + slog.ErrorContext(ctx, "module overage charge failed", + "timer_id", c.ID, "app_id", c.AppID, "error", err) + res.Failed++ + continue + } + switch r.Status { + case ModuleOverageCharged: + res.Charged++ + case ModuleOverageIncluded: + res.Included++ + default: + res.Skipped++ + } + slog.InfoContext(ctx, "module overage grace sweep", + "timer_id", c.ID, "app_id", c.AppID, "status", string(r.Status), + "charged_cents", r.ChargedCents, "stripe_invoice_id", r.StripeInvoiceID) + } + return res, nil +} + +// moduleOverageItemIdemKey / moduleOverageInvoiceIdemKey build the deterministic +// per-timer Stripe Idempotency-Keys for the Leg 1 overage charge. The timer id is +// the stable charge identity (each install charges at most once — the +// grace_resolved guard), so a re-attempt (a retried sweep after a crash between +// the Stripe call and the mark) reuses the SAME Stripe objects and can never +// double-charge even before the row is marked resolved. +// recoverModuleOverageCharge is the H5/H9 recovery path for a candidate whose +// charge_attempted_at marker is set: look the timer's invoice up on Stripe by +// its ms_charge_ref anchor and finish whatever the crashed attempt left — +// finalized invoice → mirror + mark; inert draft → complete it (attach the +// deterministic line if it never landed, then finalize) → mirror + mark. +// Returns recovered=false when Stripe has nothing under the ref (the crashed +// attempt never created its invoice — the caller charges fresh). The PM gate +// is deliberately NOT applied: reconciling possibly-moved money must never be +// blocked by a PM removed after the crash (an idem/finalize failure lands in +// the sweep's retried-error path instead). +func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOverageCandidate, at time.Time, res *ModuleOverageResult) (bool, error) { + custID, err := s.store.AccountStripeCustomer(ctx, cand.AccountID) + if err != nil { + return false, billing.Internal("stripe customer lookup failed (module overage recovery)", err) + } + if custID == "" { + // No Customer ⇒ no prior attempt could have created Stripe objects. + return false, nil + } + found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, moduleOverageChargeRef(cand.ID)) + if err != nil { + return false, billing.StripeError("module overage recovery lookup failed", err) + } + if !ok { + return false, nil + } + if found.Status == "void" { + // The charge was CANCELED (support voided it during an incident) — + // adopting it as 'charged' would silently forgive the overage and + // terminally consume the timer's charge identity. Fail loudly into the + // sweep's retried-error path so ops decides. + return false, billing.Internal(fmt.Sprintf( + "module overage recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (timer %s needs ops resolution)", + found.ID, moduleOverageChargeRef(cand.ID), cand.ID), nil) + } + + proratedMicros, coverageStart, coverageEnd, fullyForgiven := moduleOverageChargeShape(cand) + if fullyForgiven { + // The shape is deterministic, so an attempted timer can only be fully + // forgiven if the attempt itself would have been — nothing chargeable to + // recover; the caller's fresh path resolves it period_closed. + return false, nil + } + cents, err := centsFromMicros(proratedMicros) + if err != nil { + return false, billing.Internal("micros to cents conversion failed", err) + } + + inv := found + if found.Status == "draft" { + // An inert draft moved NO money — finalizing it is a fresh off-session + // debit, so the prepaid collection gate applies exactly as on a first + // attempt (wave 2, D6). Skip WITHOUT resolving; the draft stays inert + // and a relax back to arrears completes it through the same keys. + if permitted, err := s.offSessionChargePermitted(ctx, cand.AccountID); err != nil { + return false, err + } else if !permitted { + res.Status = ModuleOverageSkippedPrepaid + return true, nil + } + // The crashed attempt never finalized. Complete ITS draft — never mint a + // second one. The line either never landed (AmountDue 0 → attach it, with + // the deterministic amount the crashed attempt would have used) or already + // did (AmountDue == cents → just finalize). Anything else means the draft + // was tampered with or the deterministic math changed — refuse loudly. + switch found.AmountDue { + case 0: + desc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", cand.AppID) + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, found.ID, cents, chargeCurrency, desc, moduleOverageItemIdemKey(cand.ID)); err != nil { + return false, billing.StripeError("module overage recovery invoice item failed", err) + } + case cents: + // line already attached — nothing to add + default: + return false, billing.Internal(fmt.Sprintf( + "module overage recovery: draft %s carries %d cents but the deterministic amount is %d — refusing to finalize a mismatched draft (timer %s)", + found.ID, found.AmountDue, cents, cand.ID), nil) + } + inv, err = s.stripe.FinalizeInvoice(ctx, found.ID, moduleOverageFinalizeIdemKey(cand.ID)) + if err != nil { + return false, billing.StripeError("module overage recovery finalize failed", err) + } + } + + // Mirror + mark exactly like the fresh path. The invoice-item id is unknown + // on recovery (the search projection carries the invoice only) — the mark + // stores NULL for it, with the genuine invoice id as the correlation anchor. + acct, err := s.store.AccountCollection(ctx, cand.AccountID) + if err != nil { + return false, billing.Internal("account collection lookup failed", err) + } + if err := s.store.UpsertInvoice(ctx, InvoiceMirror{ + AccountID: cand.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: coverageStart, + PeriodEnd: coverageEnd, + IsLargeAutoCollect: flagLargeAutoCollect(proratedMicros, acct), + }); err != nil { + return false, billing.Internal("invoice mirror upsert failed (module overage recovery)", err) + } + if err := s.store.MarkModuleTimerCharged(ctx, cand.ID, at.UTC(), inv.ID, ""); err != nil { + return false, billing.Internal("mark module timer charged failed (module overage recovery)", err) + } + + res.Status = ModuleOverageCharged + res.ChargedCents = inv.AmountDue + res.StripeInvoiceID = inv.ID + return true, nil +} + +// moduleOverageChargeRef is the deterministic ms_charge_ref metadata anchor for +// one timer's Leg-1 invoice — what FindInvoiceByRef recovers by. +func moduleOverageChargeRef(timerID uuid.UUID) string { return "timer:" + timerID.String() } + +func moduleOverageItemIdemKey(timerID uuid.UUID) string { + return "mod-overage-ii-" + timerID.String() +} + +func moduleOverageInvoiceIdemKey(timerID uuid.UUID) string { + return "mod-overage-inv-" + timerID.String() +} + +func moduleOverageFinalizeIdemKey(timerID uuid.UUID) string { + return "mod-overage-fin-" + timerID.String() +} diff --git a/internal/account/cycle/overage_test.go b/internal/account/cycle/overage_test.go new file mode 100644 index 0000000..5bc9fd5 --- /dev/null +++ b/internal/account/cycle/overage_test.go @@ -0,0 +1,693 @@ +package cycle_test + +// Per-module-instance overage — Leg 1 (migration 033): the per-module grace +// charge sweep (SweepModuleOverage / ChargeModuleOverage), the LIVE FIFO +// included-vs-over determination, the install-anchored proration, and the FIFO +// monotonicity / permanent-inclusion property. Reuses the in-memory fakeStore +// (service_test.go) + fakeStripe (charge_test.go) + the registeredAccount / +// registerMirror / appsSvc helpers (apps_test.go / proration_test.go). + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" +) + +// seedTimer inserts one live, unresolved install timer directly into the fake +// (bypassing RegisterApp so a test can pin exact install dates), grace expiring +// at install + 3 days, and returns its id. +func seedTimer(store *fakeStore, accountID, appID uuid.UUID, installedAt time.Time) uuid.UUID { + id := uuid.New() + store.timers[id] = &fakeTimer{ + id: id, + accountID: accountID, + appID: appID, + installedAt: installedAt, + graceExpiresAt: installedAt.AddDate(0, 0, 3), + } + return id +} + +// liveTimerCount counts an app's currently-live (not-removed) install timers. +func liveTimerCount(store *fakeStore, appID uuid.UUID) int { + n := 0 + for _, t := range store.timers { + if t.appID == appID && !t.removed { + n++ + } + } + return n +} + +// seedIncluded seeds n live install timers already resolved-as-included at the +// SAME (earliest) install instant, so they occupy the included FIFO slots and +// stay out of the sweep's work list. +func seedIncluded(store *fakeStore, accountID, appID uuid.UUID, installedAt time.Time, n int) { + for i := 0; i < n; i++ { + id := seedTimer(store, accountID, appID, installedAt) + store.timers[id].graceResolved = true + } +} + +// Scenario 4 (pool crosses 5 later, one module at a time → two independent +// prorated charges on different days) lives in the end-to-end scenario suite +// (scenarios_test.go, TestScenario4_PoolCrossesFiveLaterPerModuleTimers). This +// file keeps the Leg-1 PROPERTY tests (FIFO monotonicity, over→included flips, +// removed-in-grace, no-PM retry, unactivated) the scenario suite doesn't repeat. + +// --- FIFO monotonicity: an included module is a PERMANENT verdict ------------- + +func TestModuleOverage_IncludedIsPermanentNeverReEvaluated(t *testing.T) { + // Once a grace-check finds a module "included", that verdict is permanent + // (grace_resolved) — it is never re-checked and never charged, even after the + // pool later grows well past the included 5. Monotonicity: a new install + // always gets the latest installed_at, so an existing row's rank can only + // improve (over→included), never regress (included→over). + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + // 5 early installs — all "included" (ranks 0-4), initially UNRESOLVED. + early := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) + var earlyIDs []uuid.UUID + for i := 0; i < 5; i++ { + earlyIDs = append(earlyIDs, seedTimer(store, acct, app, early)) + } + + // First sweep past their grace → all 5 resolved as included, none charged. + res, err := svc.SweepModuleOverage(ctx, early.AddDate(0, 0, 4)) + require.NoError(t, err) + require.Equal(t, 5, res.Pending) + require.Equal(t, 5, res.Included) + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls, "included modules are never charged") + for _, id := range earlyIDs { + require.True(t, store.timers[id].graceResolved) + require.False(t, store.timers[id].graceCharged) + } + + // Now install 10 MORE modules a month later — the pool jumps to 15, but the 5 + // early installs keep ranks 0-4 (monotonicity); the newcomers are "over". + late := time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC) + for i := 0; i < 10; i++ { + seedTimer(store, acct, app, late) + } + + // Second sweep → only the 10 unresolved newcomers are candidates and charged; + // the 5 already-included ones are NOT re-evaluated and stay uncharged forever. + res2, err := svc.SweepModuleOverage(ctx, late.AddDate(0, 0, 4)) + require.NoError(t, err) + require.Equal(t, 10, res2.Pending, "only the unresolved newcomers are candidates") + require.Equal(t, 10, res2.Charged) + require.Len(t, sc.itemCalls, 10) + for _, id := range earlyIDs { + require.False(t, store.timers[id].graceCharged, + "an included module is never charged even after the pool grows past 5") + require.True(t, store.timers[id].graceResolved) + } +} + +// --- rank flips over→included before grace elapses → NOT charged -------------- + +func TestModuleOverage_FlipsToIncludedWhenEarlierRemovedBeforeGrace(t *testing.T) { + // A module whose rank flips from "over" to "included" before its own grace + // elapses (because an earlier module was removed) must NOT be charged overage + // — the determination is LIVE at the grace-check. + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + // 5 earlier installs (ranks 0-4) + X installed a day later (rank 5 = over). + early := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) + var earlyIDs []uuid.UUID + for i := 0; i < 5; i++ { + earlyIDs = append(earlyIDs, seedTimer(store, acct, app, early)) + } + x := seedTimer(store, acct, app, time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)) + + // One earlier module is removed BEFORE X's grace elapses → X's live rank + // improves to 4 → included. + store.timers[earlyIDs[0]].removed = true + store.timers[earlyIDs[0]].removedAt = time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC) + + // Sweep past all remaining graces → X (and the 4 remaining early installs) are + // all resolved as included; nothing is charged. + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 5, res.Pending, "the removed module is out of the work list") + require.Equal(t, 5, res.Included) + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls) + require.True(t, store.timers[x].graceResolved) + require.False(t, store.timers[x].graceCharged, "an over→included flip is never charged") +} + +// --- a module removed within its own grace is never charged ------------------- + +func TestModuleOverage_RemovedWithinGraceNeverCharged(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + app := uuid.New() + over := seedTimer(store, acct, app, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + // Removed on day 1 (well within its own 3-day grace). + store.timers[over].removed = true + store.timers[over].removedAt = time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC) + + // A sweep long past grace must still never charge it (removed rows are out of + // the work list). + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "a removed timer is excluded from the sweep") + require.Empty(t, sc.itemCalls) + require.False(t, store.timers[over].graceCharged) +} + +// Regression (wave 2, D4): D1d forgives the PRE-ACTIVATION install period only. +// A pre-activation install whose grace straddles into the first post-activation +// period used to be resolved period_closed uncharged — forgiving the straddled, +// fully-chargeable period along with the install period (and the boundary +// predicate excludes it there too, so nobody billed it). The charge is now +// narrowed to the straddled period in full. +func TestModuleOverage_D1dStraddleChargesThePostActivationPeriod(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), 5) + // Installed May 2 (rank 5, over) — pre-activation, install period + // [Apr 4, May 4) closed by the May 4 activation — but its grace expires + // May 5, INSIDE the first post-activation period [May 4, Jun 4). + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "the straddled post-activation period is owed — only the install period is forgiven") + require.True(t, store.timers[over].graceCharged) + // Full $3 for the straddled period alone, window narrowed to it. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 300, sc.itemCalls[0].amountCfg) + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), inv.PeriodStart) + require.Equal(t, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd) + } +} + +// The complement: a pre-activation install whose ENTIRE coverage (install +// period AND grace) closed before activation stays fully forgiven (D1d). +func TestModuleOverage_D1dFullyPreActivationStaysForgiven(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), 5) + // Installed Apr 10 (rank 5, over) — grace expired Apr 13, both inside the + // pre-activation [Apr 4, May 4) period. + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Zero(t, res.Charged) + require.Empty(t, sc.itemCalls, "a fully pre-activation coverage is never retroactively billed") + require.True(t, store.timers[over].graceResolved, "resolved terminally (period_closed)") + require.False(t, store.timers[over].graceCharged) +} + +// --- grace straddling a period boundary: Leg 1 covers the straddled period ---- + +// Regression (review 2026-07-06, M1): a grace window that straddles a period +// boundary used to leave the straddled period billed by NO leg — Leg 1's comment +// deferred it to the boundary precharge, but the boundary ran BEFORE the grace +// elapsed and (correctly) excluded the unresolved timer. Under the coverage +// contract Leg 1 now charges install day → the END of the period the grace +// elapses into: the install period prorated + the straddled period in full. +func TestModuleOverage_GraceStraddlingBoundaryCoversStraddledPeriod(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04 → anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + // Installed Jul 2 — 2 days before the [Jun 4, Jul 4) period closes — so its + // grace elapses Jul 5, INSIDE the next period [Jul 4, Aug 4). + over := seedTimer(store, acct, app, time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + // $3 × 2/30 days (Jul 2 → Jul 4 of the 30-day install period, round-half-up + // = $0.20) + the FULL $3 for the straddled [Jul 4, Aug 4) period = $3.20. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 320, sc.itemCalls[0].amountCfg, + "install-period proration + the full straddled period") + + // The mirrored window agrees with the amount: coverage runs through the END + // of the period the grace elapsed into. + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC), inv.PeriodStart) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd) + } +} + +// The complement: a grace that elapses INSIDE the install period keeps the +// familiar install-anchored proration — no straddle surcharge. +func TestModuleOverage_GraceInsidePeriodChargesInstallPeriodOnly(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) // anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + // Installed Jun 10; grace elapses Jun 13, well inside [Jun 4, Jul 4). + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + // $3 × 24/30 days (Jun 10 → Jul 4) = $2.40 — and nothing more. + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg) + require.Len(t, store.invoices, 1) + for _, inv := range store.invoices { + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), inv.PeriodEnd, + "no straddle → coverage ends at the install period's end") + } +} + +// Regression (review 2026-07-06, H9): a crash between the Stripe charge and +// MarkModuleTimerCharged, followed by an earlier module's removal, used to let +// the retry recompute the timer's rank as "included" and resolve it uncharged — +// real money moved with no invoice mirror, no disclosure, no mark. With the +// migration-036 attempt marker the retry reconciles against Stripe FIRST: the +// finalized invoice is found by its ms_charge_ref, mirrored, and marked, and +// the (now-improved) live rank never gets to orphan it. +func TestModuleOverage_RetryAfterCrashRecoversChargeEvenWhenRankFlipped(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + // Attempt 1 reached the Stripe section (marker set), charged, and crashed + // before the mark. Its finalized invoice sits on Stripe under the ref. + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + sc.setFindByRef("timer:"+x.String(), billingstripe.Invoice{ID: "in_crashed", Status: "paid", AmountDue: 240, AmountPaid: 240, Currency: "usd"}) + + // Between crash and retry an EARLIER module is removed — x's live rank + // improves to 4 ("included"). + for id, tm := range store.timers { + if tm.installedAt.Equal(time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)) { + store.timers[id].removed = true + store.timers[id].removedAt = time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + break + } + } + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "the moved money is recovered, not orphaned by the rank flip") + require.True(t, store.timers[x].graceCharged) + require.Equal(t, "in_crashed", store.timers[x].graceInvoiceID) + inv, ok := store.invoices["in_crashed"] + require.True(t, ok, "the crashed attempt's invoice is mirrored") + require.EqualValues(t, 240, inv.AmountDueCents) + // H5: no NEW Stripe objects were minted on recovery. + require.Empty(t, sc.invoiceCalls, "no second draft") + require.Empty(t, sc.finalizeCalls, "no second finalize") +} + +// H5, the draft-completion arm: the crashed attempt created its draft but died +// before attaching the line / finalizing. The retry completes THAT draft (the +// deterministic line attached to the FOUND invoice id, then finalized) instead +// of minting a second one — safe even after Stripe pruned the idem keys. +func TestModuleOverage_RetryCompletesCrashedDraftInsteadOfMintingSecond(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + sc.setFindByRef("timer:"+x.String(), billingstripe.Invoice{ID: "in_orphan_draft", Status: "draft", AmountDue: 0, Currency: "usd"}) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.Empty(t, sc.invoiceCalls, "the found draft is completed — never a second CreateDraftInvoice") + require.Len(t, sc.itemCalls, 1) + require.Equal(t, "in_orphan_draft", sc.itemCalls[0].invoiceID, "the line lands on the crashed attempt's own draft") + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg) + require.Len(t, sc.finalizeCalls, 1) + require.Equal(t, "in_orphan_draft", sc.finalizeCalls[0].invoiceID) + require.True(t, store.timers[x].graceCharged) +} + +// The recovery no-op arm: the marker is set but Stripe has NOTHING under the +// ref — the crashed attempt never created its invoice. The retry charges fresh. +func TestModuleOverage_AttemptedButNothingOnStripeChargesFresh(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + store.timers[x].chargeAttemptedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + // sc.findByRef stays nil — not found. + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.NotEmpty(t, sc.findByRefCalls, "the recovery lookup ran") + require.Len(t, sc.invoiceCalls, 1, "nothing to recover → fresh draft→item→finalize") + require.Len(t, sc.finalizeCalls, 1) + require.True(t, store.timers[x].graceCharged) +} + +// Regression (review 2026-07-06, M2): a candidate that went stale between the +// work-list read and its turn — removed, or resolved by a concurrent sweep — +// is re-verified at charge time and never charged. +func TestModuleOverage_StaleCandidateNotCharged(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + x := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + // The candidate as the work list saw it... + cand := cycle.ModuleOverageCandidate{ + ID: x, AccountID: acct, AppID: store.timers[x].appID, + InstalledAt: store.timers[x].installedAt, GraceExpiresAt: store.timers[x].graceExpiresAt, + ActivatedAt: time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC), + } + // ...then the module is uninstalled before its turn in the batch. + store.timers[x].removed = true + store.timers[x].removedAt = time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + res, err := svc.ChargeModuleOverage(ctx, cand, time.Date(2026, 6, 14, 1, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, cycle.ModuleOverageSkippedStale, res.Status) + require.Empty(t, sc.invoiceCalls, "a stale candidate never reaches Stripe") + require.False(t, store.timers[x].graceCharged) +} + +// Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged +// off-session — the boundary spine always gated on this, but the per-module +// grace leg bypassed it entirely. The skip is transient: nothing is resolved, +// and a relax back to arrears charges through the same keys. +func TestModuleOverage_PrepaidAccountSkippedNotCharged(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + store.collection.Mode = cycle.BillingModePrepaid + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + sweepAt := time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + res, err := svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Skipped) + require.Zero(t, res.Charged) + require.Empty(t, sc.invoiceCalls, "a prepaid account is never auto-charged by Leg 1") + require.False(t, store.timers[over].graceResolved, "transient skip — nothing resolved") + + // The account relaxes back to arrears → the deferred charge fires. + store.collection.Mode = cycle.BillingModeArrears + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) +} + +// --- C2: items are pinned to their own draft; Stripe failures stay retryable -- + +// Regression (review 2026-07-06, C2): every Leg-1 line item is PINNED to the +// timer's own draft invoice (created first, pending_invoice_items_behavior= +// exclude) and money moves only at finalize — a crash at any step leaves an +// inert draft that no other charge leg's invoice can sweep up. Also the first +// Stripe-failure injection on this leg: a failed item or finalize leaves the +// timer unresolved, and the retry replays the SAME deterministic idem keys. +func TestModuleOverage_StripeFailureLeavesTimerRetryableWithSameKeys(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + sweepAt := time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC) + + // Attempt 1: the pinned-item create fails AFTER the draft exists. + sc.errItem = errors.New("stripe: item boom") + res, err := svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err, "a per-timer failure never aborts the batch") + require.Equal(t, 1, res.Failed) + require.Empty(t, sc.finalizeCalls, "no finalize → no money moved") + require.False(t, store.timers[over].graceResolved, "left unresolved for the retry") + require.Empty(t, store.invoices, "nothing mirrored") + + // Attempt 2: the finalize (money-moving step) fails. + sc.errItem = nil + sc.errInvoice = errors.New("stripe: finalize boom") + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Failed) + require.False(t, store.timers[over].graceResolved) + require.Empty(t, store.invoices) + + // Attempt 3 succeeds — through the SAME deterministic keys as both failures. + sc.errInvoice = nil + res, err = svc.SweepModuleOverage(ctx, sweepAt) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.True(t, store.timers[over].graceCharged) + + require.GreaterOrEqual(t, len(sc.invoiceCalls), 3) + require.GreaterOrEqual(t, len(sc.itemCalls), 2) + for _, dc := range sc.invoiceCalls { + require.Equal(t, "mod-overage-inv-"+over.String(), dc.idemKey, "every attempt reuses the same draft idem key") + require.Equal(t, "timer:"+over.String(), dc.ref, "the draft carries the charge-ref metadata anchor") + } + for _, ic := range sc.itemCalls { + require.Equal(t, "mod-overage-ii-"+over.String(), ic.idemKey) + require.NotEmpty(t, ic.invoiceID, "the line item is PINNED to the timer's own draft, never a floating pending item") + } + require.Equal(t, "mod-overage-fin-"+over.String(), sc.finalizeCalls[len(sc.finalizeCalls)-1].idemKey) +} + +// --- over module with no usable PM is skipped and retried (not resolved) ------ + +func TestModuleOverage_NoPMSkipsAndRetries(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + store.hasPM = false // account activated but no usable default PM + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + app := uuid.New() + over := seedTimer(store, acct, app, time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Pending) + require.Equal(t, 0, res.Charged) + require.Equal(t, 1, res.Skipped) + require.Empty(t, sc.itemCalls, "no PM → no Stripe call") + // NOT resolved — it stays a candidate for the next sweep once a PM is added. + require.False(t, store.timers[over].graceResolved) + + // Add a PM → the next sweep charges it (idempotent per-timer idem keys). + store.hasPM = true + res2, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res2.Charged) + require.Len(t, sc.itemCalls, 1) + require.True(t, store.timers[over].graceCharged) +} + +// --- unactivated accounts are never swept ------------------------------------- + +func TestModuleOverage_UnactivatedAccountNeverSwept(t *testing.T) { + store := newFakeStore() + _, acct := registeredAccount(store) + delete(store.activation, acct) // never bound a card + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "unactivated accounts are excluded from the work list") + require.Empty(t, sc.itemCalls) + require.False(t, store.timers[over].graceResolved) +} + +// --- FINDING 1: no retroactive catch-up (D1d) when an account only activates +// after an over-module's anchored install period already closed --------------- + +func TestModuleOverage_NoRetroactiveCatchUpWhenActivatedAfterPeriodClosed(t *testing.T) { + // Reproduces the exact failure scenario. An account installs 8 modules on an + // app while UNACTIVATED (RegisterApp synthesizes the timers regardless of + // activation). They sit installed + past-grace for months. Then the owner + // finally binds a card — with anchor day 1 (activated Apr 1), the timers' + // install-anchored period [Jan 1, Feb 1) is long closed. Pre-fix, the very next + // sweep charged the 3 "over" timers (ranks 5-7) a REAL Stripe invoice for that + // historical, never-chargeable January period — exactly the retroactive + // catch-up D1d forbids. Fixed: the over timers are resolved terminally WITH NO + // charge (period_closed), never resurface, and Stripe is never called; the 5 + // included ones resolve as included as usual. + store := newFakeStore() + _, acct := registeredAccount(store) + delete(store.activation, acct) // unactivated at install time + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + app := uuid.New() + + installed := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + var ids []uuid.UUID + for i := 0; i < 8; i++ { + ids = append(ids, seedTimer(store, acct, app, installed)) + } + + // While unactivated the sweep never even lists them (activated_at IS NULL gate). + res0, err := svc.SweepModuleOverage(ctx, time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res0.Pending, "unactivated → excluded from the work list") + + // MONTHS later: the owner binds a card (anchor day 1, Apr 1) + a PM. The 8 + // timers' install period [Jan 1, Feb 1) closed long before Apr 1. + store.activation[acct] = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 8, res.Pending, "now activated, all 8 past-grace timers are listed") + require.Equal(t, 0, res.Charged, "no retroactive catch-up charge for a closed period (D1d)") + require.Equal(t, 5, res.Included, "the 5 earliest installs resolve as included") + require.Equal(t, 3, res.Skipped, "the 3 over installs resolve period_closed (counted as skipped)") + require.Empty(t, sc.itemCalls, "no Stripe call for a period the account was never chargeable in") + for _, id := range ids { + require.True(t, store.timers[id].graceResolved, "every timer reached a terminal verdict") + require.False(t, store.timers[id].graceCharged, "none charged") + } + + // A later sweep never resurfaces them (permanently resolved, never re-swept). + res2, err := svc.SweepModuleOverage(ctx, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res2.Pending, "resolved timers never resurface") + require.Empty(t, sc.itemCalls) +} + +func TestModuleOverage_ActivatedBeforePeriodClosesStillCharges(t *testing.T) { + // Guard against an over-broad fix: an over-module whose account activated + // BEFORE its install-anchored period closes must still charge normally. The + // period-closed check compares against ActivatedAt (not the sweep instant), so + // an account activated well before the install (registeredAccount: May 4) that + // is swept a few days late is NOT treated as a retroactive catch-up. + store := newFakeStore() + _, acct := registeredAccount(store) // activated 2026-05-04, anchor day 4 + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + // 5 included + one over-module installed Jun 10 → period [Jun 4, Jul 4), which + // opened AFTER activation, so the account was chargeable for the whole window. + seedIncluded(store, acct, uuid.New(), time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), 5) + over := seedTimer(store, acct, uuid.New(), time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)) + + res, err := svc.SweepModuleOverage(ctx, time.Date(2026, 6, 14, 9, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, "an over-module whose period opened after activation charges normally") + require.Len(t, sc.itemCalls, 1) + require.True(t, store.timers[over].graceCharged) +} + +// --- SyncAppModules timer synthesis (grow + LIFO shrink + delete) ------------- + +func TestSyncAppModules_GrowsAndLIFOShrinksTimers(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // Register with 2 modules → 2 timers at created_at. + registerMirror(t, svc, user, appID, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), 2) + require.Equal(t, 2, liveTimerCount(store, appID)) + + // Grow 2 → 5: inserts 3 new timers anchored at now (appsNow). + five := 5 + _, err := svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: &five}) + require.NoError(t, err) + require.Equal(t, 5, liveTimerCount(store, appID)) + + // Shrink 5 → 3: LIFO-removes the 2 NEWEST (the appsNow installs), leaving the + // 2 original created_at timers + 1 of the appsNow ones. + three := 3 + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: &three}) + require.NoError(t, err) + require.Equal(t, 3, liveTimerCount(store, appID)) + // Both original created_at timers survive (they are the OLDEST — LIFO removes + // newest first). + created := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + origLive := 0 + for _, tm := range store.timers { + if tm.appID == appID && !tm.removed && tm.installedAt.Equal(created) { + origLive++ + } + } + require.Equal(t, 2, origLive, "LIFO removal keeps the oldest installs") + + // Delete the app → all remaining live timers soft-removed. + _, err = svc.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Equal(t, 0, liveTimerCount(store, appID)) +} diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go new file mode 100644 index 0000000..f361e2f --- /dev/null +++ b/internal/account/cycle/proration.go @@ -0,0 +1,679 @@ +package cycle + +// Creation-proration charge + sweep (creation grace, owner spec 2026-07-05, +// D1e follow-up). RegisterApp used to charge an app's creation-period base +// synchronously at creation; it no longer does (see apps.go). Instead a newly +// created app enters a GRACE window and is charged only once it has SURVIVED it: +// +// - RegisterApp mirrors the roster row (created_at, account, module_count) and +// charges NOTHING; +// - a periodic sweep (SweepCreationProrations, driven by cmd/billing-cycle) +// finds apps past grace (created_at <= now − GraceDays) that are still LIVE +// (deleted_at IS NULL) and NOT yet charged (proration_invoice_id IS NULL), +// and charges each the SAME creation-period proration RegisterApp used to — +// identical ProratedBaseMicros math, anchored to the TRUE created_at, so the +// app pays only for the days it actually existed. Grace delays WHEN the +// charge fires, never WHAT it covers. +// +// An app soft-deleted within grace is thus NEVER charged (the sweep excludes +// deleted rows), and the charge is race-safe against a concurrent delete via a +// brief FOR UPDATE row lock that is released BEFORE the Stripe call (see +// ChargeProrationLocked in store.go). +// +// D1d — no retroactive catch-up: an app whose account never activated (or had +// no usable PM) sits pending on every sweep. If the account only becomes +// chargeable AFTER the app's anchored creation period has already closed, +// charging it then would be exactly the retroactive catch-up D1d forbids — +// ChargeCreationProration detects this (activatedAt at/after the period's end) +// and PERMANENTLY skips the charge (proration_skipped_at, migration 031) +// rather than charging it or leaving it pending forever. +// +// module_count is a LIVE snapshot SyncAppModules can move at any time, +// including during grace. The creation-proration charge must never price its +// historical window off whatever module_count happens to read at sweep time — +// it prices off created_module_count (migration 030), frozen once at +// RegisterApp and never touched again. + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" +) + +// ProrationStatus classifies one ChargeCreationProration outcome for the sweep's +// tally + per-app log line. Only ProrationStatusCharged mints a new invoice; the +// rest are legitimate no-charge outcomes (D1d/D1e) or the idempotent guard. +type ProrationStatus string + +const ( + // ProrationStatusCharged: the creation-proration invoice was created and the + // one-shot guard armed on THIS call. + ProrationStatusCharged ProrationStatus = "charged" + // ProrationStatusAlreadyCharged: the guard was already armed (a prior sweep, + // or a concurrent one, charged) — idempotent success, no second invoice. + ProrationStatusAlreadyCharged ProrationStatus = "already_charged" + // ProrationStatusDeleted: the app is soft-deleted (within grace, or a delete + // that won the race under the lock) → never charged (D1e, no refunds). + ProrationStatusDeleted ProrationStatus = "skipped_deleted" + // ProrationStatusUnactivated: the owner account never bound a card → never + // charged (D1d, no retroactive catch-up on later activation in v1). + ProrationStatusUnactivated ProrationStatus = "skipped_unactivated" + // ProrationStatusNoPM: activated but no usable default PM → skipped, same + // posture as the boundary spine; re-attempted on the next sweep. + ProrationStatusNoPM ProrationStatus = "skipped_no_pm" + // ProrationStatusPrepaid: the account is in PREPAID collection mode — + // off-session auto-charges are not permitted (H10, the same gate the + // boundary spine applies). Transient like no-PM: re-attempted once a + // webhook-driven relax flips the account back to arrears. + ProrationStatusPrepaid ProrationStatus = "skipped_prepaid" + // ProrationStatusNoCharge: the proration rounded to 0 cents (effectively + // unreachable for a real survived app whose base is ≥ $20) → nothing to + // invoice, guard left unarmed. + ProrationStatusNoCharge ProrationStatus = "no_charge" + // ProrationStatusNotFound: no roster row for the app id (never registered). + ProrationStatusNotFound ProrationStatus = "not_found" + // ProrationStatusPeriodClosed: the account only activated at/after the + // app's anchored creation period had already closed — charging it now + // would be a retroactive catch-up (D1d). PERMANENTLY skipped: the + // proration_skipped_at marker is armed so the app never resurfaces on a + // later sweep. + ProrationStatusPeriodClosed ProrationStatus = "skipped_period_closed" +) + +// ProrationResult reports what ChargeCreationProration did. ProrationInvoiceID is +// set on ProrationStatusCharged (the new invoice) and ProrationStatusAlreadyCharged +// (the pre-existing one); ProrationCents only on a fresh charge. +type ProrationResult struct { + AppID uuid.UUID + Status ProrationStatus + ProrationInvoiceID string + ProrationCents int64 +} + +// ProrationOutcome is the store's report from the locked charge section +// (ChargeProrationLocked), decided UNDER the row lock where the terminal +// deleted/guard state is authoritative. +type ProrationOutcome int + +const ( + // ProrationLockedNotFound: the row vanished between the sweep's read and the + // lock (unregistered / cascade-deleted). + ProrationLockedNotFound ProrationOutcome = iota + // ProrationLockedDeleted: deleted_at is set under the lock — a delete won. + ProrationLockedDeleted + // ProrationLockedAlreadyCharged: proration_invoice_id is set under the lock. + ProrationLockedAlreadyCharged + // ProrationLockedNoCharge: the charge callback declined (0 cents) — nothing + // persisted, guard unarmed. + ProrationLockedNoCharge + // ProrationLockedCharged: the charge fired, was mirrored + snapshotted, and + // the guard armed, all committed atomically. + ProrationLockedCharged +) + +// ProrationCharge is the persistence payload the charge callback returns from +// inside the locked transaction: the created Stripe invoice to mirror, the base +// snapshot to freeze (migration 028, source='proration'), and the invoice id +// that arms the one-shot guard. Cents is echoed to the caller. A nil return from +// the callback means "nothing to charge" (0 cents) — the store persists nothing. +// +// TimerCharges (scenario 3) are the co-created over-module install timers billed +// as ADDITIONAL line items on this SAME invoice. persistProrationCharge stamps +// each grace_resolved/grace_charged in the SAME transaction that arms the app +// guard, so the combined charge is all-or-nothing: a co-created over-module and +// the app base fee are billed and marked together, never one without the other. +type ProrationCharge struct { + InvoiceID string + Cents int64 + Invoice InvoiceMirror + Snapshot AppBaseSnapshot + // StraddleSnapshot freezes the straddled period billed IN FULL on this same + // invoice when the app's creation grace crossed its period boundary (the + // coverage contract, review 2026-07-06) — nil otherwise. + StraddleSnapshot *AppBaseSnapshot + TimerCharges []ModuleTimerCharge +} + +// ModuleTimerCharge is one co-created over-module install timer's terminal +// "over and charged" mark (scenario 3): the timer id + the REAL Stripe +// invoice/invoice-item ids of the line it rode on the combined creation invoice. +type ModuleTimerCharge struct { + TimerID uuid.UUID + ChargedAt time.Time + InvoiceID string + InvoiceItemID string +} + +// ChargeCreationProration charges (once) the creation-period base proration for +// ONE app that has survived the grace window — the shared charge leg the sweep +// invokes per pending app. It is idempotent (the one-shot proration_invoice_id +// guard) and race-safe against a concurrent soft-delete (the FOR UPDATE section). +// +// The amount is the FLAT per-app base, prorated to the creation window: +// +// ProratedBaseMicros(BaseFeeMicros, created_at, +// the anchored period CONTAINING created_at) +// +// anchored to the TRUE created_at (NOT now), so the app pays only for the whole +// UTC days it existed in its creation period, creation day inclusive — grace +// only delayed WHEN this fires. Module overage is NO LONGER folded into this +// base (migration 032): it is billed per module instance on its own grace timer, +// and modules co-created with the app (install date == created_at) are added as +// a SEPARATE overage line on this SAME invoice (scenario 3). created_module_count +// stays frozen at RegisterApp time and is recorded on the base snapshot for +// display, but no longer moves the base amount. +// +// It IS gated on whether the account only became chargeable after the +// creation period had already closed (D1d): that would be a retroactive +// catch-up charge for time the account was never eligible to be billed for, +// and is PERMANENTLY skipped rather than charged (see the period-closed check +// below). Short of that, the creation period is billed by NO other leg (the +// boundary advance leg only ever bills an app's SUBSEQUENT periods, never the +// one containing its creation), so charging it whenever the guard is unarmed +// and the period-closed check passes is correct and can never double-bill. +// +// Cheap gates that don't need the row lock (unregistered / already-charged / +// deleted / unactivated / period-closed / no-PM) short-circuit first; the +// actual charge + arm runs under the lock (ChargeProrationLocked), which +// re-verifies the deleted + guard state authoritatively. +func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) (*ProrationResult, error) { + if appID == uuid.Nil { + return nil, billing.InvalidInput("app_id required") + } + + app, found, err := s.store.AppMirror(ctx, appID) + if err != nil { + return nil, billing.Internal("app mirror lookup failed", err) + } + if !found { + return &ProrationResult{AppID: appID, Status: ProrationStatusNotFound}, nil + } + // Idempotent short-circuit: a prior (or concurrent) sweep already charged. + if app.ProrationInvoiceID != "" { + return &ProrationResult{AppID: appID, Status: ProrationStatusAlreadyCharged, ProrationInvoiceID: app.ProrationInvoiceID}, nil + } + // Permanently skipped on a prior sweep (D1d retroactive-catch-up guard, + // migration 031) — never re-evaluated. + if app.ProrationSkipped { + return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil + } + // Deleted WITHIN grace → never charged (scenario 1). Deleted AFTER the + // grace elapsed SURVIVED it and still owes the creation charge (wave 2, + // D11) — grace only delays WHEN the charge fires, and the H2 boundary + // exclusion leaves no other leg as a backstop, so skipping any deleted app + // was a user-timable ~$22 dodge in the grace-elapse→sweep window. The + // locked section re-checks this authoritatively; this is the cheap + // early-out. + if app.Deleted && app.DeletedAt.Before(moduleGraceExpiry(app.CreatedAt.UTC())) { + return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil + } + + // Activation gate (D1d), same posture as the boundary spine: an + // unactivated account (never bound a card) is never charged. + activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("account activation lookup failed", err) + } + if !activated { + return &ProrationResult{AppID: appID, Status: ProrationStatusUnactivated}, nil + } + + // D1d — no retroactive catch-up: derive the anchored period CONTAINING the + // app's created_at from the account's (now-known) activation anchor. If the + // account only activated AT OR AFTER that period's end, the account was + // unactivated — and therefore never chargeable — for the app's ENTIRE + // creation period; charging it now, however late the sweep runs, would be + // exactly the retroactive catch-up D1d forbids. Permanently mark it skipped + // (never re-evaluated again) rather than charge it, and rather than leaving + // it pending forever (proration_invoice_id would stay NULL, so without this + // marker AppsPendingProration would resurface it on every future sweep). + // + // This check deliberately compares against activatedAt, NOT "now": grace + + // ordinary sweep cadence can itself push the charge attempt a few days past + // this SAME periodEnd for a perfectly healthy, already-activated account + // (an app created near its period boundary) — that is expected, intended + // delayed billing (still the ONLY leg that ever bills this period), not a + // retroactive catch-up, and must still charge normally. + // A creation grace that straddles into a period the account WAS activated + // during is NOT fully forgiven (wave 2, D4): D1d forgives the pre-activation + // creation period only — the straddled post-activation period is owed in + // full (the charge callback narrows the amount + window to it), and the + // advance leg only picks the app up from the NEXT boundary. + if _, periodEnd, closed := periodClosedByActivation(app.CreatedAt, activatedAt); closed { + graceExpiry := moduleGraceExpiry(app.CreatedAt.UTC()) + straddleChargeable := false + if !graceExpiry.Before(periodEnd) { + _, coverageEnd := billingperiod.AnchoredPeriodWindow(graceExpiry, billingperiod.AnchorDay(activatedAt)) + straddleChargeable = activatedAt.Before(coverageEnd) + } + if !straddleChargeable { + if err := s.store.SetAppProrationSkipped(ctx, appID); err != nil { + return nil, billing.Internal("mark proration permanently skipped failed", err) + } + return &ProrationResult{AppID: appID, Status: ProrationStatusPeriodClosed}, nil + } + } + + if s.stripe == nil { + return nil, billing.Internal("ChargeCreationProration requires a Stripe client", nil) + } + + // Gates + recovery resolution. Once a prior attempt reached its Stripe + // section (proration_attempted_at set), look its invoice up NOW by the + // ms_charge_ref anchor (once — the charge callback consumes the result): + // a FINALIZED invoice means money moved and this call's only job is to + // RECONCILE (gates bypassed — a prepaid tighten or removed PM after the + // crash must not strand the charge unmirrored); a VOID one is refused + // loudly (D10); an inert DRAFT or nothing at all moved NO money (wave 2, + // D6) — finalizing/minting is a fresh off-session debit and every gate + // applies, exactly as on a first attempt. + var custID string + var recoveredInv *billingstripe.Invoice + moneyMayHaveMoved := false + if app.ProrationAttempted { + custID, err = s.store.AccountStripeCustomer(ctx, app.AccountID) + if err != nil { + return nil, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return nil, billing.Internal("app has an attempted proration charge but the account has no Stripe customer id", nil) + } + if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(appID)); err != nil { + return nil, billing.StripeError("proration recovery lookup failed", err) + } else if ok { + if found.Status == "void" { + return nil, billing.Internal(fmt.Sprintf( + "proration recovery: invoice %s under %s is VOID — refusing to adopt a canceled charge (app %s needs ops resolution)", + found.ID, appProrationChargeRef(appID), appID), nil) + } + recoveredInv = &found + moneyMayHaveMoved = found.Status != "draft" + } + } + if !moneyMayHaveMoved { + // COLLECTION-MODE gate (review 2026-07-06, H10): a prepaid account is + // never auto-charged off-session by ANY leg. Transient skip (guard + // unarmed), like no-PM — re-attempted once the account relaxes back to + // arrears. A recovered inert draft stays inert across the skip. + if permitted, err := s.offSessionChargePermitted(ctx, app.AccountID); err != nil { + return nil, err + } else if !permitted { + return &ProrationResult{AppID: appID, Status: ProrationStatusPrepaid}, nil + } + + // PM gate (D1d), same posture as the boundary spine: activated but no + // usable default PM is skipped and re-attempted next sweep (unlike the + // period-closed case above, "no PM right now" is not itself evidence the + // account was ever ineligible for this specific period, so it stays a + // transient, retried skip rather than a permanent one — see the judgment + // call noted in the PR description for the limits of this). + var ok bool + custID, ok, err = s.resolveChargeableCustomer(ctx, app.AccountID) + if err != nil { + return nil, err + } + if !ok { + return &ProrationResult{AppID: appID, Status: ProrationStatusNoPM}, nil + } + } + + // The charge callback runs AFTER the row lock is released (ChargeProrationLocked, + // store.go): the not-deleted re-check happens under a brief lock, then the + // Stripe charge runs unlocked, then the guard-arm persists the result. + var cents int64 + outcome, invID, err := s.store.ChargeProrationLocked(ctx, appID, func(locked AppMirror) (*ProrationCharge, error) { + // Window = the anchored period CONTAINING the app's created_at (ADR 0005 + // anchor from the activation day). Derived from created_at, NEVER from now, + // so the amount is deterministic regardless of when the sweep fires. + periodStart, periodEnd := billingperiod.AnchoredPeriodWindow(locked.CreatedAt.UTC(), billingperiod.AnchorDay(activatedAt)) + // The creation proration is the FLAT per-app base (migration 032 — module + // overage is no longer a per-app tier; it is billed per module instance via + // its own grace timer). created_module_count is still frozen at RegisterApp + // time and recorded on the snapshot for display, but it no longer moves the + // base amount — a create with 0 or 50 modules prorates the identical flat base. + creationPeriodMicros := usage.ProratedBaseMicros(usage.BaseFeeMicros, locked.CreatedAt, periodStart, periodEnd) + + // Coverage contract (review 2026-07-06, H2): this charge covers creation + // day → the END of the period the creation grace ELAPSES INTO. Normally + // that is the creation period itself. An app created within GraceDays of + // its period boundary is still IN GRACE at that boundary, so the advance + // leg deliberately excludes it there (LiveAppsCreatedBefore's grace + // cutoff — a grace-deleted app must never pay a month of base); when it + // SURVIVES, this charge bills the straddled period in full on top of the + // creation-period proration, and the app joins the advance leg at the + // NEXT boundary. Deterministic across retries (created_at + activation + // anchor are immutable) — the app-keyed Stripe idem keys stay stable. + coverageEnd := periodEnd + prorated := creationPeriodMicros + straddle := !moduleGraceExpiry(locked.CreatedAt.UTC()).Before(periodEnd) + if straddle { + _, coverageEnd = billingperiod.AnchoredPeriodWindow(moduleGraceExpiry(locked.CreatedAt.UTC()), billingperiod.AnchorDay(activatedAt)) + prorated += usage.BaseFeeMicros + } + // D1d straddle narrowing (wave 2, D4). With a pre-activation-closed + // creation period this point is only reachable when the grace straddles + // into a post-activation period (the outer period-closed gate permanently + // skips every other closed case): forgive the creation period, bill the + // straddled one in full, and narrow the mirror window to it. + coverageStart := usage.ProrationCoverageStart(locked.CreatedAt, periodStart) + creationPeriodClosed := !activatedAt.Before(periodEnd) + if creationPeriodClosed { + creationPeriodMicros = 0 + prorated = usage.BaseFeeMicros + coverageStart = periodEnd + } + c, err := centsFromMicros(prorated) + if err != nil { + return nil, billing.Internal("micros to cents conversion failed", err) + } + if c == 0 { + return nil, nil // rounds to 0 cents → nothing to invoice (guard stays unarmed) + } + + // Scenario 3 — the combined creation invoice. Modules co-created with the + // app (install date == created_at) that are "over" per the live FIFO have + // their OWN grace elapse at this SAME instant (same GraceDays anchor), so + // bill them as ADDITIONAL line items PINNED to this SAME draft rather than + // minting a second invoice. Each is $3 over the IDENTICAL coverage window + // as the base (same created_at, so every co-created module is the same + // amount). They use the SAME per-timer item idem keys (mod-overage-ii-) + // Leg 1 would use; the deferred-to-combined guard (overage.go) keeps Leg 1 + // off co-created timers while this charge is pending, and the + // grace_resolved guard (persistProrationCharge) records the winner. + overTimers, err := s.store.CoCreatedOverModuleTimers(ctx, locked.AccountID, locked.AppID, locked.CreatedAt, usage.IncludedModules) + if err != nil { + return nil, billing.Internal("co-created over-module timers lookup failed", err) + } + // Same coverage as the base: creation period prorated, plus the straddled + // period in full when the (shared, co-created) grace crosses the boundary + // — the boundary precharge's grace_expires_at cutoff excluded these timers + // there, so the straddled period is this combined invoice's to bill. + overageMicros := usage.ProratedBaseMicros(usage.ModuleOverageFeeMicros, locked.CreatedAt, periodStart, periodEnd) + if straddle { + overageMicros += usage.ModuleOverageFeeMicros + } + if creationPeriodClosed { + // Same D1d narrowing as the base: only the straddled period is owed. + overageMicros = usage.ModuleOverageFeeMicros + } + overageCents, err := centsFromMicros(overageMicros) + if err != nil { + return nil, billing.Internal("overage micros to cents conversion failed", err) + } + expectedTotalCents := c + if overageCents > 0 { + expectedTotalCents += overageCents * int64(len(overTimers)) + } + + // CRASH RECOVERY (review 2026-07-06, H5): the outer gate section already + // looked the attempted charge up by its ms_charge_ref anchor (once — + // void refused there, gates re-applied when nothing/only-a-draft was + // found, D6). A finalized invoice → the money moved; adopt it. An inert + // draft → complete THAT draft below instead of creating one. Past + // Stripe's ~24h idempotency-key window a bare key replay would have + // created a SECOND draft + items + charge. + var inv billingstripe.Invoice + var draft billingstripe.Invoice + var recoveredFinal bool + if recoveredInv != nil { + if recoveredInv.Status == "draft" { + draft = *recoveredInv + } else { + inv = *recoveredInv + recoveredFinal = true + } + } + + var timerCharges []ModuleTimerCharge + if !recoveredFinal { + // Draft→pinned-items→finalize (C2): the empty draft is created FIRST so + // the base line and every co-created overage line are pinned to THIS + // invoice explicitly — never floating customer-level pending items that a + // concurrently-finalizing leg's invoice could sweep up (or that a crash + // here would leak onto the account's next unrelated invoice). Only the + // finalize step at the end moves money. The migration-036 attempt marker + // is stamped BEFORE the first Stripe call (first-write-wins). + if draft.ID == "" { + if err := s.store.MarkAppProrationAttempted(ctx, locked.AppID, s.nowFn().UTC()); err != nil { + return nil, billing.Internal("mark proration attempted failed", err) + } + draft, err = s.stripe.CreateDraftInvoice(ctx, custID, appProrationChargeRef(locked.AppID), appProrationInvoiceIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration draft invoice failed", err) + } + } + + // Attach the lines — unless a recovered draft already carries a + // COMPLETE deterministic line set: base + k overage lines with + // len(overTimers) ≤ k ≤ created_module_count (wave 2, D2). The live + // overTimers set can only SHRINK between the crash and the retry (an + // uninstall, or a rank flip via an earlier removal), so demanding + // equality with the LIVE recomputation livelocked every such retry + // forever — the crashed attempt's larger (then-correct) line set is + // equally valid: those timers were live and over when the lines were + // pinned, and D1e forbids unwinding them. k below the live set means + // the draft is genuinely INCOMPLETE (crashed mid-attach) — completing + // it past the idem-key window risks duplicate lines, so that (and any + // amount fitting no k) is refused loudly for ops. + completeForSomeK := false + if overageCents > 0 && draft.AmountDue >= c { + rem := draft.AmountDue - c + if rem%overageCents == 0 { + k := rem / overageCents + completeForSomeK = k >= int64(len(overTimers)) && k <= int64(locked.CreatedModuleCount) + } + } + switch { + case draft.AmountDue == expectedTotalCents || completeForSomeK: + // every line already attached — collect the marks for the LIVE + // still-unresolved timers with the known invoice id (item ids + // unrecoverable from the search projection; a timer removed since + // the crash keeps its pinned line — D1e — but needs no mark) + if overageCents > 0 { + for _, timerID := range overTimers { + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, ChargedAt: s.nowFn().UTC(), InvoiceID: draft.ID, + }) + } + } + case draft.AmountDue == 0: + desc := fmt.Sprintf("MirrorStack app base fee (prorated) — app %s", locked.AppID) + if _, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, c, chargeCurrency, desc, appProrationItemIdemKey(locked.AppID)); err != nil { + return nil, billing.StripeError("proration invoice item failed", err) + } + if overageCents > 0 { + overDesc := fmt.Sprintf("MirrorStack module overage (prorated) — app %s", locked.AppID) + for _, timerID := range overTimers { + item, err := s.stripe.CreateInvoiceItem(ctx, custID, draft.ID, overageCents, chargeCurrency, overDesc, moduleOverageItemIdemKey(timerID)) + if err != nil { + return nil, billing.StripeError("combined module overage invoice item failed", err) + } + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, + ChargedAt: s.nowFn().UTC(), + InvoiceID: draft.ID, + InvoiceItemID: item.ID, + }) + } + } + default: + return nil, billing.Internal(fmt.Sprintf( + "proration recovery: draft %s carries %d cents, which is neither 0 nor base(%d)+k×overage(%d) for any k ≤ %d — refusing to finalize a corrupt combined draft (app %s)", + draft.ID, draft.AmountDue, c, overageCents, locked.CreatedModuleCount, locked.AppID), nil) + } + + inv, err = s.stripe.FinalizeInvoice(ctx, draft.ID, appProrationFinalizeIdemKey(locked.AppID)) + if err != nil { + return nil, billing.StripeError("proration invoice finalize failed", err) + } + } else { + // Recovered a finalized invoice: rebuild the co-created timer marks + // against it (item ids unrecoverable from the search projection). + if overageCents > 0 { + for _, timerID := range overTimers { + timerCharges = append(timerCharges, ModuleTimerCharge{ + TimerID: timerID, ChargedAt: s.nowFn().UTC(), InvoiceID: inv.ID, + }) + } + } + } + overageTotalMicros := overageMicros * int64(len(timerCharges)) + + // Resolve the account's large-charge disclosure threshold AT CHARGE TIME, + // immediately AFTER the Stripe calls above succeeded — the SAME point (via + // the shared flagLargeAutoCollect helper, scenario 5) every off-session + // charge site uses, so a threshold edit landing concurrently with a charge is + // honored identically everywhere. The flag reflects the FULL combined debit + // (base + every co-created overage line). + acct, err := s.store.AccountCollection(ctx, locked.AccountID) + if err != nil { + return nil, billing.Internal("account collection lookup failed", err) + } + + // Mirror the window the amount priced — [creation day, coverage end) + // normally; narrowed to the straddled period alone under the D1d straddle + // rule — so mirror and amount agree by construction. + cents = c + pc := &ProrationCharge{ + InvoiceID: inv.ID, + Cents: c, + Invoice: InvoiceMirror{ + AccountID: locked.AccountID, + StripeInvoiceID: inv.ID, + Status: inv.Status, + AmountDueCents: inv.AmountDue, + AmountPaidCents: inv.AmountPaid, + Currency: chargeCurrency, + PeriodStart: coverageStart, + PeriodEnd: coverageEnd, + IsLargeAutoCollect: flagLargeAutoCollect(prorated+overageTotalMicros, acct), + }, + // Freeze what was billed keyed by the FULL anchored period_start (the + // display identity, migration 028); BaseMicros is the prorated BASE amount + // for the CREATION period only (the co-created overage rides the + // per-module timers, not the base snapshot). + Snapshot: AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodStart, + PeriodEnd: periodEnd, + ModuleCount: locked.CreatedModuleCount, + BaseMicros: creationPeriodMicros, + }, + TimerCharges: timerCharges, + } + if creationPeriodClosed { + // D1d straddle (wave 2, D4): only the straddled period was billed — + // its snapshot is the primary one; the forgiven creation period gets + // no row (nothing was charged for it). + pc.Snapshot = AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodEnd, + PeriodEnd: coverageEnd, + ModuleCount: locked.CreatedModuleCount, + BaseMicros: usage.BaseFeeMicros, + } + } else if straddle { + // The straddled period was billed IN FULL on this same invoice — freeze + // its own snapshot row so the display shows that period's base as + // charged (the boundary leg excluded the app there and writes nothing). + pc.StraddleSnapshot = &AppBaseSnapshot{ + AppID: locked.AppID, + PeriodStart: periodEnd, + PeriodEnd: coverageEnd, + ModuleCount: locked.CreatedModuleCount, + BaseMicros: usage.BaseFeeMicros, + } + } + return pc, nil + }) + if err != nil { + // A billing.Error from the charge callback (Stripe / conversion) is already + // classified — surface it verbatim; anything else is a store/tx failure. + if _, ok := err.(*billing.Error); ok { + return nil, err + } + return nil, billing.Internal("locked creation-proration charge failed", err) + } + + switch outcome { + case ProrationLockedCharged: + return &ProrationResult{AppID: appID, Status: ProrationStatusCharged, ProrationInvoiceID: invID, ProrationCents: cents}, nil + case ProrationLockedAlreadyCharged: + return &ProrationResult{AppID: appID, Status: ProrationStatusAlreadyCharged, ProrationInvoiceID: invID}, nil + case ProrationLockedDeleted: + return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil + case ProrationLockedNotFound: + return &ProrationResult{AppID: appID, Status: ProrationStatusNotFound}, nil + default: // ProrationLockedNoCharge + return &ProrationResult{AppID: appID, Status: ProrationStatusNoCharge}, nil + } +} + +// SweepProrationsResult tallies one SweepCreationProrations batch for the +// cmd/billing-cycle log line + exit code. +type SweepProrationsResult struct { + Pending int // apps past grace with an unarmed guard (the work list size) + Charged int // creation-proration invoices minted this sweep + Skipped int // legitimate no-charge outcomes (deleted / unactivated / no-PM / already / 0¢) + Failed int // per-app errors (charge failures) — retried next sweep +} + +// SweepCreationProrations charges the creation-period base for every app that has +// survived the grace window as of `at`: it lists the pending apps (created_at <= +// at − GraceDays, guard unarmed, not deleted) and runs ChargeCreationProration on +// each. Idempotent + resumable: an app charged on a prior sweep drops out of the +// work list (guard armed), and a per-app failure is counted but never aborts the +// batch (the next sweep retries it through the same deterministic Stripe keys). +func (s *Service) SweepCreationProrations(ctx context.Context, at time.Time) (*SweepProrationsResult, error) { + if at.IsZero() { + return nil, billing.InvalidInput("sweep instant required") + } + createdBefore := at.UTC().AddDate(0, 0, -usage.GraceDays) + appIDs, err := s.store.AppsPendingProration(ctx, createdBefore) + if err != nil { + return nil, billing.Internal("list pending prorations failed", err) + } + + res := &SweepProrationsResult{Pending: len(appIDs)} + for _, id := range appIDs { + r, err := s.ChargeCreationProration(ctx, id) + if err != nil { + slog.ErrorContext(ctx, "creation-proration charge failed", + "app_id", id, "error", err) + res.Failed++ + continue + } + if r.Status == ProrationStatusCharged { + res.Charged++ + } else { + res.Skipped++ + } + slog.InfoContext(ctx, "creation-proration", + "app_id", id, "status", string(r.Status), + "invoice_id", r.ProrationInvoiceID, "cents", r.ProrationCents) + } + return res, nil +} + +// appProrationItemIdemKey / appProrationInvoiceIdemKey / +// appProrationFinalizeIdemKey build the deterministic Stripe Idempotency-Keys +// for the creation-proration charge's draft→items→finalize flow. The APP id is +// the stable charge identity (each app prorates at most once — the one-shot +// proration_invoice_id guard), so a re-attempt (a retried sweep after a crash +// between the Stripe calls and the guard-arm) reuses the SAME Stripe objects +// and can never double-charge even before the guard is armed. +func appProrationItemIdemKey(appID uuid.UUID) string { return "app-ii-" + appID.String() } +func appProrationInvoiceIdemKey(appID uuid.UUID) string { return "app-inv-" + appID.String() } +func appProrationFinalizeIdemKey(appID uuid.UUID) string { return "app-fin-" + appID.String() } + +// appProrationChargeRef is the deterministic ms_charge_ref metadata anchor for +// one app's combined creation invoice — what FindInvoiceByRef recovers by. +func appProrationChargeRef(appID uuid.UUID) string { return "app-proration:" + appID.String() } diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go new file mode 100644 index 0000000..e76e817 --- /dev/null +++ b/internal/account/cycle/proration_test.go @@ -0,0 +1,700 @@ +package cycle_test + +// ChargeCreationProration + SweepCreationProrations (creation grace, owner spec +// 2026-07-05). Reuses the in-memory fakeStore (service_test.go) + fakeStripe +// (charge_test.go) and the appsNow / registeredAccount helpers (apps_test.go). + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" +) + +// registerMirror seeds a roster row through RegisterApp (which only mirrors — no +// charge) so the app is owned by the fully-chargeable registeredAccount. +func registerMirror(t *testing.T, svc *cycle.Service, user, appID uuid.UUID, created time.Time, moduleCount int) { + t.Helper() + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerUserID: user, AppID: appID, ModuleCount: moduleCount, CreatedAt: created, + }) + require.NoError(t, err) +} + +// --- (a) grace holds: an app charged before GraceDays elapse is impossible --- + +func TestSweep_SkipsAppsWithinGrace(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Sweep only 2 days later (< GraceDays=3): the app is INSIDE the grace window + // (created_at Jul 1 > cutoff Jul 3 − 3d = Jun 30), so it is not even a + // candidate — no charge, nothing pending. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "an app younger than GraceDays is not swept") + require.Equal(t, 0, res.Charged) + require.Empty(t, sc.itemCalls, "charging within grace is impossible") + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +// --- (b) an app deleted within grace is NEVER charged, even by a later sweep -- + +func TestSweep_NeverChargesAppDeletedWithinGrace(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Deleted on day 0–1 (well within grace). + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.True(t, store.apps[appID].Deleted) + + // A sweep 9 days later (long past grace) must still NEVER charge it: the + // deleted row is excluded from the work list. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "a deleted app is excluded from the sweep") + require.Empty(t, sc.itemCalls, "an app deleted within grace is never charged") + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +// Regression (wave 2, D2): a recovered combined draft whose co-created timer +// set SHRANK between crash and retry (uninstall / rank flip) used to hit the +// refuse-loudly branch on every sweep forever — the app's proration never +// resolved and its modules were never billed by any leg. The validation now +// accepts base + k overage lines for live-set ≤ k ≤ created count. +func TestChargeCreationProration_RecoveredDraftAcceptsShrunkTimerSet(t *testing.T) { + store := newFakeStore() + user, acct := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created mid-period with 7 co-created modules → 2 over. Base 15/30 days = + // $10 (1000¢); each overage line 15/30 of $3 = $1.50 (150¢). + created := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) + registerMirror(t, svc, user, appID, created, 7) + _ = acct + + // The crashed attempt pinned base + BOTH overage lines (1000 + 2×150 = 1300¢) + // and died before finalize. Marker durable. + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_shrunk_draft", Status: "draft", AmountDue: 1300, Currency: "usd"}) + + // Between crash and retry one co-created over-module is uninstalled — the + // live set shrinks to 1. + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, ModuleCount: intPtr(6)}) + require.NoError(t, err) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err, "a shrunk timer set must not livelock the recovery") + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.Equal(t, "in_shrunk_draft", resp.ProrationInvoiceID) + require.Empty(t, sc.itemCalls, "the crashed attempt's complete line set is finalized as-is") + require.Len(t, sc.finalizeCalls, 1) + require.Equal(t, "in_shrunk_draft", sc.finalizeCalls[0].invoiceID) + require.Equal(t, "in_shrunk_draft", store.apps[appID].ProrationInvoiceID, "the guard arms — no livelock") +} + +// The complement: a draft carrying FEWER lines than the still-live set is +// genuinely incomplete (crashed mid-attach) — completing it past the idem-key +// window risks duplicate lines, so it is refused loudly for ops. +func TestChargeCreationProration_RecoveredDraftBelowLiveSetRefused(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + // Base + only 1 of the 2 live over-lines (1000 + 150 = 1150¢): incomplete. + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_partial_draft", Status: "draft", AmountDue: 1150, Currency: "usd"}) + + _, err := svc.ChargeCreationProration(context.Background(), appID) + require.Error(t, err, "an incomplete draft is refused for ops, never silently completed or finalized") + require.Empty(t, sc.finalizeCalls) + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +// Regression (wave 2, D4): an app created pre-activation whose creation grace +// straddles into the first post-activation period used to be PERMANENTLY +// skipped (period_closed) — forgiving the straddled, fully-chargeable period. +// D1d forgives the creation period only; the straddled period is billed in +// full, and a fully pre-activation app stays forgiven. +func TestChargeCreationProration_D1dStraddleChargesThePostActivationPeriod(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) // activated 2026-05-04 09:00 → anchor day 4 + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created May 2 — pre-activation (creation period [Apr 4, May 4) closes at + // activation) — grace expires May 5, inside [May 4, Jun 4). + registerMirror(t, svc, user, appID, time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2000, resp.ProrationCents, "the straddled period's FULL $20 base — the creation period is forgiven") + mirror := store.invoices[sc.invoiceID] + require.Equal(t, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodStart, "window narrowed to the straddled period") + require.Equal(t, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the straddled period gets the snapshot") + require.EqualValues(t, 20_000_000, snap.snap.BaseMicros) + _, forgiven := store.baseSnapshots[snapKey{appID, time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC)}] + require.False(t, forgiven, "the forgiven creation period gets no snapshot") +} + +func TestChargeCreationProration_D1dFullyPreActivationStaysSkipped(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) // activated 2026-05-04 09:00 + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created Apr 10 — grace expired Apr 13, entirely pre-activation. + registerMirror(t, svc, user, appID, time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, resp.Status) + require.Empty(t, sc.itemCalls, "fully pre-activation coverage stays forgiven and permanently skipped") + require.True(t, store.apps[appID].ProrationSkipped) +} + +// Regression (review 2026-07-06, H5): a creation-proration retry past Stripe's +// ~24h idempotency-key window reconciles by the app's ms_charge_ref anchor — +// a crashed attempt's finalized combined invoice is adopted (guard armed with +// ITS id, timers marked against it) with no new Stripe objects. +func TestChargeCreationProration_LateRetryAdoptsFoundInvoice(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 0) + + // A prior attempt reached its Stripe section (marker set), finalized the + // invoice, and crashed before persisting. + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + sc.setFindByRef("app-proration:"+appID.String(), billingstripe.Invoice{ID: "in_prior_combined", Status: "paid", AmountDue: 1000, AmountPaid: 1000, Currency: "usd"}) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.Equal(t, "in_prior_combined", resp.ProrationInvoiceID) + require.Equal(t, "in_prior_combined", store.apps[appID].ProrationInvoiceID, "the guard arms with the recovered invoice") + require.Empty(t, sc.invoiceCalls, "no second draft") + require.Empty(t, sc.itemCalls, "no re-attached lines") + require.Empty(t, sc.finalizeCalls, "no second finalize — the money moved once") +} + +// Regression (review 2026-07-06, H10): a PREPAID account is never auto-charged +// off-session — the boundary spine always gated on this, but the creation- +// proration leg bypassed it. Transient skip (guard unarmed); a relax back to +// arrears charges through the same keys. +func TestChargeCreationProration_PrepaidAccountSkippedNotCharged(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + store.collection.Mode = cycle.BillingModePrepaid + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPrepaid, resp.Status) + require.Empty(t, sc.invoiceCalls, "a prepaid account is never auto-charged by the creation leg") + require.Empty(t, store.apps[appID].ProrationInvoiceID, "transient skip — the guard stays unarmed") + + // Relax → the deferred creation charge fires normally. + store.collection.Mode = cycle.BillingModeArrears + resp, err = svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.NotEmpty(t, store.apps[appID].ProrationInvoiceID) +} + +// --- (c) a survivor is charged EXACTLY ONCE even if the sweep runs twice ------ + +func TestSweep_ChargesSurvivorExactlyOnceAcrossReruns(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // First sweep past grace → charges the creation proration once. + first, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, first.Pending) + require.Equal(t, 1, first.Charged) + require.Len(t, sc.itemCalls, 1) + // 3 of 30 days of $20 ($2) + the straddled [Jul 4, Aug 4) period in full + // ($20) — created Jul 1 08:00, so the grace crosses the Jul 4 boundary and + // the advance leg excludes the app there (coverage contract, H2). + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) + armed := store.apps[appID].ProrationInvoiceID + require.NotEmpty(t, armed) + + // Second sweep (a re-fire the next day): the guard is armed, so the app is no + // longer pending and no second invoice is created. + second, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, second.Pending, "an already-charged app drops out of the work list") + require.Equal(t, 0, second.Charged) + require.Len(t, sc.itemCalls, 1, "the re-fire must never charge twice") + require.Equal(t, armed, store.apps[appID].ProrationInvoiceID) +} + +// --- (d) the proration $ amount, mirror window, and snapshots ----------------- + +func TestChargeCreationProration_AmountMatchesLegacyProration(t *testing.T) { + // The creation-period part is the SAME number the pre-grace RegisterApp + // charge produced: 20e6 × 3/30 = 2_000_000 micros. Created Jul 1 08:00, the + // grace crosses the Jul 4 boundary (coverage contract, H2), so the charge + // ALSO covers the straddled [Jul 4, Aug 4) period in full: 22e6 micros → + // 2200 cents, mirrored with the window [creation day, straddled period end), + // TWO snapshots frozen — the creation period's prorated amount and the + // straddled period's full base. + store := newFakeStore() + user, acct := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2200, resp.ProrationCents) + + require.Len(t, sc.itemCalls, 1) + require.Len(t, sc.invoiceCalls, 1) + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) + require.Equal(t, "cus_apps_1", sc.itemCalls[0].custID) + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + require.Equal(t, "app-inv-"+appID.String(), sc.invoiceCalls[0].idemKey) + require.Len(t, sc.finalizeCalls, 1, "the draft is finalized (auto_advance) — the money-moving step") + + require.Equal(t, sc.invoiceID, resp.ProrationInvoiceID) + mirror := store.invoices[sc.invoiceID] + require.Equal(t, acct, mirror.AccountID) + require.Equal(t, time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) // partial coverage start + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd, "coverage runs through the straddled period's end") + require.Equal(t, sc.invoiceID, store.apps[appID].ProrationInvoiceID) + + snap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the charge freezes its base keyed on the FULL anchored period start") + require.Equal(t, "proration", snap.source) + require.EqualValues(t, 2_000_000, snap.snap.BaseMicros, "the creation-period snapshot carries only the prorated part") + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), snap.snap.PeriodEnd) + require.Equal(t, 0, snap.snap.ModuleCount) + + straddleSnap, ok := store.baseSnapshots[snapKey{appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}] + require.True(t, ok, "the straddled period billed in full gets its own snapshot (the boundary leg writes nothing for it)") + require.Equal(t, "proration", straddleSnap.source) + require.EqualValues(t, 20_000_000, straddleSnap.snap.BaseMicros) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), straddleSnap.snap.PeriodEnd) +} + +func TestChargeCreationProration_ChargesFlatBaseNotFoldedOverage(t *testing.T) { + // Migration 032: module overage is NO LONGER folded into the per-app base — + // the creation proration is the FLAT $20 base regardless of module_count (a + // 7-module app prorates EXACTLY like a 0-module app). 15 of 30 remaining days + // → 20e6 × 15/30 = 10e6 micros → 1000 cents (NOT the pre-032 26e6 → 1300). + // Overage for the modules co-created with the app is a SEPARATE line, added + // by the module-overage grace leg (see overage tests). + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 1000, resp.ProrationCents, "flat base only — overage is billed per module instance, not folded here") + // The frozen created_module_count (7) is still recorded on the snapshot for + // display, even though it no longer moves the base amount. + require.Equal(t, 7, store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}].snap.ModuleCount) +} + +func TestChargeCreationProration_CreatedOnBoundaryChargesFullNewPeriodBase(t *testing.T) { + // Created exactly ON an anchor boundary (Jul 4 00:00): half-open windows put + // it at the START of the NEW period [Jul 4, Aug 4) → the FULL base ($20 → + // 2000 cents), snapshot keyed on Jul 4. Unchanged from the pre-grace charge. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 2_000, resp.ProrationCents, "creation-day == period start → full base") + mirror := store.invoices[sc.invoiceID] + require.Equal(t, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodStart) + require.Equal(t, time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC), mirror.PeriodEnd) + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, "proration", snap.source) + require.EqualValues(t, usage.BaseFeeMicros, snap.snap.BaseMicros) +} + +func TestChargeCreationProration_DelayedPastPeriodEndStillCharges(t *testing.T) { + // Delayed billing (grace point 5): the charge is anchored to the TRUE + // created_at, NOT now, so even when grace + ordinary sweep cadence pushes the + // charge attempt past the creation period's end, the creation-period + // proration STILL fires — that period is billed by NO other leg (the + // boundary advance leg only ever bills an app's SUBSEQUENT periods), so + // charging it is correct and never double-bills. This is NOT the D1d + // retroactive-catch-up case (see TestChargeCreationProration_ + // SkipsPermanentlyWhenActivatedAfterPeriodClosed below): the account here was + // ALREADY activated (May 4, registeredAccount) well before the app's period + // even opened — the period-closed check in ChargeCreationProration compares + // against activatedAt, not "now", so a healthy already-activated account is + // never penalized for a sweep that simply runs late. + // App created May 20 → period [May 4, Jun 4), long closed by the sweep in + // July: 15 of 31 days of $20 = round_half_up(9_677_419.8) → 968 cents. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC), 0) + + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 968, sc.itemCalls[0].amountCfg) + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, "proration", snap.source) +} + +// --- ChargeCreationProration: idempotency + gates ---------------------------- + +func TestChargeCreationProration_IdempotentGuard(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, first.Status) + + second, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusAlreadyCharged, second.Status) + require.Equal(t, first.ProrationInvoiceID, second.ProrationInvoiceID) + require.Len(t, sc.itemCalls, 1, "the one-shot guard prevents a second charge") +} + +func TestChargeCreationProration_SkipsDeleted(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusDeleted, resp.Status) + require.Empty(t, sc.itemCalls) +} + +func TestChargeCreationProration_SkipsUnactivatedAndNoPM(t *testing.T) { + // Unactivated account → skipped_unactivated (D1d, no retroactive catch-up). + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, resp.Status) + require.Empty(t, sc.itemCalls) + + // Activated but no usable PM → skipped_no_pm (re-attempted next sweep). + store.activation[acct] = time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + store.hasPM = false + resp, err = svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusNoPM, resp.Status) + require.Empty(t, sc.itemCalls) + require.Empty(t, store.apps[appID].ProrationInvoiceID) +} + +func TestChargeCreationProration_NotFound(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + resp, err := svc.ChargeCreationProration(context.Background(), uuid.New()) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusNotFound, resp.Status) +} + +func TestChargeCreationProration_Validation(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + _, err := svc.ChargeCreationProration(context.Background(), uuid.Nil) + requireCode(t, err, billing.CodeInvalidInput) +} + +func TestSweepCreationProrations_Validation(t *testing.T) { + svc := appsSvc(newFakeStore(), newFakeStripe()) + _, err := svc.SweepCreationProrations(context.Background(), time.Time{}) + requireCode(t, err, billing.CodeInvalidInput) +} + +// --- Sweep: multiple apps, mixed states -------------------------------------- + +func TestSweep_ChargesOnlyPastGraceLiveUnchargedApps(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + + past := uuid.New() // past grace, live, uncharged → charged + young := uuid.New() // within grace → skipped + gone := uuid.New() // deleted WITHIN grace → skipped (D11: only in-grace deletes are free) + registerMirror(t, svc, user, past, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) + registerMirror(t, svc, user, young, time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC), 0) + registerMirror(t, svc, user, gone, time.Date(2026, 6, 20, 8, 0, 0, 0, time.UTC), 0) + svcDay1 := cycle.NewService(store, sc).WithNow(func() time.Time { return time.Date(2026, 6, 21, 8, 0, 0, 0, time.UTC) }) + _, err := svcDay1.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: gone, Deleted: true}) + require.NoError(t, err) + + // Sweep as of Jun 30 → cutoff Jun 27: past (Jun 20) qualifies; young (Jun 29) + // is within grace; gone was deleted inside its grace. + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Pending) + require.Equal(t, 1, res.Charged) + require.NotEmpty(t, store.apps[past].ProrationInvoiceID) + require.Empty(t, store.apps[young].ProrationInvoiceID) + require.Empty(t, store.apps[gone].ProrationInvoiceID) +} + +// Regression (wave 2, D11): an app deleted AFTER its grace elapsed SURVIVED +// the grace — the creation charge is owed (grace only delays WHEN it fires). +// Pre-fix any deleted app was skipped, which — combined with the H2 boundary +// exclusion — made "delete in the grace-elapse→sweep window" a user-timable +// ~$22 dodge (creation proration + the straddled month), repeatable per app. +func TestSweep_AppDeletedAfterGraceStillPaysCreationCharge(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + // Created Jul 1 08:00 → grace ends Jul 4 08:00 (straddles the Jul 4 anchor + // boundary). Deleted Jul 4 12:00 — AFTER grace, BEFORE the daily sweep. + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + svcPostGrace := cycle.NewService(store, sc).WithNow(func() time.Time { return time.Date(2026, 7, 4, 12, 0, 0, 0, time.UTC) }) + _, err := svcPostGrace.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 1, res.Charged, + "a post-grace delete survived the grace — the creation (+straddle) charge is owed, not dodgeable") + require.NotEmpty(t, store.apps[appID].ProrationInvoiceID) + require.Len(t, sc.finalizeCalls, 1) +} + +// --- FINDING 1: the creation-proration charge must price off the module count +// FROZEN at RegisterApp time, never whatever module_count SyncAppModules moves +// it to during (or after) the grace window ---------------------------------- + +func TestChargeCreationProration_PricesFrozenCountNotLiveCountAfterMidGraceInstall(t *testing.T) { + // Reproduces the exact failure scenario: an app registers with 0 modules, + // the customer installs 7 MORE modules (via SyncAppModules) DURING the + // mandatory grace window — before the sweep ever charges — and the sweep + // fires after grace elapses. Pre-fix, the charge priced off the module_count + // read FRESH at sweep time (7, live) → 20e6 + 2×3e6 = 26e6 base, 3 of 30 days + // → 2_600_000 micros: a HIGHER tier retroactively applied to days that never + // had 7 modules installed. Fixed: the charge prices off created_module_count + // (frozen at registration, 0) → 20e6 base, 3 of 30 days → 2_000_000 micros, + // plus the straddled [Jul 4, Aug 4) period's full base (created Jul 1 08:00 — + // the grace crosses the boundary) → 22e6 → 2200 cents — identical to + // TestChargeCreationProration_AmountMatchesLegacyProration's un-synced case. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC), 0) + + // Mid-grace install: the live count jumps to 7 BEFORE the sweep ever runs. + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ + AppID: appID, ModuleCount: intPtr(7), + }) + require.NoError(t, err) + require.Equal(t, 7, store.apps[appID].ModuleCount, "the live count DID move") + require.Equal(t, 0, store.apps[appID].CreatedModuleCount, "the frozen count must NOT move") + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2200, resp.ProrationCents, + "must price off the FROZEN count (0 modules → $20 base), not the live count (7 → $26 base)") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 2200, sc.itemCalls[0].amountCfg) + + // The migration-028 snapshot must also record the FROZEN count/amount — the + // display must never show a tier that never applied to those days either. + snap := store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}] + require.Equal(t, 0, snap.snap.ModuleCount) + require.EqualValues(t, 2_000_000, snap.snap.BaseMicros) + + // The LIVE count survives untouched for the boundary advance leg's future + // periods — only the historical creation-period charge is frozen. + require.Equal(t, 7, store.apps[appID].ModuleCount) +} + +func TestChargeCreationProration_FlatBaseUnaffectedByMidGraceUninstall(t *testing.T) { + // Migration 032: the creation base is FLAT, so a mid-grace uninstall (7 → 0 + // modules) cannot move it — it prorates the flat $20 either way: 15 of 30 + // remaining days → 10e6 micros → 1000 cents. The frozen created_module_count + // (7) is still preserved for display (the snapshot ModuleCount), it just no + // longer drives the base amount now that overage is a separate per-module leg. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC), 7) + + _, err := svc.SyncAppModules(context.Background(), cycle.SyncAppModulesRequest{ + AppID: appID, ModuleCount: intPtr(0), + }) + require.NoError(t, err) + require.Equal(t, 0, store.apps[appID].ModuleCount) + require.Equal(t, 7, store.apps[appID].CreatedModuleCount, "the frozen count must NOT move") + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.EqualValues(t, 1_000, resp.ProrationCents, "flat base — unaffected by the module count or its mid-grace change") + // The frozen count is still recorded on the snapshot for display. + require.Equal(t, 7, store.baseSnapshots[snapKey{appID, time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC)}].snap.ModuleCount) +} + +// --- FINDING 2: no retroactive catch-up (D1d) when an account only activates +// after the app's anchored creation period already closed ------------------- + +func TestChargeCreationProration_SkipsPermanentlyWhenActivatedAfterPeriodClosed(t *testing.T) { + // Reproduces the exact failure scenario: an app is created while its account + // is unactivated. Every sweep correctly leaves it unbilled (skipped_ + // unactivated). MONTHS later the owner finally binds a card — with anchor + // day 1 (activated Apr 1), the app's anchored creation period is + // [Jan 1, Feb 1), long closed by Apr 1. Pre-fix, the very next charge + // attempt would retroactively bill that period in FULL (2000 cents on 0 + // modules, since Jan 1 == the period start): exactly the retroactive + // catch-up D1d forbids. Fixed: the charge is PERMANENTLY skipped — no + // Stripe call, ever, and the app never resurfaces on a later sweep (it + // would otherwise sit pending forever, since proration_invoice_id never + // gets set for a skipped charge). + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) // unactivated at creation + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + created := time.Date(2026, 1, 1, 8, 0, 0, 0, time.UTC) + registerMirror(t, svc, user, appID, created, 0) + + // Past grace, still unactivated → correctly pending, no charge (D1d's + // existing unactivated gate — unchanged by this fix). + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, first.Status) + require.Empty(t, sc.itemCalls) + + // MONTHS later: the owner binds a card (anchor day 1) and a PM. + store.activation[acct] = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC) + store.hasPM = true + + second, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, second.Status) + require.Empty(t, sc.itemCalls, "no Stripe call — a retroactive catch-up charge must never fire") + require.Empty(t, store.apps[appID].ProrationInvoiceID) + require.True(t, store.apps[appID].ProrationSkipped, "permanently marked so it is never re-evaluated") + + // A cheap re-evaluation (e.g. a retried RPC) short-circuits on the marker + // without even re-reading account activation — still no Stripe call. + third, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusPeriodClosed, third.Status) + require.Empty(t, sc.itemCalls) + + // A LATER sweep must never resurface it — it would otherwise sit pending + // forever (proration_invoice_id stays NULL for a permanently-skipped app). + res, err := svc.SweepCreationProrations(context.Background(), time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + require.NoError(t, err) + require.Equal(t, 0, res.Pending, "a permanently-skipped app never resurfaces on a later sweep") + require.Empty(t, sc.itemCalls) +} + +func TestChargeCreationProration_ActivatedBeforePeriodClosesStillCharges(t *testing.T) { + // Guard against an over-broad fix: an account that activates BEFORE the + // app's anchored creation period closes must charge normally — D1d only + // blocks a retroactive catch-up when the account was unactivated for the + // app's ENTIRE creation period. The anchor day is DERIVED from activatedAt + // itself (billingperiod.AnchorDay), so activating the SAME calendar day the + // app was created (the common "sign up, create an app, add a card" onboarding + // flow) anchors the period at that same day-of-month — putting its END a + // full month out, safely after activation. + store := newFakeStore() + user, acct := registeredAccount(store) + delete(store.activation, acct) + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, time.Date(2026, 1, 10, 6, 0, 0, 0, time.UTC), 0) + + first, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusUnactivated, first.Status) + + // Activates a few hours later the SAME day (anchor day 10) → period + // [Jan 10, Feb 10) — still wide open — and binds a PM. + store.activation[acct] = time.Date(2026, 1, 10, 9, 0, 0, 0, time.UTC) + store.hasPM = true + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.EqualValues(t, 2_000, resp.ProrationCents, "created on/after the period start → full base") + require.Len(t, sc.itemCalls, 1) + require.False(t, store.apps[appID].ProrationSkipped) +} diff --git a/internal/account/cycle/rollback_integration_test.go b/internal/account/cycle/rollback_integration_test.go new file mode 100644 index 0000000..3b89e74 --- /dev/null +++ b/internal/account/cycle/rollback_integration_test.go @@ -0,0 +1,54 @@ +//go:build integration + +package cycle + +// White-box (package cycle, not cycle_test) so this can exercise the +// unexported deferredRollback directly (finding 3, store.go): the deferred +// Rollback in ChargeProrationLocked's phases must reach Postgres even when the +// context passed to the surrounding call is already cancelled/expired (e.g. +// the enclosing Lambda invocation timed out while awaiting a stalled Stripe +// call) — reusing that dead context verbatim in the Rollback call can fail +// silently, leaving the transaction (and its row lock) open until Postgres's +// own dead-connection detection eventually reclaims it. + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +func TestDeferredRollback_Integration_SurvivesAlreadyCancelledContext(t *testing.T) { + pool := testutil.NewTestDB(t) + + // Sanity check: confirms the bug this test guards against is real — pgx + // refuses to run a network round-trip (including ROLLBACK) against an + // already-cancelled context, so a NAIVE `defer func() { tx.Rollback(ctx) }()` + // that reuses a dead ctx verbatim is a silent no-op. + ctx, cancel := context.WithCancel(context.Background()) + tx, err := pool.Begin(ctx) + require.NoError(t, err) + cancel() + naiveErr := tx.Rollback(ctx) + require.Error(t, naiveErr, "sanity: rolling back with an already-cancelled context fails") + // Clean up the still-open transaction/connection from the sanity check with + // a fresh context so it doesn't leak into the real assertion below. + _ = tx.Rollback(context.Background()) + + // The fix: deferredRollback is handed the SAME dead ctx but must still reach + // Postgres via its own short-lived detached context. + tx2, err := pool.Begin(context.Background()) + require.NoError(t, err) + deferredRollback(ctx, tx2) // ctx here is STILL the cancelled context from above + + // Prove the rollback actually completed and the connection is healthy again + // (a leaked/open transaction would eventually starve the pool; this bounds + // how long we're willing to wait for the pool to hand back a working conn). + require.Eventually(t, func() bool { + var one int + return pool.QueryRow(context.Background(), "SELECT 1").Scan(&one) == nil && one == 1 + }, 3*time.Second, 50*time.Millisecond, "deferredRollback must release the transaction/connection despite the dead ctx") +} diff --git a/internal/account/cycle/scenarios_test.go b/internal/account/cycle/scenarios_test.go new file mode 100644 index 0000000..735d114 --- /dev/null +++ b/internal/account/cycle/scenarios_test.go @@ -0,0 +1,523 @@ +package cycle_test + +// End-to-end scenario regression suite for the per-module overage timer model +// (DESIGN.md "Base fee — v2: creation grace + per-module overage timers", +// scenarios 1–6). Each test drives the SAME code paths cmd/billing-cycle drives +// (RegisterApp → SweepCreationProrations → SweepModuleOverage → RunBillingCycle) +// and asserts the exact dollar amounts + the exact Stripe invoice-count the spec +// calls out. Reuses the in-memory fakeStore (service_test.go) + fakeStripe +// (charge_test.go) + the registeredAccount / appsSvc / seedApp / seedTimer / +// seedIncluded helpers. +// +// Fixture: registeredAccount activates 2026-05-04 → anchor day 4, so the anchored +// period CONTAINING a mid-June instant is [2026-06-04, 2026-07-04) = 30 days. An +// app created 2026-06-19 has remain_days = whole UTC days in [Jun 19, Jul 4) = 15, +// exactly HALF the period, so each prorated amount is a clean half: +// * base $20 × 15/30 = $10.00 → 1000¢ +// * overage $3 × 15/30 = $1.50 → 150¢ + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/account/usage" +) + +func ptrI64(v int64) *int64 { return &v } + +var scenarioCreatedAt = timeUTC(2026, 6, 19, 12) // mid-period create (anchor 4) + +// scenarioSweepAt is past scenarioCreatedAt + GraceDays (Jun 22), so both the +// app's creation grace and its co-created modules' grace have elapsed. +var scenarioSweepAt = timeUTC(2026, 6, 25, 9) + +func timeUTC(y int, m, d, h int) time.Time { + return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC) +} + +// --- Scenario 1: app just created → no charge; deleted in grace → never charged - + +func TestScenario1_CreatedThenDeletedInGraceNeverCharged(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // Create with 3 modules — no charge fires at creation (creation grace). + registerMirror(t, svc, user, appID, scenarioCreatedAt, 3) + require.Empty(t, sc.itemCalls, "no charge at creation (scenario 1)") + require.Equal(t, 3, liveTimerCount(store, appID)) + + // Deleted WITHIN grace (day 1 — the delete must be clocked inside the + // grace window: D11 makes a post-grace delete chargeable) → the app + all + // its install timers drop out. + svcDay1 := cycle.NewService(store, sc).WithNow(func() time.Time { return scenarioCreatedAt.AddDate(0, 0, 1) }) + _, err := svcDay1.SyncAppModules(ctx, cycle.SyncAppModulesRequest{AppID: appID, Deleted: true}) + require.NoError(t, err) + require.Equal(t, 0, liveTimerCount(store, appID), "delete soft-removes all timers") + + // Both grace sweeps, run long past grace, charge NOTHING — a deleted app is out + // of the proration work-list and its timers are out of the overage work-list. + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, pro.Charged) + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged) + + require.Empty(t, sc.invoiceCalls, "an app deleted in grace is NEVER charged (scenario 1)") + require.Empty(t, store.invoices) +} + +// --- Scenario 2: survives grace, pool ≤ 5 → base-only prorated charge ---------- + +func TestScenario2_SurvivesGracePoolWithinIncludedBaseOnly(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // 3 co-created modules → pool 3 ≤ IncludedModules(5): all "included", no overage. + registerMirror(t, svc, user, appID, scenarioCreatedAt, 3) + + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro.Charged) + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "3 modules are all included — no overage") + + // EXACTLY one invoice, ONE line item: the prorated FLAT base only. + require.Len(t, sc.invoiceCalls, 1, "base-only creation invoice (scenario 2)") + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 1000, sc.itemCalls[0].amountCfg, "$20 × 15/30 = $10.00") + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + + // The 3 co-created timers all resolved as included, none charged. + for _, tm := range store.timers { + require.True(t, tm.graceResolved) + require.False(t, tm.graceCharged) + } +} + +// --- Scenario 3: pool > 5 from day 0 → ONE combined invoice (base + overage) --- + +func TestScenario3_PoolOverFromDayZeroOneCombinedInvoice(t *testing.T) { + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + + // 7 co-created modules → pool 7 > IncludedModules(5): 2 are "over" from day 0 + // (installed AT created_at). Their grace elapses at the SAME instant as the + // app's creation grace, so they ride ONE combined invoice with the base. + registerMirror(t, svc, user, appID, scenarioCreatedAt, 7) + + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro.Charged) + // The overage sweep AFTER proration finds the 2 over-modules already resolved + // (charged on the combined invoice) and the 5 included ones resolvable with no + // charge — it adds NO second invoice. + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "co-created over-modules already billed on the combined invoice") + require.Equal(t, 5, over.Included, "the 5 included modules resolve with no charge") + + // EXACTLY ONE Stripe invoice (not two), with THREE line items: base + 2 overage. + require.Len(t, sc.invoiceCalls, 1, "scenario 3 is ONE combined invoice, never two") + require.Len(t, sc.itemCalls, 3) + require.EqualValues(t, 1000, sc.itemCalls[0].amountCfg, "base: $20 × 15/30 = $10.00") + require.Equal(t, "app-ii-"+appID.String(), sc.itemCalls[0].idemKey) + require.EqualValues(t, 150, sc.itemCalls[1].amountCfg, "overage: $3 × 15/30 = $1.50") + require.EqualValues(t, 150, sc.itemCalls[2].amountCfg) + // Overage line items use the SAME per-timer idem keys Leg 1 would use, so a + // racing sweep can never double-charge them. + require.Contains(t, sc.itemCalls[1].idemKey, "mod-overage-ii-") + require.Contains(t, sc.itemCalls[2].idemKey, "mod-overage-ii-") + + // Exactly 2 timers marked charged (the over ones); all 7 resolved. + charged, resolved := 0, 0 + for _, tm := range store.timers { + if tm.graceCharged { + charged++ + require.Contains(t, tm.graceInvoiceItemID, "ii_test_", "the REAL Stripe item id, not the idem key") + } + if tm.graceResolved { + resolved++ + } + } + require.Equal(t, 2, charged, "exactly the 2 over-modules were charged") + require.Equal(t, 7, resolved, "all 7 co-created timers reached a terminal verdict") +} + +// --- FINDING 2: a combined-invoice Phase-3 failure must not let Leg 1 mint a +// SECOND overage invoice for the co-created over-modules ------------------------ + +func TestScenario3_ProrationPhase3FailureDoesNotMintSecondOverageInvoice(t *testing.T) { + // Reproduces the exact failure scenario. App A is created with 7 co-created + // modules (5 included + 2 over). The combined creation charge's Stripe calls + // succeed (base + 2 overage items on ONE invoice), but its persist phase + // (Phase 3 — arm the guard + mark the co-created timers resolved) FAILS + // (deadlock / transient tx error) even though the money already moved. cmd/ + // billing-cycle then runs the overage sweep in the SAME process. Pre-fix, Leg 1 + // found the 2 still-unresolved over timers and minted a SECOND invoice for them + // (a fresh mod-overage-inv- key), mis-attributing money Stripe already + // pooled onto the combined invoice. Fixed: Leg 1 DEFERS them (they belong to the + // combined invoice), so the proration sweep's next retry converges on the SAME + // combined invoice and marks them — exactly ONE invoice, ever. + store := newFakeStore() + user, _ := registeredAccount(store) + sc := newFakeStripe() + svc := appsSvc(store, sc) + ctx := context.Background() + appID := uuid.New() + registerMirror(t, svc, user, appID, scenarioCreatedAt, 7) + + // The combined charge's Stripe calls land; Phase 3 fails after they succeed. + store.errPersistAfterStripe = errors.New("serialization failure (deadlock)") + pro, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err, "a per-app charge failure never aborts the sweep") + require.Equal(t, 1, pro.Failed, "App A's combined charge failed at Phase 3") + require.Equal(t, 0, pro.Charged) + require.Empty(t, store.apps[appID].ProrationInvoiceID, "guard unarmed after Phase 3 failure") + + // Stripe DID fire: base + 2 overage items on ONE combined invoice. + require.Len(t, sc.itemCalls, 3, "base + 2 co-created overage items") + require.Len(t, sc.invoiceCalls, 1) + combinedInvoiceIdem := sc.invoiceCalls[0].idemKey + require.Equal(t, "app-inv-"+appID.String(), combinedInvoiceIdem) + // Every timer is still unresolved — Phase 3 rolled back its marks. + for _, tm := range store.timers { + require.False(t, tm.graceResolved, "no timer resolved — Phase 3 rolled back") + } + + // The SAME-process overage sweep must NOT mint a second overage invoice for the + // co-created over-modules. It resolves the 5 included (harmless) and DEFERS the + // 2 over ones back to the combined-invoice path. + over, err := svc.SweepModuleOverage(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 0, over.Charged, "co-created over-modules are NOT re-charged by Leg 1") + require.Equal(t, 5, over.Included, "the 5 included co-created timers resolve with no charge") + require.Equal(t, 2, over.Skipped, "the 2 over co-created timers are deferred to the combined invoice") + for _, ic := range sc.invoiceCalls { + require.NotContains(t, ic.idemKey, "mod-overage-inv-", + "Leg 1 must never mint a separate invoice for a co-created over-module") + } + // The 2 over timers stay unresolved, awaiting the proration retry. + unresolvedOver := 0 + for _, tm := range store.timers { + if !tm.graceResolved { + unresolvedOver++ + } + } + require.Equal(t, 2, unresolvedOver, "the 2 over timers wait for the combined-invoice retry") + + // Clear the transient failure: the proration sweep's retry converges on the + // SAME combined invoice (deterministic idem keys) and marks the 2 over timers. + store.errPersistAfterStripe = nil + pro2, err := svc.SweepCreationProrations(ctx, scenarioSweepAt) + require.NoError(t, err) + require.Equal(t, 1, pro2.Charged, "the retry finally lands the combined charge") + armed := store.apps[appID].ProrationInvoiceID + require.NotEmpty(t, armed) + + charged := 0 + for _, tm := range store.timers { + if tm.graceCharged { + charged++ + require.Equal(t, armed, tm.graceInvoiceID, + "overage landed on the combined creation invoice, not a stray second one") + } + require.True(t, tm.graceResolved, "all 7 timers now terminally resolved") + } + require.Equal(t, 2, charged, "exactly the 2 over-modules were charged") + require.Len(t, store.invoices, 1, "exactly ONE invoice ever — the combined creation invoice") +} + +// --- Scenario 4: pool crosses 5 later → two independent prorated charges ------- + +func TestScenario4_PoolCrossesFiveLaterPerModuleTimers(t *testing.T) { + // Two modules installed a day apart, each pushing the account-wide pool over 5, + // get their OWN independently-anchored 3-day grace and two DIFFERENT prorated + // charges on two DIFFERENT days (install-anchored to [install, period end)): + // * module A installed Jun 10 → grace ends Jun 13 → $3 × 24/30 = $2.40 (240¢) + // * module B installed Jun 11 → grace ends Jun 14 → $3 × 23/30 = $2.30 (230¢) + store := newFakeStore() + _, acct := registeredAccount(store) + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + ctx := context.Background() + + // 5 pre-existing included modules → the two newcomers land in the "over" bucket. + seedIncluded(store, acct, uuid.New(), timeUTC(2026, 5, 4, 0), 5) + app := uuid.New() + timerA := seedTimer(store, acct, app, timeUTC(2026, 6, 10, 0)) + timerB := seedTimer(store, acct, app, timeUTC(2026, 6, 11, 0)) + + // Sweep Jun 13: only A is past its own grace → one charge, $2.40. + resA, err := svc.SweepModuleOverage(ctx, timeUTC(2026, 6, 13, 9)) + require.NoError(t, err) + require.Equal(t, 1, resA.Charged) + require.Len(t, sc.itemCalls, 1) + require.EqualValues(t, 240, sc.itemCalls[0].amountCfg, "A: $3 × 24/30 = $2.40") + require.Equal(t, "mod-overage-ii-"+timerA.String(), sc.itemCalls[0].idemKey) + require.False(t, store.timers[timerB].graceResolved, "B is still in its own grace") + + // Sweep Jun 14: B's grace now elapsed → a DIFFERENT amount on a DIFFERENT day; + // A is already resolved and never re-charged. + resB, err := svc.SweepModuleOverage(ctx, timeUTC(2026, 6, 14, 9)) + require.NoError(t, err) + require.Equal(t, 1, resB.Charged) + require.Len(t, sc.itemCalls, 2, "A must not be charged again") + require.EqualValues(t, 230, sc.itemCalls[1].amountCfg, "B: $3 × 23/30 = $2.30") + require.Equal(t, "mod-overage-ii-"+timerB.String(), sc.itemCalls[1].idemKey) + require.True(t, store.timers[timerA].graceCharged) + require.True(t, store.timers[timerB].graceCharged) +} + +// --- Scenario 5: the shared auto-collect helper fires at EVERY charge site ----- + +func TestScenario5_LargeAutoCollectFlagAtEveryChargeSite(t *testing.T) { + // The SAME flagLargeAutoCollect helper (migration 034) sets is_large_auto_collect + // on the mirror row of EVERY off-session charge — the creation/combined leg, the + // per-module grace leg (Leg 1), and the boundary leg (Leg 2) — resolved AT CHARGE + // TIME against the account's threshold. A per-account override BELOW the charged + // amount flags it; the default $100 (nil override) does not, at every site. + onlyMirror := func(store *fakeStore) cycle.InvoiceMirror { + require.Len(t, store.invoices, 1) + for _, m := range store.invoices { + return m + } + return cycle.InvoiceMirror{} + } + + t.Run("creation/combined leg", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + user, _ := registeredAccount(store) + store.collection.AutoCollectThresholdMicros = threshold + sc := newFakeStripe() + svc := appsSvc(store, sc) + appID := uuid.New() + registerMirror(t, svc, user, appID, scenarioCreatedAt, 0) // $10 base charge + _, err := svc.SweepCreationProrations(context.Background(), scenarioSweepAt) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(5_000_000)).IsLargeAutoCollect, "$10 base > $5 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$10 base < $100 default → not flagged") + }) + + t.Run("per-module grace leg (Leg 1)", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + _, acct := registeredAccount(store) + store.collection.AutoCollectThresholdMicros = threshold + sc := newFakeStripe() + svc := cycle.NewService(store, sc) + seedIncluded(store, acct, uuid.New(), timeUTC(2026, 5, 4, 0), 5) + seedTimer(store, acct, uuid.New(), timeUTC(2026, 6, 19, 0)) // over, $1.50 + _, err := svc.SweepModuleOverage(context.Background(), timeUTC(2026, 6, 25, 9)) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(1_000_000)).IsLargeAutoCollect, "$1.50 overage > $1 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$1.50 overage < $100 default → not flagged") + }) + + t.Run("boundary leg (Leg 2)", func(t *testing.T) { + run := func(threshold *int64) cycle.InvoiceMirror { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_s5" + store.collection.AutoCollectThresholdMicros = threshold + seedApp(store, chargeAccount, 0, false) // $20 advance base + sc := newFakeStripe() + _, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + return onlyMirror(store) + } + require.True(t, run(ptrI64(5_000_000)).IsLargeAutoCollect, "$20 boundary > $5 threshold → flagged") + require.False(t, run(nil).IsLargeAutoCollect, "$20 boundary < $100 default → not flagged") + }) +} + +// --- Scenario 6: boundary = arrears + base + ongoing-over-module overage ------- + +func TestScenario6_BoundaryPrechargesOngoingOverModulesOnly(t *testing.T) { + // At the period boundary the ONE invoice = closed period's usage arrears + + // the new period's FLAT base (per live pre-existing app) + a FULL $3 precharge + // for every ONGOING over-module (a live "over" timer already charged at least + // once). A timer still inside its OWN grace (never charged) is NOT double-counted. + store := newFakeStore() + store.chargedTotal = 1_000_000 // $1 usage arrears + store.hasPM = true + store.stripeCustomer = "cus_s6" + app := seedApp(store, chargeAccount, 0, false) // one live pre-existing app → $20 base + + // 5 included (ranks 0-4) + two ONGOING over-modules already charged in an + // earlier period (ranks 5-6) + one over-module STILL in its own grace (rank 7, + // never charged). Only the two ongoing ones are precharged for the new period. + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + ongoing1 := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 10, 0)) + ongoing2 := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 11, 0)) + // One over-module STILL inside its own grace (never charged) — excluded below. + seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 28, 0)) + for _, id := range []uuid.UUID{ongoing1, ongoing2} { + store.timers[id].graceResolved = true + store.timers[id].graceCharged = true // charged in a prior period → ongoing + } + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.EqualValues(t, 1_000_000, resp.ArrearsMicros) + require.EqualValues(t, usage.BaseFeeMicros, resp.AdvanceBaseMicros, "one live app → $20 base") + require.EqualValues(t, 2*usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "only the 2 ONGOING over-modules are precharged; the in-grace one is excluded") + + // One invoice, ONE pooled line: $1 arrears + $20 base + 2 × $3 overage = $27 → 2700¢. + require.Len(t, sc.invoiceCalls, 1) + require.Len(t, sc.itemCalls, 1, "arrears + base + overage pool into ONE line") + require.EqualValues(t, 2_700, resp.ChargedCents) + require.EqualValues(t, 2_700, sc.itemCalls[0].amountCfg) +} + +// Regression (review 2026-07-06, H1): a module installed INSIDE the new period +// whose own grace already elapsed and was charged by Leg 1 (install-anchored, +// covering that same new period) must NOT be precharged again by a late/reclaimed +// boundary run — the advance-overage leg needs the same installed_at < periodEnd +// cutoff the advance-base leg has always had. +func TestScenario6_ReclaimedBoundaryNeverPrechargesInsidePeriodModule(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 + store.hasPM = true + store.stripeCustomer = "cus_h1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jul 2 — INSIDE the new period [Jul 1, Aug 1) — grace elapsed Jul 5 + // and Leg 1 charged it (prorated Jul 2 → Aug 1, i.e. covering the new period). + inside := seedTimer(store, chargeAccount, app, timeUTC(2026, 7, 2, 0)) + store.timers[inside].graceResolved = true + store.timers[inside].graceCharged = true + + // The delayed/reclaimed [Jun 1, Jul 1) boundary run executes on Jul 6. + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceOverageMicros, + "a module installed inside the new period was already covered by its own grace charge — precharging it again double-bills the period") +} + +// Regression (wave 2, D1): the precharge must NOT depend on the mutable +// grace_resolved flag. cmd/billing-cycle runs the boundary spine before the +// grace sweeps, so a timer whose grace expired in the ~24h before the boundary +// is still UNRESOLVED when the boundary run executes — keying on resolution +// excluded it, and (Leg 1's coverage being derived from immutable timestamps +// and stopping at the boundary) its post-boundary period was then billed by +// nobody. The predicate now uses immutable cutoffs only. +func TestScenario6_ExpiredButUnresolvedTimerStillPrecharged(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_d1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jun 25 → grace expired Jun 28, BEFORE the Jul 1 boundary — but + // no sweep has resolved it yet (the boundary runs first in the beat). + seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 25, 0)) + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "an expired-but-unresolved over-module is ongoing — its own Leg 1 charge stops at the boundary, so skipping it here gaps the new period") +} + +// Regression (review 2026-07-06, C1): an over-module resolved WITHOUT charge +// under the D1d period-closed posture (installed pre-activation, so its own +// install period is forgiven) still owes overage for every post-activation +// period. The old grace_charged_at IS NOT NULL predicate exempted such modules +// from ALL boundary precharges, forever. +func TestScenario6_D1dResolvedUnchargedOverModuleStillPrecharged(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_c1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Over-rank timer resolved terminally with NO charge (the D1d posture): + // grace_resolved = true, grace_charged_at never set. + d1d := seedTimer(store, chargeAccount, app, timeUTC(2026, 5, 10, 0)) + store.timers[d1d].graceResolved = true + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.EqualValues(t, usage.ModuleOverageFeeMicros, resp.AdvanceOverageMicros, + "a D1d resolved-uncharged over-module is ongoing — only its pre-activation install period is forgiven, not every period after") +} + +// Regression (review 2026-07-06, H2): an app created within GraceDays of the +// boundary is still IN GRACE when the boundary runs — it must NOT be precharged +// the new period's base. It can still be deleted for free (scenario 1: deleted +// within grace is NEVER charged); an app that survives has the straddled period +// billed by its own creation charge, and joins the advance leg at the NEXT +// boundary. Pre-fix the boundary billed it a full month while still deletable. +func TestScenario6_AppStillInGraceAtBoundaryNotPrechargedBase(t *testing.T) { + store := newFakeStore() + store.chargedTotal = 1_000_000 // keep the invoice non-zero without any base + store.hasPM = true + store.stripeCustomer = "cus_h2" + // Created Jun 29 — inside period A [Jun 1, Jul 1) but within GraceDays of + // the Jul 1 boundary, so its grace (expires Jul 2) straddles it. + seedAppCreated(store, chargeAccount, 0, false, timeUTC(2026, 6, 29, 0)) + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceBaseMicros, + "an app still inside its creation grace at the boundary is not precharged — deleted-in-grace must stay free, and a survivor's creation charge covers the straddled period") + require.EqualValues(t, 1_000_000, resp.ArrearsMicros) +} + +// Regression (review 2026-07-06, M1 boundary side): a charged over-module whose +// grace STRADDLES the boundary is excluded from the precharge — its own Leg 1 +// charge covers through the END of the period its grace elapses into, so the +// precharge counting it would double-bill, and Leg 1's coverage means skipping +// it leaves no gap. +func TestScenario6_StraddlingGraceExcludedFromPrecharge(t *testing.T) { + store := newFakeStore() + store.hasPM = true + store.stripeCustomer = "cus_m1" + app := seedApp(store, chargeAccount, 0, false) + + seedIncluded(store, chargeAccount, app, timeUTC(2026, 5, 1, 0), 5) + // Installed Jun 29 → grace expires Jul 2, past the Jul 1 boundary. Already + // charged by (a delayed) Leg 1 covering install → end of the period the grace + // elapsed into. + straddle := seedTimer(store, chargeAccount, app, timeUTC(2026, 6, 29, 0)) + store.timers[straddle].graceResolved = true + store.timers[straddle].graceCharged = true + + sc := newFakeStripe() + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), chargeAccount, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Zero(t, resp.AdvanceOverageMicros, + "a boundary-straddling grace is Leg 1's coverage — the precharge must not double-bill the new period") +} diff --git a/internal/account/cycle/service.go b/internal/account/cycle/service.go index 62d4710..8feef99 100644 --- a/internal/account/cycle/service.go +++ b/internal/account/cycle/service.go @@ -9,6 +9,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/billing" "github.com/mirrorstack-ai/billing-engine/internal/account/usage" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) @@ -33,6 +34,62 @@ func NewService(store Store, stripe billingstripe.Client) *Service { return &Service{store: store, stripe: stripe, nowFn: time.Now} } +// periodClosedByActivation is the D1d no-retroactive-catch-up decision shared +// by the creation-proration leg and the module-overage leg: given the anchored +// period CONTAINING anchorInstant (an app's created_at, or a module timer's +// installed_at) resolved against the account's activation anchor, report +// whether the account only activated AT OR AFTER that period's end — i.e. was +// never chargeable for the period's ENTIRE duration, so charging now (however +// late the sweep runs) would be retroactive catch-up. Compared against +// activatedAt, NOT "now": ordinary sweep/grace delay pushing a charge a few +// days past periodEnd for an already-activated account is expected, not +// retroactive, and must still charge normally — only the caller decides what +// to persist when closed=true (each leg's own permanent-skip marker). +func periodClosedByActivation(anchorInstant, activatedAt time.Time) (periodStart, periodEnd time.Time, closed bool) { + anchorDay := billingperiod.AnchorDay(activatedAt) + periodStart, periodEnd = billingperiod.AnchoredPeriodWindow(anchorInstant.UTC(), anchorDay) + return periodStart, periodEnd, !activatedAt.Before(periodEnd) +} + +// offSessionChargePermitted is the collection-mode gate shared by the +// creation-proration and per-module-overage legs (review 2026-07-06, H10): an +// account in PREPAID mode is never auto-charged off-session — by ANY leg, not +// only the boundary spine (which gates itself inside RunBillingCycle). The +// skip must be TRANSIENT (retried, nothing resolved/armed): the webhook-driven +// relax can flip the account back to arrears, at which point the deferred +// charges fire through their unchanged deterministic idem keys. +func (s *Service) offSessionChargePermitted(ctx context.Context, accountID uuid.UUID) (bool, error) { + acct, err := s.store.AccountCollection(ctx, accountID) + if err != nil { + return false, billing.Internal("account collection lookup failed", err) + } + return acct.Mode != BillingModePrepaid, nil +} + +// resolveChargeableCustomer is the PM-gate + Stripe-customer resolution shared +// by all three charge legs (RunBillingCycle, ChargeCreationProration, +// ChargeModuleOverage): no usable default PM -> ok=false (the caller's own +// skip status, transient/retried); a usable PM implies a Stripe Customer (a +// card can't attach without one) -> an empty custID here is an anomaly, +// surfaced as an error rather than silently auto-creating a Customer. +func (s *Service) resolveChargeableCustomer(ctx context.Context, accountID uuid.UUID) (custID string, ok bool, err error) { + hasPM, err := s.store.HasUsableDefaultPM(ctx, accountID) + if err != nil { + return "", false, billing.Internal("usable PM check failed", err) + } + if !hasPM { + return "", false, nil + } + custID, err = s.store.AccountStripeCustomer(ctx, accountID) + if err != nil { + return "", false, billing.Internal("stripe customer lookup failed", err) + } + if custID == "" { + return "", false, billing.Internal("account has a usable PM but no Stripe customer id", nil) + } + return custID, true, nil +} + // WithNow overrides the Service clock — deterministic-test hook only (mirrors // the usage.Service nowFn seam). Returns the Service for chaining. func (s *Service) WithNow(now func() time.Time) *Service { diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 2026d7f..1397f80 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "math" + "sort" "testing" "time" @@ -51,6 +52,9 @@ type fakeStore struct { activatedAccounts []cycle.AccountAnchor // ActivatedAccounts return latestPeriodEnd map[uuid.UUID]time.Time // LatestClosedPeriodEnd return (absent → not found) + // onFreezeCharge runs at the top of FreezeBillingRunCharge (see there). + onFreezeCharge func(runID uuid.UUID) + // risk-graded collection inputs (PR #9) collection cycle.AccountCollection // AccountCollection return unpaidInvoice bool // HasUnpaidInvoice return (delinquency signal) @@ -69,43 +73,89 @@ type fakeStore struct { activation map[uuid.UUID]time.Time baseSnapshots map[snapKey]fakeBaseSnapshot + // per-module install-timer state (migration 033). timers models + // ms_billing.app_module_overage_timers keyed by surrogate id; each row's + // removed/graceResolved/graceCharged* fields mirror the columns the FIFO + + // Leg 1 sweep read and write. + timers map[uuid.UUID]*fakeTimer + // captured charge writes - insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) - runStatus map[uuid.UUID]cycle.BillingRunStatus // run id → current status (models the DB row's terminal state) - markedRuns map[uuid.UUID]markedRun // run id → terminal mark - invoices map[string]cycle.InvoiceMirror // stripe_invoice_id → mirror + insertedRuns map[string]uuid.UUID // (account/start/end) → run id (the idempotency gate state) + runStatus map[uuid.UUID]cycle.BillingRunStatus // run id → current status (models the DB row's terminal state) + markedRuns map[uuid.UUID]markedRun // run id → terminal mark + invoices map[string]cycle.InvoiceMirror // stripe_invoice_id → mirror + frozenCharges map[uuid.UUID]cycle.FrozenBoundaryCharge // run id → frozen boundary charge (migration 035); survives a reclaim // injected errors - errOpen error - errRaw error - errPrice error - errUpsert error - errIncome error - errVis error - errSettle error - errInsertRun error - errTotal error - errPM error - errCustomer error - errInvoice error - errMarkRun error - errUnbilled error - errUsageEvents error - errActivated error // ActivatedAccounts - errLatestPeriod error // LatestClosedPeriodEnd - errCollection error // AccountCollection - errUpdateColl error // UpdateAccountCollection - errUnpaid error // HasUnpaidInvoice - errEnsureAcct error // EnsureAccountForUser - errActivation error // AccountActivation - errAppInsert error // InsertAppMirror - errAppMirror error // AppMirror - errSetProration error // SetAppProrationInvoice - errSetCount error // SetAppModuleCount - errMarkDeleted error // MarkAppDeleted - errLiveCounts error // LiveAppsCreatedBefore - errProrationSnap error // UpsertProrationBaseSnapshot - errAdvanceSnap error // InsertAdvanceBaseSnapshot + errOpen error + errRaw error + errPrice error + errUpsert error + errIncome error + errVis error + errSettle error + errInsertRun error + errTotal error + errPM error + errCustomer error + errInvoice error + errMarkRun error + errUnbilled error + errUsageEvents error + errActivated error // ActivatedAccounts + errLatestPeriod error // LatestClosedPeriodEnd + errCollection error // AccountCollection + errUpdateColl error // UpdateAccountCollection + errUnpaid error // HasUnpaidInvoice + errEnsureAcct error // EnsureAccountForUser + errActivation error // AccountActivation + errAppInsert error // InsertAppMirror + errAppMirror error // AppMirror + errSetProration error // SetAppProrationInvoice + errSetSkipped error // SetAppProrationSkipped + errSetCount error // SetAppModuleCount + errMarkDeleted error // MarkAppDeleted + errLiveCounts error // LiveAppsCreatedBefore + errProrationSnap error // UpsertProrationBaseSnapshot + errAdvanceSnap error // InsertAdvanceBaseSnapshot + errPendingProration error // AppsPendingProration + errChargeLocked error // ChargeProrationLocked + // errPersistAfterStripe fails ChargeProrationLocked's persist phase (Phase 3) + // AFTER the charge callback's Stripe calls already succeeded — modeling a + // combined-invoice charge whose guard/timer marks fail to commit (deadlock / + // transient tx error) even though Stripe already moved the money. + errPersistAfterStripe error + errFreezeCharge error // FreezeBillingRunCharge + errFrozenCharge error // BillingRunFrozenCharge + + errLiveTimerCount error // LiveModuleTimerCountForApp + errInsertTimers error // InsertModuleOverageTimers + errReconcileTimers error // ReconcileModuleTimersToTarget + errRemoveNewest error // SoftRemoveNewestModuleTimers + errRemoveAllTimers error // SoftRemoveAllModuleTimersForApp + errTimersPastGrace error // ModuleOverageTimersPastGrace + errTimerRank error // LiveModuleTimerRankBefore + errMarkIncluded error // MarkModuleTimerIncluded + errMarkTimerCharged error // MarkModuleTimerCharged + errCountOngoingOver error // CountOngoingOverModuleTimers + errCoCreatedOver error // CoCreatedOverModuleTimers +} + +// fakeTimer models one ms_billing.app_module_overage_timers row (migration 033). +type fakeTimer struct { + id uuid.UUID + accountID uuid.UUID + appID uuid.UUID + installedAt time.Time + graceExpiresAt time.Time + removed bool + removedAt time.Time + graceResolved bool + graceCharged bool + graceChargedAt time.Time + graceInvoiceID string + graceInvoiceItemID string + chargeAttemptedAt time.Time // migration-036 recovery marker } // snapKey mirrors the app_base_snapshots PRIMARY KEY (app_id, period_start). @@ -141,10 +191,12 @@ func newFakeStore() *fakeStore { runStatus: map[uuid.UUID]cycle.BillingRunStatus{}, markedRuns: map[uuid.UUID]markedRun{}, invoices: map[string]cycle.InvoiceMirror{}, + frozenCharges: map[uuid.UUID]cycle.FrozenBoundaryCharge{}, apps: map[uuid.UUID]cycle.AppMirror{}, accountsByUser: map[uuid.UUID]uuid.UUID{}, activation: map[uuid.UUID]time.Time{}, baseSnapshots: map[snapKey]fakeBaseSnapshot{}, + timers: map[uuid.UUID]*fakeTimer{}, // Default collection state: arrears mode with a high credit limit + no // spend ceiling, so the existing charge tests (which don't set risk // fields) flow through the gate to the charge path unchanged. Risk tests @@ -334,6 +386,48 @@ func (f *fakeStore) MarkBillingRun(_ context.Context, runID uuid.UUID, status cy return nil } +// FreezeBillingRunCharge records the run's frozen boundary charge (migration 035). +// First-write-wins, mirroring the SQL's WHERE frozen_charge_cents IS NULL: a +// reclaim that already froze keeps the ORIGINAL values, so a retry can never +// overwrite the amount a crashed attempt already put through Stripe. +func (f *fakeStore) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) { + // Mirrors the SQL guard: the terminal zero-mark loses to a concurrent freeze. + if _, frozen := f.frozenCharges[runID]; frozen { + return false, nil + } + if err := f.MarkBillingRun(ctx, runID, cycle.RunStatusInvoiced, "", 0); err != nil { + return false, err + } + return true, nil +} + +func (f *fakeStore) FreezeBillingRunCharge(_ context.Context, runID uuid.UUID, charge cycle.FrozenBoundaryCharge) (cycle.FrozenBoundaryCharge, error) { + if f.errFreezeCharge != nil { + return cycle.FrozenBoundaryCharge{}, f.errFreezeCharge + } + // onFreezeCharge, when set, runs BEFORE this process's write — modeling a + // concurrent second daemon that reclaimed the same run and froze first (its + // write lands in the race window between the caller's top-of-run frozen read + // and this freeze). Used by the H6 regression test. + if f.onFreezeCharge != nil { + f.onFreezeCharge(runID) + } + // First-write-wins, returning the SURVIVING value (mirrors the SQL's + // WHERE frozen_charge_cents IS NULL + read-back). + if _, exists := f.frozenCharges[runID]; !exists { + f.frozenCharges[runID] = charge + } + return f.frozenCharges[runID], nil +} + +func (f *fakeStore) BillingRunFrozenCharge(_ context.Context, runID uuid.UUID) (cycle.FrozenBoundaryCharge, bool, error) { + if f.errFrozenCharge != nil { + return cycle.FrozenBoundaryCharge{}, false, f.errFrozenCharge + } + c, ok := f.frozenCharges[runID] + return c, ok, nil +} + func (f *fakeStore) AccountsWithUsageEvents(_ context.Context, _, _ time.Time) ([]uuid.UUID, error) { if f.errUsageEvents != nil { return nil, f.errUsageEvents @@ -393,7 +487,9 @@ func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUI return nil // ON CONFLICT (app_id) DO NOTHING — the FIRST registration wins } f.apps[appID] = cycle.AppMirror{ - AppID: appID, AccountID: accountID, ModuleCount: moduleCount, CreatedAt: createdAt, + AppID: appID, AccountID: accountID, ModuleCount: moduleCount, + CreatedModuleCount: moduleCount, // frozen at insert, mirroring InsertAppMirror's $3/$3 write + CreatedAt: createdAt, } return nil } @@ -417,6 +513,17 @@ func (f *fakeStore) SetAppProrationInvoice(_ context.Context, appID uuid.UUID, s return nil } +func (f *fakeStore) SetAppProrationSkipped(_ context.Context, appID uuid.UUID) error { + if f.errSetSkipped != nil { + return f.errSetSkipped + } + if app, ok := f.apps[appID]; ok && !app.ProrationSkipped && app.ProrationInvoiceID == "" { + app.ProrationSkipped = true // first-write-wins, like the WHERE … IS NULL guard + f.apps[appID] = app + } + return nil +} + func (f *fakeStore) SetAppModuleCount(_ context.Context, appID uuid.UUID, moduleCount int) error { if f.errSetCount != nil { return f.errSetCount @@ -440,16 +547,95 @@ func (f *fakeStore) MarkAppDeleted(_ context.Context, appID uuid.UUID) error { return nil } -func (f *fakeStore) LiveAppsCreatedBefore(_ context.Context, accountID uuid.UUID, createdBefore time.Time) ([]cycle.AppModuleCount, error) { +func (f *fakeStore) AppsPendingProration(_ context.Context, createdBefore time.Time) ([]uuid.UUID, error) { + if f.errPendingProration != nil { + return nil, f.errPendingProration + } + // Mirrors the SQL: created_at <= cutoff AND proration_invoice_id IS NULL AND + // (deleted_at IS NULL OR deleted after the grace elapsed — D11: a survivor + // still owes) AND proration_skipped_at IS NULL. Sorted by created_at for a + // deterministic sweep order. + var out []uuid.UUID + for id, app := range f.apps { + deletedInGrace := app.Deleted && app.DeletedAt.Before(app.CreatedAt.UTC().AddDate(0, 0, 3)) + if app.ProrationInvoiceID == "" && !deletedInGrace && !app.ProrationSkipped && !app.CreatedAt.After(createdBefore) { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { + return f.apps[out[i]].CreatedAt.Before(f.apps[out[j]].CreatedAt) + }) + return out, nil +} + +// ChargeProrationLocked models the pgxStore's FOR UPDATE-locked charge section: +// it re-checks the terminal state (the fake's in-memory row is the "locked" +// read), invokes charge only when still chargeable, and persists the invoice + +// snapshot + arms the guard on a non-nil payload — exactly what the real tx does +// atomically. +func (f *fakeStore) ChargeProrationLocked(_ context.Context, appID uuid.UUID, charge func(cycle.AppMirror) (*cycle.ProrationCharge, error)) (cycle.ProrationOutcome, string, error) { + if f.errChargeLocked != nil { + return 0, "", f.errChargeLocked + } + app, ok := f.apps[appID] + if !ok { + return cycle.ProrationLockedNotFound, "", nil + } + if app.Deleted && app.DeletedAt.Before(app.CreatedAt.UTC().AddDate(0, 0, 3)) { + return cycle.ProrationLockedDeleted, "", nil // deleted WITHIN grace only (D11) + } + if app.ProrationInvoiceID != "" { + return cycle.ProrationLockedAlreadyCharged, app.ProrationInvoiceID, nil + } + pc, err := charge(app) + if err != nil { + return 0, "", err + } + if pc == nil { + return cycle.ProrationLockedNoCharge, "", nil + } + // Phase 3 failure AFTER the callback's Stripe calls already succeeded: the + // money moved, but the guard-arm + timer marks never commit. Models a + // deadlock/transient tx error so a test can prove the co-created over-module + // timers stay unresolved (and are NOT independently re-invoiced by Leg 1). + if f.errPersistAfterStripe != nil { + return 0, "", f.errPersistAfterStripe + } + f.invoices[pc.Invoice.StripeInvoiceID] = pc.Invoice + f.baseSnapshots[snapKey{pc.Snapshot.AppID, pc.Snapshot.PeriodStart}] = fakeBaseSnapshot{snap: pc.Snapshot, source: "proration"} + if pc.StraddleSnapshot != nil { + f.baseSnapshots[snapKey{pc.StraddleSnapshot.AppID, pc.StraddleSnapshot.PeriodStart}] = fakeBaseSnapshot{snap: *pc.StraddleSnapshot, source: "proration"} + } + app.ProrationInvoiceID = pc.InvoiceID // first-charge-wins, like WHERE … IS NULL under the lock + f.apps[appID] = app + // Scenario 3 — mark the co-created over-module timers billed on this combined + // invoice terminally charged, in the SAME "transaction" (first-write-wins on + // grace_resolved, like the real MarkModuleTimerCharged WHERE grace_resolved = false). + for _, tc := range pc.TimerCharges { + if t, ok := f.timers[tc.TimerID]; ok && !t.graceResolved { + t.graceResolved = true + t.graceCharged = true + t.graceChargedAt = tc.ChargedAt + t.graceInvoiceID = tc.InvoiceID + t.graceInvoiceItemID = tc.InvoiceItemID + } + } + return cycle.ProrationLockedCharged, pc.InvoiceID, nil +} + +func (f *fakeStore) LiveAppsCreatedBefore(_ context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]cycle.AppModuleCount, error) { if f.errLiveCounts != nil { return nil, f.errLiveCounts } apps := []cycle.AppModuleCount{} for _, app := range f.apps { - // Strictly-before cutoff, mirroring the SQL's created_at < $2: an app - // created ON or AFTER the new period's start is excluded (its base is - // RegisterApp's proration leg's, never the advance leg's). - if app.AccountID == accountID && !app.Deleted && app.CreatedAt.Before(createdBefore) { + // Strictly-before cutoffs, mirroring the SQL: an app created ON or AFTER + // the new period's start is excluded (its base is the proration leg's, + // never the advance leg's), and so is an app whose creation grace had not + // yet elapsed when the new period opened (H2 — it hasn't survived grace, + // and its creation charge covers through the grace-elapsed period). + if app.AccountID == accountID && !app.Deleted && app.CreatedAt.Before(createdBefore) && + app.CreatedAt.AddDate(0, 0, graceDays).Before(createdBefore) { apps = append(apps, cycle.AppModuleCount{AppID: app.AppID, ModuleCount: app.ModuleCount}) } } @@ -480,6 +666,273 @@ func (f *fakeStore) InsertAdvanceBaseSnapshot(_ context.Context, snap cycle.AppB return nil } +// --- per-module install-timer fake (migration 033) -------------------------- + +func (f *fakeStore) LiveModuleTimerCountForApp(_ context.Context, appID uuid.UUID) (int, error) { + if f.errLiveTimerCount != nil { + return 0, f.errLiveTimerCount + } + n := 0 + for _, t := range f.timers { + if t.appID == appID && !t.removed { + n++ + } + } + return n, nil +} + +func (f *fakeStore) InsertModuleOverageTimers(_ context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error { + if f.errInsertTimers != nil { + return f.errInsertTimers + } + for i := 0; i < n; i++ { + id := uuid.New() + f.timers[id] = &fakeTimer{ + id: id, + accountID: accountID, + appID: appID, + installedAt: installedAt, + graceExpiresAt: graceExpiresAt, + } + } + return nil +} + +// liveTimersForApp returns the app's live timers sorted (installed_at DESC, id +// DESC) — the LIFO removal order. +func (f *fakeStore) liveTimersForApp(appID uuid.UUID) []*fakeTimer { + var out []*fakeTimer + for _, t := range f.timers { + if t.appID == appID && !t.removed { + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { + if !out[i].installedAt.Equal(out[j].installedAt) { + return out[i].installedAt.After(out[j].installedAt) + } + return out[i].id.String() > out[j].id.String() + }) + return out +} + +func (f *fakeStore) SoftRemoveNewestModuleTimers(_ context.Context, appID uuid.UUID, n int, removedAt time.Time) error { + if f.errRemoveNewest != nil { + return f.errRemoveNewest + } + live := f.liveTimersForApp(appID) + for i := 0; i < n && i < len(live); i++ { + live[i].removed = true + live[i].removedAt = removedAt + } + return nil +} + +func (f *fakeStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error { + if f.errReconcileTimers != nil { + return f.errReconcileTimers + } + // Mirrors the pgx locked reconcile (wave 2, D8/D9): the target, account, and + // deleted state come from the CURRENT roster row — never the caller; a + // deleted row reconciles to zero. (Unit tests are single-threaded; the + // advisory-lock serialization itself is exercised by the integration test.) + app, ok := f.apps[appID] + if !ok { + return nil + } + target := app.ModuleCount + if app.Deleted { + target = 0 + } + live, err := f.LiveModuleTimerCountForApp(ctx, appID) + if err != nil { + return err + } + switch { + case target > live: + return f.InsertModuleOverageTimers(ctx, app.AccountID, appID, installedAt, graceExpiresAt, target-live) + case target < live: + return f.SoftRemoveNewestModuleTimers(ctx, appID, live-target, removedAt) + } + return nil +} + +func (f *fakeStore) MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + if f.errMarkDeleted != nil { + return f.errMarkDeleted + } + // DeletedAt = the service clock's removedAt (the real SQL uses now() in the + // same transaction) — NOT the test host's wall clock, which would misplace + // the deletion relative to the fixtures' grace windows (D11 pivots on it). + if app, ok := f.apps[appID]; ok && !app.Deleted { + app.Deleted = true + app.DeletedAt = removedAt + f.apps[appID] = app + } + return f.SoftRemoveAllModuleTimersForApp(ctx, appID, removedAt) +} + +func (f *fakeStore) SoftRemoveAllModuleTimersForApp(_ context.Context, appID uuid.UUID, removedAt time.Time) error { + if f.errRemoveAllTimers != nil { + return f.errRemoveAllTimers + } + for _, t := range f.timers { + if t.appID == appID && !t.removed { + t.removed = true + t.removedAt = removedAt + } + } + return nil +} + +func (f *fakeStore) ModuleOverageTimersPastGrace(_ context.Context, at time.Time) ([]cycle.ModuleOverageCandidate, error) { + if f.errTimersPastGrace != nil { + return nil, f.errTimersPastGrace + } + var out []cycle.ModuleOverageCandidate + for _, t := range f.timers { + if t.removed || t.graceResolved || t.graceExpiresAt.After(at) { + continue + } + activatedAt, activated := f.activation[t.accountID] + if !activated { + continue // activated_at IS NOT NULL gate + } + out = append(out, cycle.ModuleOverageCandidate{ + ID: t.id, + AccountID: t.accountID, + AppID: t.appID, + InstalledAt: t.installedAt, + GraceExpiresAt: t.graceExpiresAt, + ChargeAttemptedAt: t.chargeAttemptedAt, + ActivatedAt: activatedAt, + }) + } + // Ordered (installed_at, id) like the query, so the sweep charges oldest-first + // deterministically. + sort.Slice(out, func(i, j int) bool { + if !out[i].InstalledAt.Equal(out[j].InstalledAt) { + return out[i].InstalledAt.Before(out[j].InstalledAt) + } + return out[i].ID.String() < out[j].ID.String() + }) + return out, nil +} + +func (f *fakeStore) LiveModuleTimerRankBefore(_ context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) { + if f.errTimerRank != nil { + return 0, f.errTimerRank + } + rank := 0 + for _, t := range f.timers { + if t.accountID != accountID || t.removed { + continue + } + if t.installedAt.Before(installedAt) || + (t.installedAt.Equal(installedAt) && t.id.String() < timerID.String()) { + rank++ + } + } + return rank, nil +} + +func (f *fakeStore) MarkModuleTimerChargeAttempted(_ context.Context, timerID uuid.UUID, at time.Time) error { + if t, ok := f.timers[timerID]; ok && t.chargeAttemptedAt.IsZero() { + t.chargeAttemptedAt = at // first-write-wins, mirroring the SQL + } + return nil +} + +func (f *fakeStore) ModuleTimerStillPending(_ context.Context, timerID uuid.UUID) (bool, error) { + t, ok := f.timers[timerID] + if !ok { + return false, nil + } + return !t.removed && !t.graceResolved, nil +} + +func (f *fakeStore) MarkAppProrationAttempted(_ context.Context, appID uuid.UUID, _ time.Time) error { + if app, ok := f.apps[appID]; ok && !app.ProrationAttempted { + app.ProrationAttempted = true // first-write-wins + f.apps[appID] = app + } + return nil +} + +func (f *fakeStore) MarkModuleTimerIncluded(_ context.Context, timerID uuid.UUID) error { + if f.errMarkIncluded != nil { + return f.errMarkIncluded + } + if t, ok := f.timers[timerID]; ok && !t.graceResolved { + t.graceResolved = true + } + return nil +} + +func (f *fakeStore) MarkModuleTimerCharged(_ context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error { + if f.errMarkTimerCharged != nil { + return f.errMarkTimerCharged + } + if t, ok := f.timers[timerID]; ok && !t.graceResolved { + t.graceResolved = true + t.graceCharged = true + t.graceChargedAt = chargedAt + t.graceInvoiceID = invoiceID + t.graceInvoiceItemID = invoiceItemID + } + return nil +} + +// liveTimersForAccountFIFO returns the account's live timers ordered (installed_at +// ASC, id ASC) — the FIFO order the rank/over predicates read. +func (f *fakeStore) liveTimersForAccountFIFO(accountID uuid.UUID) []*fakeTimer { + var out []*fakeTimer + for _, t := range f.timers { + if t.accountID == accountID && !t.removed { + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { + if !out[i].installedAt.Equal(out[j].installedAt) { + return out[i].installedAt.Before(out[j].installedAt) + } + return out[i].id.String() < out[j].id.String() + }) + return out +} + +func (f *fakeStore) CountOngoingOverModuleTimers(_ context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) { + if f.errCountOngoingOver != nil { + return 0, f.errCountOngoingOver + } + // Live FIFO: the first includedModules are "included"; the rest are "over". + // Count the "over" tail owed a precharge for the new period opening at + // periodEnd: installed before it, grace elapsed before it — IMMUTABLE + // cutoffs only (wave 2, D1: resolution state is sweep-ordering-dependent + // and deliberately not part of the predicate), mirroring the SQL. + n := 0 + for rank, t := range f.liveTimersForAccountFIFO(accountID) { + if rank >= includedModules && + t.installedAt.Before(periodEnd) && t.graceExpiresAt.Before(periodEnd) { + n++ + } + } + return n, nil +} + +func (f *fakeStore) CoCreatedOverModuleTimers(_ context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) { + if f.errCoCreatedOver != nil { + return nil, f.errCoCreatedOver + } + var out []uuid.UUID + for rank, t := range f.liveTimersForAccountFIFO(accountID) { + if rank >= includedModules && t.appID == appID && !t.graceResolved && t.installedAt.Equal(createdAt) { + out = append(out, t.id) + } + } + return out, nil +} + // --- helpers -------------------------------------------------------------- func requireCode(t *testing.T, err error, want billing.Code) { diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 3e248cd..e74664d 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -141,6 +141,30 @@ type Store interface { // (empty → NULL), and the charged total in whole cents. MarkBillingRun(ctx context.Context, runID uuid.UUID, status BillingRunStatus, stripeInvoiceID string, totalCents int64) error + // MarkBillingRunInvoicedIfUnfrozen terminally marks a ZERO-total run + // 'invoiced' (no Stripe call happened) — guarded on the run still being + // UNFROZEN (wave 2, D7): a concurrent reclaim may have frozen + charged + // after this process's top-of-run frozen read, and an unguarded terminal + // mark would bury that charge forever. ok=false → the guard lost; the + // caller must back off and leave the run reclaimable. + MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) + + // FreezeBillingRunCharge records — BEFORE the boundary run's first Stripe + // charge — the exact amount + base/overage description determinant it will send + // under the deterministic idem keys ii-/inv- (migration 035). + // First-write-wins, and it returns the SURVIVING row value: when a concurrent + // second daemon reclaimed the same run and froze first, the loser's write + // no-ops and it MUST adopt the returned winner value — charging a locally + // computed amount under the shared idem keys would send Stripe two different + // bodies for the same key (the H6 race). The retry path likewise never sends + // a request that differs from what a prior attempt froze. + FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) (FrozenBoundaryCharge, error) + + // BillingRunFrozenCharge reads a run's frozen boundary charge; ok=false when no + // prior attempt reached the Stripe call (a fresh run). On a reclaim it is the + // amount already charged, which the retry REUSES verbatim. + BillingRunFrozenCharge(ctx context.Context, runID uuid.UUID) (charge FrozenBoundaryCharge, ok bool, err error) + // AccountsWithUnbilledUsage returns the accounts that have usage_aggregates // in a closed period window [periodStart, periodEnd) with no billing_run yet // — the work list for cmd/billing-cycle. @@ -183,12 +207,51 @@ type Store interface { // deletion semantics). found=false → the app was never registered. AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, bool, error) + // AppsPendingProration returns the app ids past the creation grace window + // (created_at <= createdBefore = now − GraceDays) that are still LIVE + // (deleted_at IS NULL), NOT yet charged (proration_invoice_id IS NULL), and + // NOT permanently skipped (proration_skipped_at IS NULL, migration 031) — + // the creation-proration sweep's work list. An app deleted within grace, + // already charged, or already determined to be a would-be retroactive + // catch-up (D1d) is excluded. + AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) + + // ChargeProrationLocked runs the creation-proration charge for ONE app. It + // briefly SELECT ... FOR UPDATE-locks the roster row to re-verify the row is + // still chargeable (deleted_at IS NULL AND proration_invoice_id IS NULL) and + // read its frozen state, then RELEASES the lock before invoking charge — + // which performs the (potentially slow) Stripe network calls OUTSIDE any + // lock or transaction — and finally persists the mirrored invoice, the base + // snapshot, and the one-shot guard in a second short transaction. The lock is + // deliberately NOT held across the Stripe call (a prior version did; it could + // block a concurrent SyncAppModules/MarkAppDeleted write for the Stripe SDK's + // full ~80s-per-call timeout): a soft-delete that commits while the charge + // callback is in flight does NOT unwind an already-succeeded Stripe charge + // (D1e already forbids refunds — the money moved), so the persist step + // writes the invoice/snapshot/guard unconditionally on success. A second, + // genuinely concurrent charge attempt for the SAME app converges on the SAME + // Stripe objects (the deterministic per-app Idempotency-Keys) and the guard's + // first-write-wins UPDATE, so this stays race-safe without a lock spanning + // both phases. charge returning (nil, nil) means "nothing to charge" (0 + // cents) → nothing is persisted. The returned invoice id is the armed (or + // pre-armed) guard's. + ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(locked AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) + // SetAppProrationInvoice arms the ONE-SHOT creation-proration guard: it // records the Stripe invoice id, first-charge-wins (UPDATE … WHERE // proration_invoice_id IS NULL). An already-armed guard is NOT an error — // the write is a no-op and the original invoice id survives. SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, stripeInvoiceID string) error + // SetAppProrationSkipped arms the PERMANENT creation-proration skip marker + // (migration 031, D1d): the account only activated at/after this app's + // anchored creation period had already closed, so the app is EXCLUDED from + // every future sweep rather than left pending forever (proration_invoice_id + // stays NULL, so without this marker AppsPendingProration would resurface it + // on every sweep indefinitely). First-write-wins and a no-op if the app was + // somehow already charged in the meantime — never an error. + SetAppProrationSkipped(ctx context.Context, appID uuid.UUID) error + // SetAppModuleCount snapshots a new installed-module count. A deleted // app's count is frozen (the UPDATE's WHERE deleted_at IS NULL no-ops — // D1e: no future base, so no tier to move). @@ -199,14 +262,17 @@ type Store interface { MarkAppDeleted(ctx context.Context, appID uuid.UUID) error // LiveAppsCreatedBefore returns every LIVE (deleted_at IS NULL) app on the - // account created STRICTLY BEFORE createdBefore, with its module_count — - // the boundary charge's advance-base input. createdBefore is the NEW - // period's start (the closed window's period_end): an app created inside - // the new period is EXCLUDED because RegisterApp's creation-proration leg - // already owns that period's base (full or prorated) — it joins the + // account that has JOINED the advance-base mechanism by createdBefore (the + // NEW period's start, i.e. the closed window's period_end), with its + // module_count — the boundary charge's advance-base input. An app is + // excluded when created inside the new period (its creation-proration leg + // owns that period's base) OR when its creation grace (graceDays) had not + // yet elapsed by createdBefore (it hasn't survived grace — deleted-in-grace + // is never charged — and when it survives, its creation charge covers + // through the END of the period its grace elapses into). It joins the // advance leg at the NEXT boundary. Empty for a pre-backfill account → // advance base 0 (pre-027 behavior). - LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) ([]AppModuleCount, error) + LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]AppModuleCount, error) // UpsertProrationBaseSnapshot persists the creation-proration leg's // per-app-period base snapshot (migration 028, source='proration'), keyed @@ -221,6 +287,127 @@ type Store interface { // proration snapshot, or a prior reclaimed attempt's own row) wins, so a // re-run never rewrites what was already recorded as billed. InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSnapshot) error + + // --- per-module-instance overage timers (migration 033) ----------------- + + // LiveModuleTimerCountForApp returns the count of an app's currently-live + // (removed_at IS NULL) install timers — the reconciliation input RegisterApp + // / SyncAppModules use to bring the live-timer set into line with the app's + // module_count idempotently across fire-and-forget retries. + LiveModuleTimerCountForApp(ctx context.Context, appID uuid.UUID) (int, error) + + // InsertModuleOverageTimers inserts n identical install timers for one app, + // all anchored at installedAt with grace expiring at graceExpiresAt (= + // installedAt + the 3-day grace window). n <= 0 is a no-op. + InsertModuleOverageTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error + + // SoftRemoveNewestModuleTimers LIFO-soft-removes the n NEWEST currently-live + // install timers for one app (a SyncAppModules shrink removes what was added + // most recently). n <= 0 is a no-op. + SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error + + // SoftRemoveAllModuleTimersForApp soft-removes every still-live install timer + // for an app — the app-deletion path. Idempotent (WHERE removed_at IS NULL). + SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error + + // MarkModuleTimerChargeAttempted stamps the migration-036 recovery marker + // BEFORE a charge attempt's first Stripe call — first-write-wins, never + // cleared. A later retry seeing it set reconciles against Stripe (the + // ms_charge_ref anchor) before recomputing any live verdict or minting new + // Stripe objects. + MarkModuleTimerChargeAttempted(ctx context.Context, timerID uuid.UUID, at time.Time) error + + // ModuleTimerStillPending re-verifies, immediately before acting on a sweep + // candidate, that the timer is STILL live and unresolved — the work list is + // read once and can be minutes stale by the time a late candidate is + // processed (M2). + ModuleTimerStillPending(ctx context.Context, timerID uuid.UUID) (bool, error) + + // MarkAppProrationAttempted stamps the migration-036 recovery marker for the + // creation-proration leg — first-write-wins, never cleared. + MarkAppProrationAttempted(ctx context.Context, appID uuid.UUID, at time.Time) error + + // ReconcileModuleTimersToTarget brings an app's live install-timer set into + // line with its CURRENT roster row, ATOMICALLY under a per-app advisory + // transaction lock (review 2026-07-06, H7; hardened in wave 2, D8/D9): the + // target count, owning account, and deleted state are all read from the + // apps row INSIDE the locked transaction — never caller-supplied — so a + // late fire-and-forget retry can neither shrink timers to a stale + // module_count (D8) nor resurrect timers for an app deleted after its + // mirror read (D9: a deleted row reconciles to zero, removing any live + // orphans instead of inserting). A grow inserts the deficit anchored at + // installedAt/graceExpiresAt, a shrink LIFO-soft-removes the surplus at + // removedAt. The lock also serializes concurrent executions so two retries + // can never both insert the full deficit (phantom $3 timers). + ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error + + // MarkAppDeletedAndRemoveTimers soft-deletes the roster row AND soft-removes + // every still-live install timer in ONE transaction under the SAME per-app + // advisory lock the reconcile takes (wave 2, D9) — a crash can no longer + // separate the two writes, and a concurrent synthesis retry serializes + // behind (or ahead of, then gets corrected by) the deletion. Idempotent: + // re-fire keeps the first deletion instant and affects already-removed + // timers zero times. + MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error + + // ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install + // timers whose grace window has elapsed as of `at`, on chargeable (activated) + // accounts — each with the account's activation anchor so the sweep resolves + // the install's period window without a second read. + ModuleOverageTimersPastGrace(ctx context.Context, at time.Time) ([]ModuleOverageCandidate, error) + + // LiveModuleTimerRankBefore returns the 0-based FIFO rank of one install timer + // among the account's currently-live timers ordered (installed_at ASC, id + // ASC): the count of live timers ordering STRICTLY BEFORE it. rank < + // IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". Computed + // fresh at every grace-check (never cached). + LiveModuleTimerRankBefore(ctx context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) + + // MarkModuleTimerIncluded stamps the TERMINAL "included" verdict + // (grace_resolved=true, no charge) — first-write-wins (WHERE grace_resolved + // IS false). Monotonicity makes it permanent; the row is never re-checked. + MarkModuleTimerIncluded(ctx context.Context, timerID uuid.UUID) error + + // MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once + // Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved=true and + // the GENUINE Stripe invoice / invoice-item ids (never idempotency-key + // strings). WHERE grace_resolved IS false keeps a crash-retry idempotent. + MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error + + // CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario + // 6): the count of the account's live timers that are "over" (live-FIFO rank + // >= includedModules) AND owed a full precharge for the NEW period opening at + // periodEnd — installed before it, grace elapsed before it (a straddling + // grace's new period is Leg 1's coverage), and grace terminally resolved + // (charged, OR resolved-uncharged via the D1d period-closed posture — those + // still owe every post-activation period). See the query comment for the full + // coverage contract. + CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) + + // CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the + // ids of an app's live, unresolved install timers whose install instant equals + // the app's createdAt (co-created at app creation) AND that are "over" (live-FIFO + // rank >= includedModules) — the co-created over-modules folded onto the app's + // own creation-proration invoice, priced from the same day-0 window. + CoCreatedOverModuleTimers(ctx context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) +} + +// ModuleOverageCandidate is one per-module-instance install timer the Leg 1 +// grace sweep evaluates (migration 033): its surrogate id + app/account, the +// InstalledAt anchor (FIFO key AND proration anchor), GraceExpiresAt (already +// elapsed for a candidate), and the owning account's ActivatedAt (the billing- +// period anchor, ADR 0005, used to resolve the install's period window). +type ModuleOverageCandidate struct { + ID uuid.UUID + AccountID uuid.UUID + AppID uuid.UUID + InstalledAt time.Time + GraceExpiresAt time.Time + ActivatedAt time.Time + // ChargeAttemptedAt: a prior charge attempt reached its Stripe section + // (migration 036 recovery marker); zero = never attempted. A retried + // candidate reconciles against Stripe BEFORE recomputing any live verdict. + ChargeAttemptedAt time.Time } // AppModuleCount pairs one live roster app with its module_count snapshot — @@ -249,12 +436,27 @@ type AppBaseSnapshot struct { // AppMirror is the in-memory form of a ms_billing.apps roster row (migration // 027). ProrationInvoiceID is "" while the one-shot creation-proration guard // is unarmed; DeletedAt is meaningful only when Deleted is true. +// CreatedModuleCount (migration 030) is the module count FROZEN at +// RegisterApp time — immutable, never touched by SyncAppModules — and is what +// ChargeCreationProration prices the historical creation-period window from; +// ModuleCount is the LIVE count SyncAppModules keeps current and is what the +// boundary advance leg (and the display read for all FUTURE periods) uses. +// ProrationSkipped (migration 031) is true once the app's creation-proration +// charge has been PERMANENTLY skipped as a would-be retroactive catch-up +// (D1d): the account only activated at/after the app's anchored creation +// period had already closed. type AppMirror struct { AppID uuid.UUID AccountID uuid.UUID ModuleCount int + CreatedModuleCount int CreatedAt time.Time ProrationInvoiceID string + ProrationSkipped bool + // ProrationAttempted: a prior creation-proration charge attempt reached its + // Stripe section (migration 036 recovery marker) — a retry with this set and + // an unarmed guard reconciles against Stripe before minting new objects. + ProrationAttempted bool Deleted bool DeletedAt time.Time } @@ -268,6 +470,17 @@ type AccountAnchor struct { ActivatedAt time.Time } +// FrozenBoundaryCharge is the boundary run's Stripe request FROZEN before its +// first charge (migration 035): the whole-cent amount and whether the line +// includes advance base/overage (the description determinant). Both feed the +// deterministic idem keys ii-/inv-, so a reclaimed run reuses this +// frozen tuple verbatim rather than re-deriving a possibly-drifted live total — +// keeping every retry's Stripe request byte-identical under the stable key. +type FrozenBoundaryCharge struct { + Cents int64 + WithBase bool +} + // ErrAccountNotFound is returned by UpdateAccountCollection when no accounts row // matches the id (the UPDATE affected zero rows). var ErrAccountNotFound = errors.New("billing account not found") @@ -282,6 +495,11 @@ type AccountCollection struct { HasSpendCeiling bool SpendCeilingMicros int64 CreatedAt time.Time + // AutoCollectThresholdMicros is the per-account large-charge disclosure + // threshold (migration 034), 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 +527,11 @@ type InvoiceMirror struct { Currency string PeriodStart time.Time PeriodEnd time.Time + // IsLargeAutoCollect is the server-computed post-hoc disclosure flag + // (migration 034): 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 +827,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 +914,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, }) } @@ -709,6 +939,52 @@ func (s *pgxStore) MarkBillingRun(ctx context.Context, runID uuid.UUID, status B }) } +func (s *pgxStore) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, runID uuid.UUID) (bool, error) { + rows, err := s.q.MarkBillingRunInvoicedIfUnfrozen(ctx, runID.String()) + if err != nil { + return false, err + } + return rows > 0, nil +} + +func (s *pgxStore) FreezeBillingRunCharge(ctx context.Context, runID uuid.UUID, charge FrozenBoundaryCharge) (FrozenBoundaryCharge, error) { + // WHERE frozen_charge_cents IS NULL (in the query) makes this first-write-wins: + // a run that already froze (an earlier attempt, or a CONCURRENT daemon that got + // here first) affects 0 rows and keeps the ORIGINAL frozen amount. The read-back + // below returns the SURVIVING value regardless of which write won, and the + // caller charges THAT — never its locally computed amount — so two racing + // processes can never send different bodies under the shared idem keys. + if err := s.q.FreezeBillingRunCharge(ctx, db.FreezeBillingRunChargeParams{ + ID: runID.String(), + FrozenChargeCents: pgtype.Int8{Int64: charge.Cents, Valid: true}, + FrozenChargeWithBase: pgtype.Bool{Bool: charge.WithBase, Valid: true}, + }); err != nil { + return FrozenBoundaryCharge{}, err + } + surviving, ok, err := s.BillingRunFrozenCharge(ctx, runID) + if err != nil { + return FrozenBoundaryCharge{}, err + } + if !ok { + return FrozenBoundaryCharge{}, fmt.Errorf("billing run %s has no frozen charge immediately after freezing", runID) + } + return surviving, nil +} + +func (s *pgxStore) BillingRunFrozenCharge(ctx context.Context, runID uuid.UUID) (FrozenBoundaryCharge, bool, error) { + row, err := s.q.BillingRunFrozenCharge(ctx, runID.String()) + if err != nil { + return FrozenBoundaryCharge{}, false, err + } + if !row.FrozenChargeCents.Valid { + return FrozenBoundaryCharge{}, false, nil // fresh run — no prior attempt froze + } + return FrozenBoundaryCharge{ + Cents: row.FrozenChargeCents.Int64, + WithBase: row.FrozenChargeWithBase.Bool, + }, true, nil +} + func (s *pgxStore) AccountsWithUsageEvents(ctx context.Context, periodStart, periodEnd time.Time) ([]uuid.UUID, error) { rows, err := s.q.AccountsWithUsageEvents(ctx, db.AccountsWithUsageEventsParams{ RecordedAt: periodStart, @@ -841,13 +1117,221 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b AppID: app, AccountID: acct, ModuleCount: int(row.ModuleCount), + CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, ProrationInvoiceID: row.ProrationInvoiceID.String, // "" when NULL (guard unarmed) + ProrationSkipped: row.ProrationSkippedAt.Valid, + ProrationAttempted: row.ProrationAttemptedAt.Valid, Deleted: row.DeletedAt.Valid, DeletedAt: row.DeletedAt.Time, }, true, nil } +func (s *pgxStore) AppsPendingProration(ctx context.Context, createdBefore time.Time) ([]uuid.UUID, error) { + rows, err := s.q.AppsPendingProration(ctx, db.AppsPendingProrationParams{ + CreatedBefore: createdBefore, + // hours, not days (D5): the SQL grace cutoff must match the Go legs' + // fixed 24h-per-day UTC window regardless of the session timezone. + GraceHours: usage.GraceDays * 24, + }) + if err != nil { + return nil, err + } + return parseUUIDs(rows) +} + +// deferredRollback rolls back tx using a short-lived DETACHED context rather +// than reusing ctx verbatim. ctx may already be cancelled or past its deadline +// by the time this runs (e.g. the surrounding Lambda invocation timed out +// while a Stripe call the caller was awaiting stalled) — Rollback on a dead +// context can fail silently, leaving the row lock / transaction open until +// Postgres's own dead-connection detection eventually reclaims it. Stripping +// cancellation (context.WithoutCancel) while keeping request-scoped values, +// then applying a fresh short timeout, lets cleanup reach Postgres either way. +func deferredRollback(ctx context.Context, tx pgx.Tx) { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + _ = tx.Rollback(rctx) // no-op after a successful Commit +} + +// lockAndReadChargeableApp briefly SELECT ... FOR UPDATE-locks the roster row, +// re-verifies it is still chargeable (deleted_at IS NULL AND +// proration_invoice_id IS NULL), and releases the lock (the transaction +// commits either way — there is nothing left to write once the terminal +// checks pass, so a plain commit is equivalent to and cheaper than a rollback +// here). proceed=false means the caller must return (outcome, invID, nil) +// immediately without invoking charge; proceed=true carries the locked +// snapshot (including the frozen created_module_count) charge prices from. +func (s *pgxStore) lockAndReadChargeableApp(ctx context.Context, appID uuid.UUID) (locked AppMirror, outcome ProrationOutcome, invID string, proceed bool, err error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return AppMirror{}, 0, "", false, err + } + defer deferredRollback(ctx, tx) + + qtx := s.q.WithTx(tx) + row, err := qtx.SelectAppMirrorForUpdate(ctx, appID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return AppMirror{}, ProrationLockedNotFound, "", false, nil + } + if err != nil { + return AppMirror{}, 0, "", false, err + } + // Deleted WITHIN grace = never charged (scenario 1). Deleted AFTER the + // grace elapsed SURVIVED it (wave 2, D11) — the creation charge is owed + // (grace only delays WHEN it fires; the H2 boundary exclusion means no + // other leg has a backstop for this window), so the charge proceeds. + if row.DeletedAt.Valid && row.DeletedAt.Time.Before(moduleGraceExpiry(row.CreatedAt.UTC())) { + return AppMirror{}, ProrationLockedDeleted, "", false, nil + } + if row.ProrationInvoiceID.Valid { + return AppMirror{}, ProrationLockedAlreadyCharged, row.ProrationInvoiceID.String, false, nil + } + + app, err := uuid.Parse(row.AppID) + if err != nil { + return AppMirror{}, 0, "", false, err + } + acct, err := uuid.Parse(row.AccountID) + if err != nil { + return AppMirror{}, 0, "", false, err + } + locked = AppMirror{ + AppID: app, + AccountID: acct, + ModuleCount: int(row.ModuleCount), + CreatedModuleCount: int(row.CreatedModuleCount), + CreatedAt: row.CreatedAt, + ProrationAttempted: row.ProrationAttemptedAt.Valid, + } + + if err := tx.Commit(ctx); err != nil { + return AppMirror{}, 0, "", false, err + } + return locked, 0, "", true, nil +} + +// persistProrationCharge mirrors a SUCCESSFULLY-created Stripe charge (the +// invoice, the migration-028 base snapshot, and the one-shot guard) inside one +// short transaction. Called AFTER the Stripe network call has already +// completed — the money has already moved — so this always persists on a +// non-nil pc: a concurrent soft-delete that raced in during the (now-released) +// window between the lock and this write does NOT unwind an already-succeeded +// charge (D1e forbids refunds), and a genuinely concurrent second charge +// attempt for the same app converges on identical values (the deterministic +// per-app Stripe Idempotency-Keys guarantee the SAME invoice id, and every +// write here is itself idempotent / first-write-wins). +func (s *pgxStore) persistProrationCharge(ctx context.Context, appID uuid.UUID, pc *ProrationCharge) (ProrationOutcome, string, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, "", err + } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) + + due, err := centsNumeric(pc.Invoice.AmountDueCents) + if err != nil { + return 0, "", err + } + paid, err := centsNumeric(pc.Invoice.AmountPaidCents) + if err != nil { + return 0, "", err + } + if err := qtx.UpsertInvoice(ctx, db.UpsertInvoiceParams{ + AccountID: pc.Invoice.AccountID.String(), + StripeInvoiceID: pc.Invoice.StripeInvoiceID, + Status: pc.Invoice.Status, + AmountDue: due, + AmountPaid: paid, + Currency: pc.Invoice.Currency, + PeriodStart: pgtype.Timestamptz{Time: pc.Invoice.PeriodStart, Valid: !pc.Invoice.PeriodStart.IsZero()}, + PeriodEnd: pgtype.Timestamptz{Time: pc.Invoice.PeriodEnd, Valid: !pc.Invoice.PeriodEnd.IsZero()}, + // Scenario 5 — the disclosure flag the charge callback computed for the FULL + // combined debit (base + co-created overage lines). Dropping it here would + // silently write false for every creation/combined invoice. + IsLargeAutoCollect: pc.Invoice.IsLargeAutoCollect, + }); err != nil { + return 0, "", err + } + if err := qtx.UpsertProrationBaseSnapshot(ctx, db.UpsertProrationBaseSnapshotParams{ + AppID: pc.Snapshot.AppID.String(), + PeriodStart: pc.Snapshot.PeriodStart, + PeriodEnd: pc.Snapshot.PeriodEnd, + ModuleCount: int32(pc.Snapshot.ModuleCount), //nolint:gosec // count comes from the locked apps row, whose writers validate 0 ≤ count ≤ maxModuleCount + BaseMicros: pc.Snapshot.BaseMicros, + }); err != nil { + return 0, "", err + } + // A creation grace that straddled the period boundary billed the straddled + // period IN FULL on this same invoice — freeze its snapshot too (the boundary + // leg excluded the app there and writes nothing for that period). + if pc.StraddleSnapshot != nil { + if err := qtx.UpsertProrationBaseSnapshot(ctx, db.UpsertProrationBaseSnapshotParams{ + AppID: pc.StraddleSnapshot.AppID.String(), + PeriodStart: pc.StraddleSnapshot.PeriodStart, + PeriodEnd: pc.StraddleSnapshot.PeriodEnd, + ModuleCount: int32(pc.StraddleSnapshot.ModuleCount), //nolint:gosec // same validated apps-row count as above + BaseMicros: pc.StraddleSnapshot.BaseMicros, + }); err != nil { + return 0, "", err + } + } + // Arm the one-shot guard. First-write-wins (WHERE proration_invoice_id IS + // NULL): a concurrent second attempt for the same app affects 0 rows here + // and keeps the winner's (identical, by construction) invoice id. + if _, err := qtx.SetAppProrationInvoice(ctx, db.SetAppProrationInvoiceParams{ + AppID: appID.String(), + ProrationInvoiceID: pgtype.Text{String: pc.InvoiceID, Valid: true}, + }); err != nil { + return 0, "", err + } + + // Scenario 3 — stamp the co-created over-module timers billed on this SAME + // combined invoice as terminally charged, in the SAME transaction as the guard + // arm (all-or-nothing: an over-module and the app base are marked together). + // WHERE grace_resolved = false is first-write-wins — a Leg 1 sweep that already + // resolved a timer (its own invoice) affects 0 rows here, keeping the winner's ids. + for _, tc := range pc.TimerCharges { + if err := qtx.MarkModuleTimerCharged(ctx, db.MarkModuleTimerChargedParams{ + TimerID: tc.TimerID.String(), + GraceChargedAt: tc.ChargedAt, + GraceInvoiceID: pgtype.Text{String: tc.InvoiceID, Valid: tc.InvoiceID != ""}, + GraceInvoiceItemID: pgtype.Text{String: tc.InvoiceItemID, Valid: tc.InvoiceItemID != ""}, + }); err != nil { + return 0, "", err + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, "", err + } + return ProrationLockedCharged, pc.InvoiceID, nil +} + +func (s *pgxStore) ChargeProrationLocked(ctx context.Context, appID uuid.UUID, charge func(AppMirror) (*ProrationCharge, error)) (ProrationOutcome, string, error) { + // Phase 1: lock just long enough to read + verify chargeable state, then + // release — never held across the Stripe call below. + locked, outcome, invID, proceed, err := s.lockAndReadChargeableApp(ctx, appID) + if err != nil { + return 0, "", err + } + if !proceed { + return outcome, invID, nil + } + + // Phase 2: the Stripe network calls, OUTSIDE any lock or transaction. + pc, err := charge(locked) + if err != nil { + return 0, "", err // guard unarmed → the next sweep retries (idem keys) + } + if pc == nil { + return ProrationLockedNoCharge, "", nil // 0 cents — nothing to invoice + } + + // Phase 3: persist the successful charge. + return s.persistProrationCharge(ctx, appID, pc) +} + func (s *pgxStore) SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, stripeInvoiceID string) error { // 0 rows = the guard was already armed (first-charge-wins) — not an error: // the deterministic Stripe idem keys guarantee the concurrent charger @@ -859,6 +1343,13 @@ func (s *pgxStore) SetAppProrationInvoice(ctx context.Context, appID uuid.UUID, return err } +func (s *pgxStore) SetAppProrationSkipped(ctx context.Context, appID uuid.UUID) error { + // 0 rows = already marked, or already charged in the meantime — neither is + // an error: the marker is a one-shot, first-write-wins terminal state. + _, err := s.q.SetAppProrationSkipped(ctx, appID.String()) + return err +} + func (s *pgxStore) SetAppModuleCount(ctx context.Context, appID uuid.UUID, moduleCount int) error { // 0 rows = the app is deleted (count frozen, D1e); existence was already // checked by the service via AppMirror, so this is a legitimate no-op. @@ -875,10 +1366,13 @@ func (s *pgxStore) MarkAppDeleted(ctx context.Context, appID uuid.UUID) error { return err } -func (s *pgxStore) LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time) ([]AppModuleCount, error) { +func (s *pgxStore) LiveAppsCreatedBefore(ctx context.Context, accountID uuid.UUID, createdBefore time.Time, graceDays int) ([]AppModuleCount, error) { rows, err := s.q.LiveAppModuleCountsCreatedBefore(ctx, db.LiveAppModuleCountsCreatedBeforeParams{ - AccountID: accountID.String(), - CreatedAt: createdBefore, + AccountID: accountID.String(), + CreatedBefore: createdBefore, + // hours, not days (wave 2, D5): keeps the SQL cutoff identical to the Go + // legs' fixed 24h-per-day UTC grace regardless of the session timezone. + GraceHours: int32(graceDays) * 24, //nolint:gosec // graceDays is the small GraceDays const (3) }) if err != nil { return nil, err @@ -917,6 +1411,247 @@ func (s *pgxStore) InsertAdvanceBaseSnapshot(ctx context.Context, snap AppBaseSn return err } +// --- per-module-instance overage timers (migration 033) -------------------- + +func (s *pgxStore) LiveModuleTimerCountForApp(ctx context.Context, appID uuid.UUID) (int, error) { + n, err := s.q.LiveModuleTimerCountForApp(ctx, appID.String()) + if err != nil { + return 0, err + } + return int(n), nil +} + +func (s *pgxStore) InsertModuleOverageTimers(ctx context.Context, accountID, appID uuid.UUID, installedAt, graceExpiresAt time.Time, n int) error { + if n <= 0 { + return nil // generate_series(1, 0) would be a no-op anyway; skip the round-trip + } + return s.q.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ + AccountID: accountID.String(), + AppID: appID.String(), + InstalledAt: installedAt, + GraceExpiresAt: graceExpiresAt, + Count: int32(n), //nolint:gosec // n = a module_count delta, bounded by maxModuleCount (100000), far below int32 max + }) +} + +func (s *pgxStore) MarkModuleTimerChargeAttempted(ctx context.Context, timerID uuid.UUID, at time.Time) error { + return s.q.MarkModuleTimerChargeAttempted(ctx, db.MarkModuleTimerChargeAttemptedParams{ + ID: timerID.String(), + ChargeAttemptedAt: pgtype.Timestamptz{Time: at, Valid: true}, + }) +} + +func (s *pgxStore) ModuleTimerStillPending(ctx context.Context, timerID uuid.UUID) (bool, error) { + pending, err := s.q.ModuleTimerStillPending(ctx, timerID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil // the row vanished — certainly not pending + } + return pending, err +} + +func (s *pgxStore) MarkAppProrationAttempted(ctx context.Context, appID uuid.UUID, at time.Time) error { + return s.q.MarkAppProrationAttempted(ctx, db.MarkAppProrationAttemptedParams{ + AppID: appID.String(), + ProrationAttemptedAt: pgtype.Timestamptz{Time: at, Valid: true}, + }) +} + +// lockModuleTimers takes the per-app advisory xact lock every timer-set writer +// serializes on. Released automatically on commit/rollback. +func lockModuleTimers(ctx context.Context, tx pgx.Tx, appID uuid.UUID) error { + _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, "module-timers:"+appID.String()) + return err +} + +// ReconcileModuleTimersToTarget — roster read + count + write in ONE +// transaction serialized by the per-app advisory xact lock, so concurrent +// RegisterApp/SyncAppModules retries can never both observe the same live +// count and double-insert (H7), a stale caller can never impose an outdated +// target (D8 — the target is the row's CURRENT module_count, read under the +// lock), and a deleted row reconciles to zero instead of resurrecting (D9). +func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid.UUID, installedAt, graceExpiresAt, removedAt time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) + + if err := lockModuleTimers(ctx, tx, appID); err != nil { + return err + } + row, err := qtx.SelectAppMirror(ctx, appID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return nil // no roster row — nothing to reconcile against + } + if err != nil { + return err + } + target := int64(row.ModuleCount) + if row.DeletedAt.Valid { + target = 0 // deleted apps hold no live timers — remove orphans, never insert + } + live, err := qtx.LiveModuleTimerCountForApp(ctx, appID.String()) + if err != nil { + return err + } + switch { + case target > live: + if err := qtx.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ + AccountID: row.AccountID, + AppID: appID.String(), + InstalledAt: installedAt, + GraceExpiresAt: graceExpiresAt, + Count: int32(target - live), //nolint:gosec // bounded by maxModuleCount (100000) + }); err != nil { + return err + } + case target < live: + if err := qtx.SoftRemoveNewestModuleTimers(ctx, db.SoftRemoveNewestModuleTimersParams{ + AppID: appID.String(), + RemovedAt: removedAt, + LimitCount: int32(live - target), //nolint:gosec // bounded by maxModuleCount (100000) + }); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +// MarkAppDeletedAndRemoveTimers — the deletion write and the timer soft-remove +// in ONE transaction under the SAME advisory lock (wave 2, D9): no crash +// window between them, and no interleaving with a concurrent reconcile. +func (s *pgxStore) MarkAppDeletedAndRemoveTimers(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer deferredRollback(ctx, tx) + qtx := s.q.WithTx(tx) + + if err := lockModuleTimers(ctx, tx, appID); err != nil { + return err + } + if _, err := qtx.MarkAppDeleted(ctx, appID.String()); err != nil { + return err + } + if err := qtx.SoftRemoveAllModuleTimersForApp(ctx, db.SoftRemoveAllModuleTimersForAppParams{ + AppID: appID.String(), + RemovedAt: pgtype.Timestamptz{Time: removedAt, Valid: true}, + }); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *pgxStore) SoftRemoveNewestModuleTimers(ctx context.Context, appID uuid.UUID, n int, removedAt time.Time) error { + if n <= 0 { + return nil + } + return s.q.SoftRemoveNewestModuleTimers(ctx, db.SoftRemoveNewestModuleTimersParams{ + AppID: appID.String(), + LimitCount: int32(n), //nolint:gosec // n = a module_count delta, bounded by maxModuleCount (100000), far below int32 max + RemovedAt: removedAt, + }) +} + +func (s *pgxStore) SoftRemoveAllModuleTimersForApp(ctx context.Context, appID uuid.UUID, removedAt time.Time) error { + return s.q.SoftRemoveAllModuleTimersForApp(ctx, db.SoftRemoveAllModuleTimersForAppParams{ + AppID: appID.String(), + RemovedAt: pgtype.Timestamptz{Time: removedAt, Valid: true}, + }) +} + +func (s *pgxStore) ModuleOverageTimersPastGrace(ctx context.Context, at time.Time) ([]ModuleOverageCandidate, error) { + rows, err := s.q.ModuleOverageTimersPastGrace(ctx, at) + if err != nil { + return nil, err + } + out := make([]ModuleOverageCandidate, 0, len(rows)) + for _, r := range rows { + id, err := uuid.Parse(r.ID) + if err != nil { + return nil, err + } + acct, err := uuid.Parse(r.AccountID) + if err != nil { + return nil, err + } + app, err := uuid.Parse(r.AppID) + if err != nil { + return nil, err + } + // The query filters activated_at IS NOT NULL, so a non-Valid value here is + // a driver anomaly; skip it defensively rather than anchor on the zero time. + if !r.ActivatedAt.Valid { + continue + } + cand := ModuleOverageCandidate{ + ID: id, + AccountID: acct, + AppID: app, + InstalledAt: r.InstalledAt, + GraceExpiresAt: r.GraceExpiresAt, + ActivatedAt: r.ActivatedAt.Time, + } + if r.ChargeAttemptedAt.Valid { + cand.ChargeAttemptedAt = r.ChargeAttemptedAt.Time + } + out = append(out, cand) + } + return out, nil +} + +func (s *pgxStore) LiveModuleTimerRankBefore(ctx context.Context, accountID, timerID uuid.UUID, installedAt time.Time) (int, error) { + rank, err := s.q.LiveModuleTimerRankBefore(ctx, db.LiveModuleTimerRankBeforeParams{ + AccountID: accountID.String(), + InstalledAt: installedAt, + TimerID: timerID.String(), + }) + if err != nil { + return 0, err + } + return int(rank), nil +} + +func (s *pgxStore) MarkModuleTimerIncluded(ctx context.Context, timerID uuid.UUID) error { + return s.q.MarkModuleTimerIncluded(ctx, timerID.String()) +} + +func (s *pgxStore) MarkModuleTimerCharged(ctx context.Context, timerID uuid.UUID, chargedAt time.Time, invoiceID, invoiceItemID string) error { + return s.q.MarkModuleTimerCharged(ctx, db.MarkModuleTimerChargedParams{ + TimerID: timerID.String(), + GraceChargedAt: chargedAt, + GraceInvoiceID: pgtype.Text{String: invoiceID, Valid: invoiceID != ""}, + GraceInvoiceItemID: pgtype.Text{String: invoiceItemID, Valid: invoiceItemID != ""}, + }) +} + +func (s *pgxStore) CountOngoingOverModuleTimers(ctx context.Context, accountID uuid.UUID, includedModules int, periodEnd time.Time) (int, error) { + n, err := s.q.CountOngoingOverModuleTimers(ctx, db.CountOngoingOverModuleTimersParams{ + AccountID: accountID.String(), + IncludedModules: int32(includedModules), //nolint:gosec // includedModules is the small IncludedModules const (5) + PeriodEnd: periodEnd, + }) + if err != nil { + return 0, err + } + return int(n), nil +} + +func (s *pgxStore) CoCreatedOverModuleTimers(ctx context.Context, accountID, appID uuid.UUID, createdAt time.Time, includedModules int) ([]uuid.UUID, error) { + ids, err := s.q.CoCreatedOverModuleTimers(ctx, db.CoCreatedOverModuleTimersParams{ + AccountID: accountID.String(), + AppID: appID.String(), + CreatedAt: createdAt, + IncludedModules: int32(includedModules), //nolint:gosec // includedModules is the small IncludedModules const (5) + }) + if err != nil { + return nil, err + } + return parseUUIDs(ids) +} + // parseUUIDs parses a slice of UUID-as-string account ids (the form the sqlc // NOT NULL uuid → string override yields) into uuid.UUID. func parseUUIDs(ids []string) ([]uuid.UUID, error) { diff --git a/internal/account/cycle/store_prorationlock_integration_test.go b/internal/account/cycle/store_prorationlock_integration_test.go new file mode 100644 index 0000000..11a75cd --- /dev/null +++ b/internal/account/cycle/store_prorationlock_integration_test.go @@ -0,0 +1,175 @@ +//go:build integration + +package cycle_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" + "github.com/mirrorstack-ai/billing-engine/internal/shared/testutil" +) + +// ChargeProrationLocked (finding 3, store.go) — verifies against a REAL +// Postgres that the row lock is released BEFORE the (simulated) Stripe network +// call, not held across it: a concurrent SyncAppModules/MarkAppDeleted write +// to the SAME row must complete promptly while the charge callback is still +// in flight, never blocking behind it. + +func mkProrationCharge(acct, appID uuid.UUID, invID string, periodEnd time.Time) *cycle.ProrationCharge { + return &cycle.ProrationCharge{ + InvoiceID: invID, + Cents: 200, + Invoice: cycle.InvoiceMirror{ + AccountID: acct, StripeInvoiceID: invID, Status: "open", + AmountDueCents: 200, Currency: "usd", + PeriodStart: periodEnd.AddDate(0, 0, -3), PeriodEnd: periodEnd, + }, + Snapshot: cycle.AppBaseSnapshot{ + AppID: appID, PeriodStart: periodEnd.AddDate(0, -1, 0), PeriodEnd: periodEnd, + ModuleCount: 0, BaseMicros: 2_000_000, + }, + } +} + +func TestChargeProrationLocked_Integration_LockNotHeldAcrossStripeCall(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + + insideCallback := make(chan struct{}) + release := make(chan struct{}) + chargeDone := make(chan error, 1) + + go func() { + _, _, err := store.ChargeProrationLocked(ctx, appID, func(locked cycle.AppMirror) (*cycle.ProrationCharge, error) { + close(insideCallback) // signal: the phase-1 lock has already been read + released + <-release // simulate a slow Stripe HTTP call + return mkProrationCharge(acct, appID, "in_slow", mustTime(t, "2026-07-04T00:00:00Z")), nil + }) + chargeDone <- err + }() + + select { + case <-insideCallback: + case <-time.After(5 * time.Second): + t.Fatal("charge callback never started") + } + + // A concurrent write to the SAME row must complete promptly — it must NOT + // be blocked behind the "Stripe call" the callback is simulating. Under the + // pre-fix code (the lock held across the callback via one long-lived FOR + // UPDATE transaction) this write would hang until `release` is closed. + writeDone := make(chan error, 1) + go func() { writeDone <- store.SetAppModuleCount(ctx, appID, 42) }() + + select { + case err := <-writeDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("SetAppModuleCount blocked behind the in-flight charge callback — the row lock is still held across it") + } + + close(release) + require.NoError(t, <-chargeDone) + + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.Equal(t, 42, app.ModuleCount, "the concurrent write landed") + require.Equal(t, "in_slow", app.ProrationInvoiceID, "the charge still committed after release") +} + +func TestChargeProrationLocked_Integration_ConcurrentDeleteDoesNotBlockOnLock(t *testing.T) { + // Same shape as above, using MarkAppDeleted (the specific writer finding 3 + // calls out — SyncAppModules' soft-delete path) as the concurrent write. + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + + insideCallback := make(chan struct{}) + release := make(chan struct{}) + chargeDone := make(chan error, 1) + + go func() { + _, _, err := store.ChargeProrationLocked(ctx, appID, func(locked cycle.AppMirror) (*cycle.ProrationCharge, error) { + close(insideCallback) + <-release + return mkProrationCharge(acct, appID, "in_slow_del", mustTime(t, "2026-07-04T00:00:00Z")), nil + }) + chargeDone <- err + }() + + select { + case <-insideCallback: + case <-time.After(5 * time.Second): + t.Fatal("charge callback never started") + } + + deleteDone := make(chan error, 1) + go func() { deleteDone <- store.MarkAppDeleted(ctx, appID) }() + + select { + case err := <-deleteDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("MarkAppDeleted blocked behind the in-flight charge callback — the row lock is still held across it") + } + + close(release) + require.NoError(t, <-chargeDone) + + // Judgment call (documented in store.go / proration.go and the PR + // description): once the Stripe charge has already succeeded, a delete that + // raced in during the (now-released) window does NOT unwind it — D1e + // already forbids refunds, and the money has already moved. The invoice / + // snapshot / guard are persisted regardless of the app's deleted_at. + app, _, err := store.AppMirror(ctx, appID) + require.NoError(t, err) + require.True(t, app.Deleted, "the delete committed") + require.Equal(t, "in_slow_del", app.ProrationInvoiceID, "the already-succeeded Stripe charge is still recorded, not silently dropped") +} + +// Regression (review 2026-07-06): persistProrationCharge dropped the +// IsLargeAutoCollect field from its UpsertInvoiceParams, so the combined +// creation invoice (scenario 3/5a) was ALWAYS persisted with +// is_large_auto_collect = false in production even when the charge callback +// computed true — the unit suite never caught it because the fake store +// carries the InvoiceMirror struct through verbatim. Assert against the REAL +// pgx persist path. +func TestChargeProrationLocked_Integration_PersistsLargeAutoCollectFlag(t *testing.T) { + pool := testutil.NewTestDB(t) + store := cycle.NewStore(pool) + ctx := context.Background() + + acct := seedAccount(t, pool) + appID := uuid.New() + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"))) + + pc := mkProrationCharge(acct, appID, "in_large_flag", mustTime(t, "2026-07-04T00:00:00Z")) + pc.Invoice.IsLargeAutoCollect = true + + outcome, invID, err := store.ChargeProrationLocked(ctx, appID, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { + return pc, nil + }) + require.NoError(t, err) + require.Equal(t, cycle.ProrationLockedCharged, outcome) + require.Equal(t, "in_large_flag", invID) + + var flagged bool + require.NoError(t, pool.QueryRow(ctx, + `SELECT is_large_auto_collect FROM ms_billing.invoices WHERE stripe_invoice_id = $1`, + "in_large_flag").Scan(&flagged)) + require.True(t, flagged, "the charge callback's IsLargeAutoCollect must survive the pgx persist path") +} diff --git a/internal/account/cycle/types.go b/internal/account/cycle/types.go index f8d0a86..1e2ed91 100644 --- a/internal/account/cycle/types.go +++ b/internal/account/cycle/types.go @@ -205,15 +205,23 @@ type ChargeSummary struct { // allowanceMicros) the cycle computed for the CLOSED period. ArrearsMicros int64 - // AdvanceBaseMicros is the NEW period's advance base fee (base-fee v1): - // Σ over the account's live apps of BaseFee + Overage × max(0, - // module_count − IncludedModules). 0 for a pre-backfill account (no - // mirror rows). The invoice total is ArrearsMicros + AdvanceBaseMicros; - // only when BOTH are 0 is the Stripe call skipped. + // AdvanceBaseMicros is the NEW period's advance base fee: Σ over the + // account's live apps of the FLAT BaseFeeMicros. 0 for a pre-backfill + // account (no mirror rows). Module overage is billed SEPARATELY as + // AdvanceOverageMicros, not folded into an app's base. AdvanceBaseMicros int64 + // AdvanceOverageMicros is the NEW period's advance overage precharge + // (scenario 6, Leg 2): ModuleOverageFeeMicros × the count of ongoing + // over-modules (live install timers that are "over" per the live FIFO and + // already charged at least once). Billed FULL (not prorated — the module + // exists for the whole new period) on the same boundary invoice. 0 when no + // ongoing over-module continues into the new period. + AdvanceOverageMicros int64 + // ChargedCents is the whole-cent amount sent to Stripe (micros → cents - // round-half-up over arrears + advance base). 0 when no charge happened. + // round-half-up over arrears + advance base + advance overage). 0 when no + // charge happened. ChargedCents int64 // StripeInvoiceID is the created Stripe invoice id, empty when no charge diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index 140c140..8131a40 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -12,6 +12,54 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const appsPendingProration = `-- name: AppsPendingProration :many +SELECT app_id +FROM ms_billing.apps +WHERE created_at <= $1::timestamptz + AND proration_invoice_id IS NULL + AND (deleted_at IS NULL + OR deleted_at >= created_at + make_interval(hours => $2::int)) + AND proration_skipped_at IS NULL +ORDER BY created_at +` + +type AppsPendingProrationParams struct { + CreatedBefore time.Time `json:"created_before"` + GraceHours int32 `json:"grace_hours"` +} + +// AppsPendingProration is the creation-proration sweep's work list: apps that +// have survived the grace window (@created_before = now() − GraceDays) and were +// never charged their creation-period base. proration_invoice_id IS NULL is the +// one-shot guard (an already-charged app drops out); the deleted_at predicate +// excludes ONLY apps soft-deleted WITHIN their grace (never charged, scenario +// 1) — an app deleted AFTER its grace elapsed SURVIVED it and still owes the +// creation charge (wave 2, D11: grace only delays WHEN the charge fires, and +// the H2 boundary exclusion leaves no other leg as a backstop; pre-fix this +// was a user-timable ~$22 dodge in the grace-elapse→sweep window). Grace in +// HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes +// apps permanently skipped as a would-be retroactive catch-up (migration 031, +// D1d). Ordered by created_at so the oldest pending app charges first. +func (q *Queries) AppsPendingProration(ctx context.Context, arg AppsPendingProrationParams) ([]string, error) { + rows, err := q.db.Query(ctx, appsPendingProration, arg.CreatedBefore, arg.GraceHours) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var app_id string + if err := rows.Scan(&app_id); err != nil { + return nil, err + } + items = append(items, app_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const insertAdvanceBaseSnapshot = `-- name: InsertAdvanceBaseSnapshot :execrows INSERT INTO ms_billing.app_base_snapshots (app_id, period_start, period_end, module_count, base_micros, source) @@ -49,8 +97,8 @@ func (q *Queries) InsertAdvanceBaseSnapshot(ctx context.Context, arg InsertAdvan const insertAppMirror = `-- name: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_at) -VALUES ($1, $2, $3, $4) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) +VALUES ($1, $2, $3, $3, $4) ON CONFLICT (app_id) DO NOTHING ` @@ -67,10 +115,14 @@ type InsertAppMirrorParams struct { // the installed-module-count snapshot, and the one-shot proration guard. // InsertAppMirror registers an app row idempotently: ON CONFLICT (app_id) DO // NOTHING so a RegisterApp retry (or a concurrent double-fire) never rewrites -// created_at / module_count / the proration guard of the original insert — -// the FIRST registration's values are the stable proration anchor. :execrows -// so the caller can tell a fresh insert (1) from a retry no-op (0), though -// both are success. +// created_at / module_count / created_module_count / the proration guard of +// the original insert — the FIRST registration's values are the stable +// proration anchor. created_module_count is stamped from the SAME $3 value as +// module_count and NEVER written again by any other query (migration 030) — +// it is the frozen count ChargeCreationProration prices its historical window +// from, immune to a later SyncAppModules install/uninstall during grace. +// :execrows so the caller can tell a fresh insert (1) from a retry no-op (0), +// though both are success. func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams) (int64, error) { result, err := q.db.Exec(ctx, insertAppMirror, arg.AppID, @@ -87,14 +139,16 @@ func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams const liveAppModuleCountsCreatedBefore = `-- name: LiveAppModuleCountsCreatedBefore :many SELECT app_id, module_count FROM ms_billing.apps -WHERE account_id = $1 +WHERE account_id = $1::uuid AND deleted_at IS NULL - AND created_at < $2 + AND created_at < $2::timestamptz + AND created_at + make_interval(hours => $3::int) < $2::timestamptz ` type LiveAppModuleCountsCreatedBeforeParams struct { - AccountID string `json:"account_id"` - CreatedAt time.Time `json:"created_at"` + AccountID string `json:"account_id"` + CreatedBefore time.Time `json:"created_before"` + GraceHours int32 `json:"grace_hours"` } type LiveAppModuleCountsCreatedBeforeRow struct { @@ -103,20 +157,37 @@ type LiveAppModuleCountsCreatedBeforeRow struct { } // LiveAppModuleCountsCreatedBefore returns (app_id, module_count) for every -// LIVE (deleted_at IS NULL) app on the account created STRICTLY BEFORE the -// cutoff — the boundary charge's advance-base input: +// LIVE (deleted_at IS NULL) app on the account that has JOINED the advance +// base mechanism by the cutoff — the boundary charge's advance-base input: // advance base = Σ (BaseFee + Overage × max(0, module_count − included)). -// The cutoff is the NEW period's start (the closed window's period_end): an -// app created INSIDE the new period is excluded, because RegisterApp's -// creation-proration leg already charged that app's new-period base (full or -// prorated) — summing it here would double-bill the same period (same-day -// cron race, and deterministically on reclaimed skipped_no_pm/failed runs). -// Such an app joins the advance leg at the NEXT boundary. Deleted apps are -// excluded (D1e); an account with no rows (pre-backfill) sums to base 0 and -// keeps the pre-027 arrears-only invoice. app_id is returned so the charge -// leg can write the per-app-period base snapshot (migration 028) it bills. +// The cutoff is the NEW period's start (the closed window's period_end). Two +// conditions, mirroring the module-timer coverage contract (review 2026-07-06): +// - created_at < @created_before — an app created INSIDE the new period is +// excluded, because RegisterApp's creation-proration leg already charged +// that app's new-period base (full or prorated) — summing it here would +// double-bill the same period (same-day cron race, and deterministically +// on reclaimed skipped_no_pm/failed runs). +// - created_at + grace < @created_before — an app whose CREATION GRACE had +// not yet elapsed when the new period opened is excluded: it has not +// survived grace yet (an app deleted in grace is NEVER charged, scenario +// 1 — precharging its next-period base here would bill a full month for +// an app that can still be deleted for free), and when it does survive, +// its creation-proration charge covers through the END of the period its +// grace elapses into (the straddled period), so this boundary's new +// period is already that leg's coverage. Spec: apps "join this boundary +// mechanism starting at the NEXT boundary after their own creation charge +// fires". +// +// Deleted apps are excluded (D1e); an account with no rows (pre-backfill) +// sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is +// returned so the charge leg can write the per-app-period base snapshot +// (migration 028) it bills. The grace interval is expressed in HOURS, not +// days (wave 2, D5): timestamptz + a day-interval is evaluated in the SESSION +// timezone (DST-shifting), while the Go legs' grace is a fixed GraceDays*24h +// UTC window (moduleGraceExpiry) — a non-UTC session would disagree with them +// by an hour around DST and double-bill or gap a whole period. func (q *Queries) LiveAppModuleCountsCreatedBefore(ctx context.Context, arg LiveAppModuleCountsCreatedBeforeParams) ([]LiveAppModuleCountsCreatedBeforeRow, error) { - rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedAt) + rows, err := q.db.Query(ctx, liveAppModuleCountsCreatedBefore, arg.AccountID, arg.CreatedBefore, arg.GraceHours) if err != nil { return nil, err } @@ -153,6 +224,26 @@ func (q *Queries) MarkAppDeleted(ctx context.Context, appID string) (int64, erro return result.RowsAffected(), nil } +const markAppProrationAttempted = `-- name: MarkAppProrationAttempted :exec +UPDATE ms_billing.apps +SET proration_attempted_at = $2 +WHERE app_id = $1 + AND proration_attempted_at IS NULL +` + +type MarkAppProrationAttemptedParams struct { + AppID string `json:"app_id"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` +} + +// MarkAppProrationAttempted stamps the recovery marker (036) BEFORE a +// creation-proration charge attempt's first Stripe call. First-write-wins +// (the FIRST attempt instant is the durable one); never cleared. +func (q *Queries) MarkAppProrationAttempted(ctx context.Context, arg MarkAppProrationAttemptedParams) error { + _, err := q.db.Exec(ctx, markAppProrationAttempted, arg.AppID, arg.ProrationAttemptedAt) + return err +} + const mirroredAppIDsOverlappingWindow = `-- name: MirroredAppIDsOverlappingWindow :many SELECT app_id FROM ms_billing.apps @@ -234,18 +325,22 @@ func (q *Queries) SelectAppBaseSnapshot(ctx context.Context, arg SelectAppBaseSn } const selectAppMirror = `-- name: SelectAppMirror :one -SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1 ` type SelectAppMirrorRow struct { - AppID string `json:"app_id"` - AccountID string `json:"account_id"` - ModuleCount int32 `json:"module_count"` - CreatedAt time.Time `json:"created_at"` - ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` - DeletedAt pgtype.Timestamptz `json:"deleted_at"` + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` + CreatedAt time.Time `json:"created_at"` + ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` } // SelectAppMirror reads one roster row (deleted or not — the caller decides @@ -258,8 +353,56 @@ func (q *Queries) SelectAppMirror(ctx context.Context, appID string) (SelectAppM &i.AppID, &i.AccountID, &i.ModuleCount, + &i.CreatedModuleCount, + &i.CreatedAt, + &i.ProrationInvoiceID, + &i.ProrationSkippedAt, + &i.ProrationAttemptedAt, + &i.DeletedAt, + ) + return i, err +} + +const selectAppMirrorForUpdate = `-- name: SelectAppMirrorForUpdate :one +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at +FROM ms_billing.apps +WHERE app_id = $1 +FOR UPDATE +` + +type SelectAppMirrorForUpdateRow struct { + AppID string `json:"app_id"` + AccountID string `json:"account_id"` + ModuleCount int32 `json:"module_count"` + CreatedModuleCount int32 `json:"created_module_count"` + CreatedAt time.Time `json:"created_at"` + ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` +} + +// SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — +// the creation-proration charge's race-safety primitive. The charge locks the +// row here just long enough to re-verify deleted_at IS NULL and +// proration_invoice_id IS NULL and read the frozen created_module_count, +// releasing the lock immediately after (ChargeProrationLocked runs the actual +// Stripe network call OUTSIDE this lock — see store.go); a concurrent +// SyncAppModules soft-delete (MarkAppDeleted) only ever contends for the brief +// read, never for the duration of a Stripe HTTP call. +func (q *Queries) SelectAppMirrorForUpdate(ctx context.Context, appID string) (SelectAppMirrorForUpdateRow, error) { + row := q.db.QueryRow(ctx, selectAppMirrorForUpdate, appID) + var i SelectAppMirrorForUpdateRow + err := row.Scan( + &i.AppID, + &i.AccountID, + &i.ModuleCount, + &i.CreatedModuleCount, &i.CreatedAt, &i.ProrationInvoiceID, + &i.ProrationSkippedAt, + &i.ProrationAttemptedAt, &i.DeletedAt, ) return i, err @@ -315,6 +458,29 @@ func (q *Queries) SetAppProrationInvoice(ctx context.Context, arg SetAppProratio return result.RowsAffected(), nil } +const setAppProrationSkipped = `-- name: SetAppProrationSkipped :execrows +UPDATE ms_billing.apps +SET proration_skipped_at = now() +WHERE app_id = $1 + AND proration_skipped_at IS NULL + AND proration_invoice_id IS NULL +` + +// SetAppProrationSkipped arms the PERMANENT skip marker (migration 031, D1d): +// the account only activated at/after this app's anchored creation period had +// already closed, so charging it now would be a retroactive catch-up. The +// WHERE clause makes it idempotent (a re-evaluation, or a concurrent one, +// affects 0 rows once set) and defensively refuses to mark an app that was +// somehow already charged in the meantime. :execrows so the caller can +// observe (and tolerate) the already-set / already-charged cases. +func (q *Queries) SetAppProrationSkipped(ctx context.Context, appID string) (int64, error) { + result, err := q.db.Exec(ctx, setAppProrationSkipped, appID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const upsertProrationBaseSnapshot = `-- name: UpsertProrationBaseSnapshot :exec INSERT INTO ms_billing.app_base_snapshots (app_id, period_start, period_end, module_count, base_micros, source) diff --git a/internal/account/db/cycle.sql.go b/internal/account/db/cycle.sql.go index 865a76a..beccf19 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 @@ -246,6 +252,56 @@ func (q *Queries) ActivatedAccounts(ctx context.Context) ([]ActivatedAccountsRow return items, nil } +const billingRunFrozenCharge = `-- name: BillingRunFrozenCharge :one +SELECT frozen_charge_cents, frozen_charge_with_base +FROM ms_billing.billing_runs +WHERE id = $1 +` + +type BillingRunFrozenChargeRow struct { + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` +} + +// BillingRunFrozenCharge reads a run's frozen boundary-charge amount + description +// determinant (set by a prior attempt's FreezeBillingRunCharge). Both are NULL +// when no prior attempt reached the Stripe charge (a fresh run), so the caller +// freezes the freshly-computed values and charges them; on a reclaim they are the +// amount already charged, which the retry REUSES verbatim. +func (q *Queries) BillingRunFrozenCharge(ctx context.Context, id string) (BillingRunFrozenChargeRow, error) { + row := q.db.QueryRow(ctx, billingRunFrozenCharge, id) + var i BillingRunFrozenChargeRow + err := row.Scan(&i.FrozenChargeCents, &i.FrozenChargeWithBase) + return i, err +} + +const freezeBillingRunCharge = `-- name: FreezeBillingRunCharge :exec +UPDATE ms_billing.billing_runs +SET frozen_charge_cents = $2, + frozen_charge_with_base = $3 +WHERE id = $1 + AND frozen_charge_cents IS NULL +` + +type FreezeBillingRunChargeParams struct { + ID string `json:"id"` + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` +} + +// FreezeBillingRunCharge records, BEFORE the boundary Stripe charge, the exact +// whole-cent amount AND the base/overage description determinant this run will +// send Stripe under the deterministic idem keys ii- / inv- (migration +// 035). First-write-wins (WHERE frozen_charge_cents IS NULL): a reclaimed run that +// already froze keeps its ORIGINAL values, so a retry that recomputed a different +// LIVE total can never send Stripe a mismatched request under the same idem key. +// InsertBillingRun's ON CONFLICT DO UPDATE deliberately leaves these columns +// untouched, so the freeze survives the reclaim. +func (q *Queries) FreezeBillingRunCharge(ctx context.Context, arg FreezeBillingRunChargeParams) error { + _, err := q.db.Exec(ctx, freezeBillingRunCharge, arg.ID, arg.FrozenChargeCents, arg.FrozenChargeWithBase) + return err +} + const hasUsableDefaultPM = `-- name: HasUsableDefaultPM :one SELECT EXISTS ( SELECT 1 @@ -365,6 +421,32 @@ func (q *Queries) MarkBillingRun(ctx context.Context, arg MarkBillingRunParams) return err } +const markBillingRunInvoicedIfUnfrozen = `-- name: MarkBillingRunInvoicedIfUnfrozen :execrows +UPDATE ms_billing.billing_runs +SET status = 'invoiced', + stripe_invoice_id = NULL, + total_amount = 0 +WHERE id = $1 + AND frozen_charge_cents IS NULL +` + +// MarkBillingRunInvoicedIfUnfrozen is the ZERO-SKIP terminal mark (review +// 2026-07-06 wave 2, D7): marking a run 'invoiced' with NO Stripe call is only +// safe while no attempt has committed to a charge. Under the two-daemons model +// (H6), a concurrent reclaim can freeze + charge between this process's +// top-of-run frozen read and its zero-skip; an unguarded terminal mark would +// bury that charge forever ('invoiced' blocks all future reclaims). The +// frozen_charge_cents IS NULL guard makes the stale zero-skip LOSE: 0 rows → +// the caller errors out, the run stays reclaimable, and the next reclaim +// reconciles the frozen charge. +func (q *Queries) MarkBillingRunInvoicedIfUnfrozen(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, markBillingRunInvoicedIfUnfrozen, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const periodChargedTotal = `-- name: PeriodChargedTotal :one SELECT COALESCE(SUM(ua.charged_micros), 0)::bigint AS total_micros FROM ms_billing.usage_aggregates ua @@ -427,35 +509,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 034) 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 +555,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..5b92ac7 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 { @@ -352,6 +354,12 @@ type MsBillingApp struct { ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` UpdatedAt time.Time `json:"updated_at"` + // Module count frozen at RegisterApp time (immutable — SyncAppModules never writes this column). ChargeCreationProration prices the creation-period window off THIS value, never the live module_count. + CreatedModuleCount int32 `json:"created_module_count"` + // Set once (never unset) when ChargeCreationProration determines the account only activated at/after this app's anchored creation period had already closed — a would-be retroactive catch-up charge (D1d). The app is permanently excluded from the proration sweep from then on. + ProrationSkippedAt pgtype.Timestamptz `json:"proration_skipped_at"` + // First instant a creation-proration charge attempt for this app reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set and an unarmed guard reconciles against Stripe (ms_charge_ref app-proration:) before minting new Stripe objects. + ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` } type MsBillingAppBaseSnapshot struct { @@ -364,6 +372,22 @@ type MsBillingAppBaseSnapshot struct { CreatedAt time.Time `json:"created_at"` } +type MsBillingAppModuleOverageTimer struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + RemovedAt pgtype.Timestamptz `json:"removed_at"` + GraceChargedAt pgtype.Timestamptz `json:"grace_charged_at"` + GraceResolved bool `json:"grace_resolved"` + GraceInvoiceID pgtype.Text `json:"grace_invoice_id"` + GraceInvoiceItemID pgtype.Text `json:"grace_invoice_item_id"` + CreatedAt time.Time `json:"created_at"` + // First instant a Leg-1 (or combined-invoice) charge attempt for this timer reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set reconciles against Stripe (ms_charge_ref) before recomputing any live verdict. + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` +} + type MsBillingBillingPeriod struct { ID string `json:"id"` AccountID string `json:"account_id"` @@ -374,14 +398,16 @@ type MsBillingBillingPeriod struct { } type MsBillingBillingRun struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Status string `json:"status"` - StripeInvoiceID pgtype.Text `json:"stripe_invoice_id"` - TotalAmount pgtype.Numeric `json:"total_amount"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Status string `json:"status"` + StripeInvoiceID pgtype.Text `json:"stripe_invoice_id"` + TotalAmount pgtype.Numeric `json:"total_amount"` + CreatedAt time.Time `json:"created_at"` + FrozenChargeCents pgtype.Int8 `json:"frozen_charge_cents"` + FrozenChargeWithBase pgtype.Bool `json:"frozen_charge_with_base"` } type MsBillingBudget struct { @@ -439,6 +465,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/module_timers.sql.go b/internal/account/db/module_timers.sql.go new file mode 100644 index 0000000..5e640c2 --- /dev/null +++ b/internal/account/db/module_timers.sql.go @@ -0,0 +1,431 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: module_timers.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const coCreatedOverModuleTimers = `-- name: CoCreatedOverModuleTimers :many +SELECT id +FROM ( + SELECT id, app_id, installed_at, grace_resolved, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = $1::uuid + AND removed_at IS NULL +) ranked +WHERE app_id = $2::uuid + AND installed_at = $3::timestamptz + AND grace_resolved = false + AND rn > $4::int +ORDER BY installed_at, id +` + +type CoCreatedOverModuleTimersParams struct { + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + CreatedAt time.Time `json:"created_at"` + IncludedModules int32 `json:"included_modules"` +} + +// CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the +// ids of the app's live, unresolved install timers whose install instant IS the +// app's created_at (co-created at app creation) AND that are "over" (live-FIFO +// rank >= included). Their grace elapses at the SAME instant as the app's own +// creation grace, so the creation-proration charge folds them onto ONE invoice. +// The rank window spans ALL the account's live timers (an included module still +// occupies a FIFO slot), so rn > @included_modules is the 0-based rank >= included +// "over" predicate; the outer filter keeps only this app's co-created, still- +// unresolved rows. Ordered (installed_at, id) for a deterministic charge order. +func (q *Queries) CoCreatedOverModuleTimers(ctx context.Context, arg CoCreatedOverModuleTimersParams) ([]string, error) { + rows, err := q.db.Query(ctx, coCreatedOverModuleTimers, + arg.AccountID, + arg.AppID, + arg.CreatedAt, + arg.IncludedModules, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const countLiveModuleTimersForAccount = `-- name: CountLiveModuleTimersForAccount :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1 + AND removed_at IS NULL +` + +// CountLiveModuleTimersForAccount returns the account's currently-live +// (removed_at IS NULL) install-timer count — the DISPLAY read behind +// GetAccountBill's account-overage line under the per-module-instance model +// (migration 033). The steady-state estimate $3 × max(0, live − included) counts +// the live "over" rows (the FIFO tail past the included 5); reading the timer +// table (the overage model's source of truth) rather than SUM(apps.module_count) +// keeps the shown overage tied to the rows the charge legs actually tier on. +// ::bigint keeps the aggregate a non-nullable scalar. +func (q *Queries) CountLiveModuleTimersForAccount(ctx context.Context, accountID string) (int64, error) { + row := q.db.QueryRow(ctx, countLiveModuleTimersForAccount, accountID) + var live_count int64 + err := row.Scan(&live_count) + return live_count, err +} + +const countOngoingOverModuleTimers = `-- name: CountOngoingOverModuleTimers :one +SELECT COALESCE(count(*), 0)::bigint AS over_count +FROM ( + SELECT installed_at, grace_expires_at, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = $1::uuid + AND removed_at IS NULL +) ranked +WHERE rn > $2::int + AND installed_at < $3::timestamptz + AND grace_expires_at < $3::timestamptz +` + +type CountOngoingOverModuleTimersParams struct { + AccountID string `json:"account_id"` + IncludedModules int32 `json:"included_modules"` + PeriodEnd time.Time `json:"period_end"` +} + +// CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): +// the count of the account's currently-live install timers that are "over" +// (live-FIFO rank >= included) AND owed a FULL $3 precharge for the NEW period +// [@period_end, next boundary) — ongoing over-modules continuing into it. +// row_number() over the whole live set gives every live timer its 1-based FIFO +// rank; rn > @included_modules is exactly the 0-based rank >= included ("over") +// predicate. "over" is re-derived LIVE, so a charged timer that has since flipped +// to "included" (an earlier install removed) is not counted. +// +// The coverage contract with the grace legs (review 2026-07-06, tightened in +// wave 2 D1) — a timer is "ongoing" for the new period iff BOTH of: +// - installed_at < @period_end — it existed before the new period opened. A +// module installed INSIDE the new period had that period covered by its OWN +// grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base +// leg applies via LiveAppsCreatedBefore; without it a reclaimed +// skipped_no_pm/failed boundary run double-bills the period. +// - grace_expires_at < @period_end — its grace elapsed BEFORE the new period +// opened. Every grace charge covers install → the END of the period its grace +// elapses into, so a boundary-straddling timer's new period belongs to Leg 1, +// not this precharge (counting it would double-bill; skipping the NEXT +// boundary would leave a gap — this predicate does neither). +// +// DELIBERATELY NOT a condition: grace_resolved (wave 2, D1). Resolution state +// is MUTABLE and set only by the sweeps, so keying on it made the precharge +// depend on cron ordering: a timer whose grace expired in the ~24h before the +// boundary was still unresolved when the boundary run executed, got excluded, +// and its post-boundary period was then billed by NO leg (Leg 1's coverage is +// derived from immutable timestamps and stops at the boundary). Both cutoffs +// above are immutable, so the precharge decision is identical whenever the run +// (or its reclaim) executes. An expired-unresolved timer counted here is +// charged its own install-period coverage by Leg 1 later — disjoint windows, +// never a double-bill. D1d resolved-uncharged rows count too (only the +// pre-activation install period is forgiven). Residual edge (accepted, +// verdict-at-boundary-time semantics): a timer whose live rank improves +// over→included between this run and its own sweep keeps the one precharge — +// no refund (D1e); the next boundary excludes it by rank. +func (q *Queries) CountOngoingOverModuleTimers(ctx context.Context, arg CountOngoingOverModuleTimersParams) (int64, error) { + row := q.db.QueryRow(ctx, countOngoingOverModuleTimers, arg.AccountID, arg.IncludedModules, arg.PeriodEnd) + var over_count int64 + err := row.Scan(&over_count) + return over_count, err +} + +const insertModuleOverageTimers = `-- name: InsertModuleOverageTimers :exec +INSERT INTO ms_billing.app_module_overage_timers + (account_id, app_id, installed_at, grace_expires_at) +SELECT $1::uuid, $2::uuid, $3::timestamptz, $4::timestamptz +FROM generate_series(1, $5::int) +` + +type InsertModuleOverageTimersParams struct { + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + Count int32 `json:"count"` +} + +// InsertModuleOverageTimers inserts N identical install timers for one app, all +// anchored at the SAME installed_at / grace_expires_at (RegisterApp's K +// co-created modules share created_at; a SyncAppModules grow shares now()). +// generate_series(1, @count) with @count <= 0 yields no rows — a safe no-op. +func (q *Queries) InsertModuleOverageTimers(ctx context.Context, arg InsertModuleOverageTimersParams) error { + _, err := q.db.Exec(ctx, insertModuleOverageTimers, + arg.AccountID, + arg.AppID, + arg.InstalledAt, + arg.GraceExpiresAt, + arg.Count, + ) + return err +} + +const liveModuleTimerCountForApp = `-- name: LiveModuleTimerCountForApp :one + +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE app_id = $1 + AND removed_at IS NULL +` + +// Queries backing the per-module-instance overage timers (migration 033, +// DESIGN.md "Base fee — v2"). One row per module INSTALL EVENT; the charge +// layer (cycle/overage.go) synthesizes instances (the RPC carries only an +// integer module_count), determines included-vs-over LIVE by FIFO, and charges +// the over rows once their own grace elapses. +// LiveModuleTimerCountForApp counts an app's currently-live (removed_at IS NULL) +// install timers — the reconciliation input RegisterApp / SyncAppModules use to +// decide how many rows to insert or LIFO-remove so the live-timer set matches the +// app's module_count idempotently across fire-and-forget retries. ::bigint keeps +// it a non-nullable scalar. +func (q *Queries) LiveModuleTimerCountForApp(ctx context.Context, appID string) (int64, error) { + row := q.db.QueryRow(ctx, liveModuleTimerCountForApp, appID) + var live_count int64 + err := row.Scan(&live_count) + return live_count, err +} + +const liveModuleTimerRankBefore = `-- name: LiveModuleTimerRankBefore :one +SELECT COALESCE(count(*), 0)::bigint AS rank +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1::uuid + AND removed_at IS NULL + AND (installed_at < $2::timestamptz + OR (installed_at = $2::timestamptz AND id < $3::uuid)) +` + +type LiveModuleTimerRankBeforeParams struct { + AccountID string `json:"account_id"` + InstalledAt time.Time `json:"installed_at"` + TimerID string `json:"timer_id"` +} + +// LiveModuleTimerRankBefore returns how many of the account's currently-live +// install timers order STRICTLY BEFORE a given (installed_at, id) under the FIFO +// ordering (installed_at ASC, id ASC) — i.e. the target's 0-based FIFO rank. +// rank < IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". +// Computed fresh at every grace-check (never cached). Backed by +// app_module_overage_timers_live_fifo_idx. +func (q *Queries) LiveModuleTimerRankBefore(ctx context.Context, arg LiveModuleTimerRankBeforeParams) (int64, error) { + row := q.db.QueryRow(ctx, liveModuleTimerRankBefore, arg.AccountID, arg.InstalledAt, arg.TimerID) + var rank int64 + err := row.Scan(&rank) + return rank, err +} + +const markModuleTimerChargeAttempted = `-- name: MarkModuleTimerChargeAttempted :exec +UPDATE ms_billing.app_module_overage_timers +SET charge_attempted_at = $2 +WHERE id = $1 + AND charge_attempted_at IS NULL +` + +type MarkModuleTimerChargeAttemptedParams struct { + ID string `json:"id"` + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` +} + +// MarkModuleTimerChargeAttempted stamps the recovery marker (036) BEFORE a +// charge attempt's first Stripe call. First-write-wins (the FIRST attempt +// instant is the durable one); never cleared. +func (q *Queries) MarkModuleTimerChargeAttempted(ctx context.Context, arg MarkModuleTimerChargeAttemptedParams) error { + _, err := q.db.Exec(ctx, markModuleTimerChargeAttempted, arg.ID, arg.ChargeAttemptedAt) + return err +} + +const markModuleTimerCharged = `-- name: MarkModuleTimerCharged :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true, + grace_charged_at = $1::timestamptz, + grace_invoice_id = $2, + grace_invoice_item_id = $3 +WHERE id = $4::uuid + AND grace_resolved = false +` + +type MarkModuleTimerChargedParams struct { + GraceChargedAt time.Time `json:"grace_charged_at"` + GraceInvoiceID pgtype.Text `json:"grace_invoice_id"` + GraceInvoiceItemID pgtype.Text `json:"grace_invoice_item_id"` + TimerID string `json:"timer_id"` +} + +// MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once +// Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved = true and +// the GENUINE Stripe invoice / invoice-item ids (never idempotency-key strings). +// WHERE grace_resolved = false keeps a crash-retry idempotent (the deterministic +// per-timer Stripe keys already dedupe the charge itself). +func (q *Queries) MarkModuleTimerCharged(ctx context.Context, arg MarkModuleTimerChargedParams) error { + _, err := q.db.Exec(ctx, markModuleTimerCharged, + arg.GraceChargedAt, + arg.GraceInvoiceID, + arg.GraceInvoiceItemID, + arg.TimerID, + ) + return err +} + +const markModuleTimerIncluded = `-- name: MarkModuleTimerIncluded :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true +WHERE id = $1 + AND grace_resolved = false +` + +// MarkModuleTimerIncluded stamps a TERMINAL "included" verdict (grace_resolved = +// true, no charge) on a timer the grace-check found within the included 5. +// WHERE grace_resolved = false is first-write-wins (a concurrent sweep that +// already resolved it affects 0 rows). Monotonicity makes this verdict permanent +// — the row is never re-checked. +func (q *Queries) MarkModuleTimerIncluded(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, markModuleTimerIncluded, id) + return err +} + +const moduleOverageTimersPastGrace = `-- name: ModuleOverageTimersPastGrace :many +SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, + t.charge_attempted_at, a.activated_at +FROM ms_billing.app_module_overage_timers t +JOIN ms_billing.accounts a ON a.id = t.account_id +WHERE t.removed_at IS NULL + AND t.grace_resolved = false + AND t.grace_expires_at <= $1 + AND a.activated_at IS NOT NULL +ORDER BY t.installed_at, t.id +` + +type ModuleOverageTimersPastGraceRow struct { + ID string `json:"id"` + AccountID string `json:"account_id"` + AppID string `json:"app_id"` + InstalledAt time.Time `json:"installed_at"` + GraceExpiresAt time.Time `json:"grace_expires_at"` + ChargeAttemptedAt pgtype.Timestamptz `json:"charge_attempted_at"` + ActivatedAt pgtype.Timestamptz `json:"activated_at"` +} + +// ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install +// timers whose grace window has elapsed as of $1, on accounts that are chargeable +// (activated_at IS NOT NULL — the same activation gate as the spine + proration +// leg). Each row carries the account's activation anchor so the sweep can resolve +// the install's period window without a second read, and the charge_attempted_at +// recovery marker (036) so a retried candidate reconciles against Stripe first. +// Ordered (installed_at, id) so the oldest install charges first (matches the +// FIFO ordering). Backed by app_module_overage_timers_sweep_idx. +func (q *Queries) ModuleOverageTimersPastGrace(ctx context.Context, graceExpiresAt time.Time) ([]ModuleOverageTimersPastGraceRow, error) { + rows, err := q.db.Query(ctx, moduleOverageTimersPastGrace, graceExpiresAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ModuleOverageTimersPastGraceRow{} + for rows.Next() { + var i ModuleOverageTimersPastGraceRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AppID, + &i.InstalledAt, + &i.GraceExpiresAt, + &i.ChargeAttemptedAt, + &i.ActivatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const moduleTimerStillPending = `-- name: ModuleTimerStillPending :one +SELECT (removed_at IS NULL AND grace_resolved = false)::bool AS pending +FROM ms_billing.app_module_overage_timers +WHERE id = $1 +` + +// ModuleTimerStillPending is the charge-time re-verification read (review +// 2026-07-06, M2): the sweep's work list is read ONCE and can be minutes stale +// by the time a late candidate is processed — re-check live + unresolved +// immediately before acting so a module removed (or resolved by a concurrent +// sweep) mid-batch is not charged. +func (q *Queries) ModuleTimerStillPending(ctx context.Context, id string) (bool, error) { + row := q.db.QueryRow(ctx, moduleTimerStillPending, id) + var pending bool + err := row.Scan(&pending) + return pending, err +} + +const softRemoveAllModuleTimersForApp = `-- name: SoftRemoveAllModuleTimersForApp :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $2 +WHERE app_id = $1 + AND removed_at IS NULL +` + +type SoftRemoveAllModuleTimersForAppParams struct { + AppID string `json:"app_id"` + RemovedAt pgtype.Timestamptz `json:"removed_at"` +} + +// SoftRemoveAllModuleTimersForApp soft-removes EVERY still-live install timer for +// an app — the app-deletion path. Idempotent: a re-fire affects the rows already +// removed 0 times (WHERE removed_at IS NULL). +func (q *Queries) SoftRemoveAllModuleTimersForApp(ctx context.Context, arg SoftRemoveAllModuleTimersForAppParams) error { + _, err := q.db.Exec(ctx, softRemoveAllModuleTimersForApp, arg.AppID, arg.RemovedAt) + return err +} + +const softRemoveNewestModuleTimers = `-- name: SoftRemoveNewestModuleTimers :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $1::timestamptz +WHERE id IN ( + SELECT id + FROM ms_billing.app_module_overage_timers + WHERE app_id = $2::uuid + AND removed_at IS NULL + ORDER BY installed_at DESC, id DESC + LIMIT $3::int +) +` + +type SoftRemoveNewestModuleTimersParams struct { + RemovedAt time.Time `json:"removed_at"` + AppID string `json:"app_id"` + LimitCount int32 `json:"limit_count"` +} + +// SoftRemoveNewestModuleTimers LIFO-soft-removes the N NEWEST currently-live +// install timers for one app (a SyncAppModules shrink removes what was added most +// recently). Ordered (installed_at DESC, id DESC) — the reverse of the FIFO +// ordering. Sets removed_at = @removed_at on exactly those rows. +func (q *Queries) SoftRemoveNewestModuleTimers(ctx context.Context, arg SoftRemoveNewestModuleTimersParams) error { + _, err := q.db.Exec(ctx, softRemoveNewestModuleTimers, arg.RemovedAt, arg.AppID, arg.LimitCount) + return err +} diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index e827cfd..f0f5a3b 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -5,23 +5,65 @@ -- InsertAppMirror registers an app row idempotently: ON CONFLICT (app_id) DO -- NOTHING so a RegisterApp retry (or a concurrent double-fire) never rewrites --- created_at / module_count / the proration guard of the original insert — --- the FIRST registration's values are the stable proration anchor. :execrows --- so the caller can tell a fresh insert (1) from a retry no-op (0), though --- both are success. +-- created_at / module_count / created_module_count / the proration guard of +-- the original insert — the FIRST registration's values are the stable +-- proration anchor. created_module_count is stamped from the SAME $3 value as +-- module_count and NEVER written again by any other query (migration 030) — +-- it is the frozen count ChargeCreationProration prices its historical window +-- from, immune to a later SyncAppModules install/uninstall during grace. +-- :execrows so the caller can tell a fresh insert (1) from a retry no-op (0), +-- though both are success. -- name: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_at) -VALUES ($1, $2, $3, $4) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at) +VALUES ($1, $2, $3, $3, $4) ON CONFLICT (app_id) DO NOTHING; -- SelectAppMirror reads one roster row (deleted or not — the caller decides -- what deletion means for its path: SyncAppModules no-ops a count update, -- GetAppBill still displays the spent creation-period base). -- name: SelectAppMirror :one -SELECT app_id, account_id, module_count, created_at, proration_invoice_id, deleted_at +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at FROM ms_billing.apps WHERE app_id = $1; +-- SelectAppMirrorForUpdate reads one roster row under a ROW LOCK (FOR UPDATE) — +-- the creation-proration charge's race-safety primitive. The charge locks the +-- row here just long enough to re-verify deleted_at IS NULL and +-- proration_invoice_id IS NULL and read the frozen created_module_count, +-- releasing the lock immediately after (ChargeProrationLocked runs the actual +-- Stripe network call OUTSIDE this lock — see store.go); a concurrent +-- SyncAppModules soft-delete (MarkAppDeleted) only ever contends for the brief +-- read, never for the duration of a Stripe HTTP call. +-- name: SelectAppMirrorForUpdate :one +SELECT app_id, account_id, module_count, created_module_count, created_at, + proration_invoice_id, proration_skipped_at, proration_attempted_at, deleted_at +FROM ms_billing.apps +WHERE app_id = $1 +FOR UPDATE; + +-- AppsPendingProration is the creation-proration sweep's work list: apps that +-- have survived the grace window (@created_before = now() − GraceDays) and were +-- never charged their creation-period base. proration_invoice_id IS NULL is the +-- one-shot guard (an already-charged app drops out); the deleted_at predicate +-- excludes ONLY apps soft-deleted WITHIN their grace (never charged, scenario +-- 1) — an app deleted AFTER its grace elapsed SURVIVED it and still owes the +-- creation charge (wave 2, D11: grace only delays WHEN the charge fires, and +-- the H2 boundary exclusion leaves no other leg as a backstop; pre-fix this +-- was a user-timable ~$22 dodge in the grace-elapse→sweep window). Grace in +-- HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes +-- apps permanently skipped as a would-be retroactive catch-up (migration 031, +-- D1d). Ordered by created_at so the oldest pending app charges first. +-- name: AppsPendingProration :many +SELECT app_id +FROM ms_billing.apps +WHERE created_at <= @created_before::timestamptz + AND proration_invoice_id IS NULL + AND (deleted_at IS NULL + OR deleted_at >= created_at + make_interval(hours => @grace_hours::int)) + AND proration_skipped_at IS NULL +ORDER BY created_at; + -- SetAppProrationInvoice arms the ONE-SHOT proration guard: it records the -- Stripe invoice id of the creation-proration charge, and the WHERE -- proration_invoice_id IS NULL makes the write first-charge-wins — a retry or @@ -33,6 +75,29 @@ SET proration_invoice_id = $2 WHERE app_id = $1 AND proration_invoice_id IS NULL; +-- SetAppProrationSkipped arms the PERMANENT skip marker (migration 031, D1d): +-- the account only activated at/after this app's anchored creation period had +-- already closed, so charging it now would be a retroactive catch-up. The +-- WHERE clause makes it idempotent (a re-evaluation, or a concurrent one, +-- affects 0 rows once set) and defensively refuses to mark an app that was +-- somehow already charged in the meantime. :execrows so the caller can +-- observe (and tolerate) the already-set / already-charged cases. +-- name: SetAppProrationSkipped :execrows +UPDATE ms_billing.apps +SET proration_skipped_at = now() +WHERE app_id = $1 + AND proration_skipped_at IS NULL + AND proration_invoice_id IS NULL; + +-- MarkAppProrationAttempted stamps the recovery marker (036) BEFORE a +-- creation-proration charge attempt's first Stripe call. First-write-wins +-- (the FIRST attempt instant is the durable one); never cleared. +-- name: MarkAppProrationAttempted :exec +UPDATE ms_billing.apps +SET proration_attempted_at = $2 +WHERE app_id = $1 + AND proration_attempted_at IS NULL; + -- SetAppModuleCount snapshots a new installed-module count (SyncAppModules). -- WHERE deleted_at IS NULL makes a count update on a deleted app a no-op -- (D1e — a deleted app accrues no future base, so its count is frozen). @@ -54,24 +119,41 @@ WHERE app_id = $1 AND deleted_at IS NULL; -- LiveAppModuleCountsCreatedBefore returns (app_id, module_count) for every --- LIVE (deleted_at IS NULL) app on the account created STRICTLY BEFORE the --- cutoff — the boundary charge's advance-base input: +-- LIVE (deleted_at IS NULL) app on the account that has JOINED the advance +-- base mechanism by the cutoff — the boundary charge's advance-base input: -- advance base = Σ (BaseFee + Overage × max(0, module_count − included)). --- The cutoff is the NEW period's start (the closed window's period_end): an --- app created INSIDE the new period is excluded, because RegisterApp's --- creation-proration leg already charged that app's new-period base (full or --- prorated) — summing it here would double-bill the same period (same-day --- cron race, and deterministically on reclaimed skipped_no_pm/failed runs). --- Such an app joins the advance leg at the NEXT boundary. Deleted apps are --- excluded (D1e); an account with no rows (pre-backfill) sums to base 0 and --- keeps the pre-027 arrears-only invoice. app_id is returned so the charge --- leg can write the per-app-period base snapshot (migration 028) it bills. +-- The cutoff is the NEW period's start (the closed window's period_end). Two +-- conditions, mirroring the module-timer coverage contract (review 2026-07-06): +-- * created_at < @created_before — an app created INSIDE the new period is +-- excluded, because RegisterApp's creation-proration leg already charged +-- that app's new-period base (full or prorated) — summing it here would +-- double-bill the same period (same-day cron race, and deterministically +-- on reclaimed skipped_no_pm/failed runs). +-- * created_at + grace < @created_before — an app whose CREATION GRACE had +-- not yet elapsed when the new period opened is excluded: it has not +-- survived grace yet (an app deleted in grace is NEVER charged, scenario +-- 1 — precharging its next-period base here would bill a full month for +-- an app that can still be deleted for free), and when it does survive, +-- its creation-proration charge covers through the END of the period its +-- grace elapses into (the straddled period), so this boundary's new +-- period is already that leg's coverage. Spec: apps "join this boundary +-- mechanism starting at the NEXT boundary after their own creation charge +-- fires". +-- Deleted apps are excluded (D1e); an account with no rows (pre-backfill) +-- sums to base 0 and keeps the pre-027 arrears-only invoice. app_id is +-- returned so the charge leg can write the per-app-period base snapshot +-- (migration 028) it bills. The grace interval is expressed in HOURS, not +-- days (wave 2, D5): timestamptz + a day-interval is evaluated in the SESSION +-- timezone (DST-shifting), while the Go legs' grace is a fixed GraceDays*24h +-- UTC window (moduleGraceExpiry) — a non-UTC session would disagree with them +-- by an hour around DST and double-bill or gap a whole period. -- name: LiveAppModuleCountsCreatedBefore :many SELECT app_id, module_count FROM ms_billing.apps -WHERE account_id = $1 +WHERE account_id = @account_id::uuid AND deleted_at IS NULL - AND created_at < $2; + AND created_at < @created_before::timestamptz + AND created_at + make_interval(hours => @grace_hours::int) < @created_before::timestamptz; -- UpsertProrationBaseSnapshot records what RegisterApp's creation-proration -- leg billed one app for its creation period (migration 028). Keyed by the diff --git a/internal/account/db/queries/cycle.sql b/internal/account/db/queries/cycle.sql index 6bb4cf7..07fcafa 100644 --- a/internal/account/db/queries/cycle.sql +++ b/internal/account/db/queries/cycle.sql @@ -162,26 +162,74 @@ SET status = $2, total_amount = $4 WHERE id = $1; +-- MarkBillingRunInvoicedIfUnfrozen is the ZERO-SKIP terminal mark (review +-- 2026-07-06 wave 2, D7): marking a run 'invoiced' with NO Stripe call is only +-- safe while no attempt has committed to a charge. Under the two-daemons model +-- (H6), a concurrent reclaim can freeze + charge between this process's +-- top-of-run frozen read and its zero-skip; an unguarded terminal mark would +-- bury that charge forever ('invoiced' blocks all future reclaims). The +-- frozen_charge_cents IS NULL guard makes the stale zero-skip LOSE: 0 rows → +-- the caller errors out, the run stays reclaimable, and the next reclaim +-- reconciles the frozen charge. +-- name: MarkBillingRunInvoicedIfUnfrozen :execrows +UPDATE ms_billing.billing_runs +SET status = 'invoiced', + stripe_invoice_id = NULL, + total_amount = 0 +WHERE id = $1 + AND frozen_charge_cents IS NULL; + +-- FreezeBillingRunCharge records, BEFORE the boundary Stripe charge, the exact +-- whole-cent amount AND the base/overage description determinant this run will +-- send Stripe under the deterministic idem keys ii- / inv- (migration +-- 035). First-write-wins (WHERE frozen_charge_cents IS NULL): a reclaimed run that +-- already froze keeps its ORIGINAL values, so a retry that recomputed a different +-- LIVE total can never send Stripe a mismatched request under the same idem key. +-- InsertBillingRun's ON CONFLICT DO UPDATE deliberately leaves these columns +-- untouched, so the freeze survives the reclaim. +-- name: FreezeBillingRunCharge :exec +UPDATE ms_billing.billing_runs +SET frozen_charge_cents = $2, + frozen_charge_with_base = $3 +WHERE id = $1 + AND frozen_charge_cents IS NULL; + +-- BillingRunFrozenCharge reads a run's frozen boundary-charge amount + description +-- determinant (set by a prior attempt's FreezeBillingRunCharge). Both are NULL +-- when no prior attempt reached the Stripe charge (a fresh run), so the caller +-- freezes the freshly-computed values and charges them; on a reclaim they are the +-- amount already charged, which the retry REUSES verbatim. +-- name: BillingRunFrozenCharge :one +SELECT frozen_charge_cents, frozen_charge_with_base +FROM ms_billing.billing_runs +WHERE id = $1; + -- UpsertInvoice mirrors a Stripe invoice into ms_billing.invoices, keyed on the -- UNIQUE stripe_invoice_id so a re-run (deterministic Stripe Idempotency-Key -- returns the same invoice) upserts the same row rather than duplicating it. -- Webhook reconciliation (PR #7) later updates status + amount_paid in place. +-- is_large_auto_collect (migration 034) 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 +265,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/db/queries/module_timers.sql b/internal/account/db/queries/module_timers.sql new file mode 100644 index 0000000..2c5385d --- /dev/null +++ b/internal/account/db/queries/module_timers.sql @@ -0,0 +1,215 @@ +-- Queries backing the per-module-instance overage timers (migration 033, +-- DESIGN.md "Base fee — v2"). One row per module INSTALL EVENT; the charge +-- layer (cycle/overage.go) synthesizes instances (the RPC carries only an +-- integer module_count), determines included-vs-over LIVE by FIFO, and charges +-- the over rows once their own grace elapses. + +-- LiveModuleTimerCountForApp counts an app's currently-live (removed_at IS NULL) +-- install timers — the reconciliation input RegisterApp / SyncAppModules use to +-- decide how many rows to insert or LIFO-remove so the live-timer set matches the +-- app's module_count idempotently across fire-and-forget retries. ::bigint keeps +-- it a non-nullable scalar. +-- name: LiveModuleTimerCountForApp :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE app_id = $1 + AND removed_at IS NULL; + +-- InsertModuleOverageTimers inserts N identical install timers for one app, all +-- anchored at the SAME installed_at / grace_expires_at (RegisterApp's K +-- co-created modules share created_at; a SyncAppModules grow shares now()). +-- generate_series(1, @count) with @count <= 0 yields no rows — a safe no-op. +-- name: InsertModuleOverageTimers :exec +INSERT INTO ms_billing.app_module_overage_timers + (account_id, app_id, installed_at, grace_expires_at) +SELECT @account_id::uuid, @app_id::uuid, @installed_at::timestamptz, @grace_expires_at::timestamptz +FROM generate_series(1, @count::int); + +-- SoftRemoveNewestModuleTimers LIFO-soft-removes the N NEWEST currently-live +-- install timers for one app (a SyncAppModules shrink removes what was added most +-- recently). Ordered (installed_at DESC, id DESC) — the reverse of the FIFO +-- ordering. Sets removed_at = @removed_at on exactly those rows. +-- name: SoftRemoveNewestModuleTimers :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = @removed_at::timestamptz +WHERE id IN ( + SELECT id + FROM ms_billing.app_module_overage_timers + WHERE app_id = @app_id::uuid + AND removed_at IS NULL + ORDER BY installed_at DESC, id DESC + LIMIT @limit_count::int +); + +-- SoftRemoveAllModuleTimersForApp soft-removes EVERY still-live install timer for +-- an app — the app-deletion path. Idempotent: a re-fire affects the rows already +-- removed 0 times (WHERE removed_at IS NULL). +-- name: SoftRemoveAllModuleTimersForApp :exec +UPDATE ms_billing.app_module_overage_timers +SET removed_at = $2 +WHERE app_id = $1 + AND removed_at IS NULL; + +-- ModuleOverageTimersPastGrace is Leg 1's work list: live, unresolved install +-- timers whose grace window has elapsed as of $1, on accounts that are chargeable +-- (activated_at IS NOT NULL — the same activation gate as the spine + proration +-- leg). Each row carries the account's activation anchor so the sweep can resolve +-- the install's period window without a second read, and the charge_attempted_at +-- recovery marker (036) so a retried candidate reconciles against Stripe first. +-- Ordered (installed_at, id) so the oldest install charges first (matches the +-- FIFO ordering). Backed by app_module_overage_timers_sweep_idx. +-- name: ModuleOverageTimersPastGrace :many +SELECT t.id, t.account_id, t.app_id, t.installed_at, t.grace_expires_at, + t.charge_attempted_at, a.activated_at +FROM ms_billing.app_module_overage_timers t +JOIN ms_billing.accounts a ON a.id = t.account_id +WHERE t.removed_at IS NULL + AND t.grace_resolved = false + AND t.grace_expires_at <= $1 + AND a.activated_at IS NOT NULL +ORDER BY t.installed_at, t.id; + +-- MarkModuleTimerChargeAttempted stamps the recovery marker (036) BEFORE a +-- charge attempt's first Stripe call. First-write-wins (the FIRST attempt +-- instant is the durable one); never cleared. +-- name: MarkModuleTimerChargeAttempted :exec +UPDATE ms_billing.app_module_overage_timers +SET charge_attempted_at = $2 +WHERE id = $1 + AND charge_attempted_at IS NULL; + +-- ModuleTimerStillPending is the charge-time re-verification read (review +-- 2026-07-06, M2): the sweep's work list is read ONCE and can be minutes stale +-- by the time a late candidate is processed — re-check live + unresolved +-- immediately before acting so a module removed (or resolved by a concurrent +-- sweep) mid-batch is not charged. +-- name: ModuleTimerStillPending :one +SELECT (removed_at IS NULL AND grace_resolved = false)::bool AS pending +FROM ms_billing.app_module_overage_timers +WHERE id = $1; + +-- LiveModuleTimerRankBefore returns how many of the account's currently-live +-- install timers order STRICTLY BEFORE a given (installed_at, id) under the FIFO +-- ordering (installed_at ASC, id ASC) — i.e. the target's 0-based FIFO rank. +-- rank < IncludedModules ⇒ "included"; rank >= IncludedModules ⇒ "over". +-- Computed fresh at every grace-check (never cached). Backed by +-- app_module_overage_timers_live_fifo_idx. +-- name: LiveModuleTimerRankBefore :one +SELECT COALESCE(count(*), 0)::bigint AS rank +FROM ms_billing.app_module_overage_timers +WHERE account_id = @account_id::uuid + AND removed_at IS NULL + AND (installed_at < @installed_at::timestamptz + OR (installed_at = @installed_at::timestamptz AND id < @timer_id::uuid)); + +-- MarkModuleTimerIncluded stamps a TERMINAL "included" verdict (grace_resolved = +-- true, no charge) on a timer the grace-check found within the included 5. +-- WHERE grace_resolved = false is first-write-wins (a concurrent sweep that +-- already resolved it affects 0 rows). Monotonicity makes this verdict permanent +-- — the row is never re-checked. +-- name: MarkModuleTimerIncluded :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true +WHERE id = $1 + AND grace_resolved = false; + +-- MarkModuleTimerCharged stamps the TERMINAL "over and charged" verdict once +-- Leg 1's Stripe charge succeeded: grace_charged_at + grace_resolved = true and +-- the GENUINE Stripe invoice / invoice-item ids (never idempotency-key strings). +-- WHERE grace_resolved = false keeps a crash-retry idempotent (the deterministic +-- per-timer Stripe keys already dedupe the charge itself). +-- name: MarkModuleTimerCharged :exec +UPDATE ms_billing.app_module_overage_timers +SET grace_resolved = true, + grace_charged_at = @grace_charged_at::timestamptz, + grace_invoice_id = @grace_invoice_id, + grace_invoice_item_id = @grace_invoice_item_id +WHERE id = @timer_id::uuid + AND grace_resolved = false; + +-- CountOngoingOverModuleTimers is Leg 2's boundary-precharge input (scenario 6): +-- the count of the account's currently-live install timers that are "over" +-- (live-FIFO rank >= included) AND owed a FULL $3 precharge for the NEW period +-- [@period_end, next boundary) — ongoing over-modules continuing into it. +-- row_number() over the whole live set gives every live timer its 1-based FIFO +-- rank; rn > @included_modules is exactly the 0-based rank >= included ("over") +-- predicate. "over" is re-derived LIVE, so a charged timer that has since flipped +-- to "included" (an earlier install removed) is not counted. +-- +-- The coverage contract with the grace legs (review 2026-07-06, tightened in +-- wave 2 D1) — a timer is "ongoing" for the new period iff BOTH of: +-- * installed_at < @period_end — it existed before the new period opened. A +-- module installed INSIDE the new period had that period covered by its OWN +-- grace charge (Leg 1 / scenario 3), exactly the same cutoff the advance-base +-- leg applies via LiveAppsCreatedBefore; without it a reclaimed +-- skipped_no_pm/failed boundary run double-bills the period. +-- * grace_expires_at < @period_end — its grace elapsed BEFORE the new period +-- opened. Every grace charge covers install → the END of the period its grace +-- elapses into, so a boundary-straddling timer's new period belongs to Leg 1, +-- not this precharge (counting it would double-bill; skipping the NEXT +-- boundary would leave a gap — this predicate does neither). +-- +-- DELIBERATELY NOT a condition: grace_resolved (wave 2, D1). Resolution state +-- is MUTABLE and set only by the sweeps, so keying on it made the precharge +-- depend on cron ordering: a timer whose grace expired in the ~24h before the +-- boundary was still unresolved when the boundary run executed, got excluded, +-- and its post-boundary period was then billed by NO leg (Leg 1's coverage is +-- derived from immutable timestamps and stops at the boundary). Both cutoffs +-- above are immutable, so the precharge decision is identical whenever the run +-- (or its reclaim) executes. An expired-unresolved timer counted here is +-- charged its own install-period coverage by Leg 1 later — disjoint windows, +-- never a double-bill. D1d resolved-uncharged rows count too (only the +-- pre-activation install period is forgiven). Residual edge (accepted, +-- verdict-at-boundary-time semantics): a timer whose live rank improves +-- over→included between this run and its own sweep keeps the one precharge — +-- no refund (D1e); the next boundary excludes it by rank. +-- name: CountOngoingOverModuleTimers :one +SELECT COALESCE(count(*), 0)::bigint AS over_count +FROM ( + SELECT installed_at, grace_expires_at, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = @account_id::uuid + AND removed_at IS NULL +) ranked +WHERE rn > @included_modules::int + AND installed_at < @period_end::timestamptz + AND grace_expires_at < @period_end::timestamptz; + +-- CoCreatedOverModuleTimers backs the scenario-3 combined creation invoice: the +-- ids of the app's live, unresolved install timers whose install instant IS the +-- app's created_at (co-created at app creation) AND that are "over" (live-FIFO +-- rank >= included). Their grace elapses at the SAME instant as the app's own +-- creation grace, so the creation-proration charge folds them onto ONE invoice. +-- The rank window spans ALL the account's live timers (an included module still +-- occupies a FIFO slot), so rn > @included_modules is the 0-based rank >= included +-- "over" predicate; the outer filter keeps only this app's co-created, still- +-- unresolved rows. Ordered (installed_at, id) for a deterministic charge order. +-- name: CoCreatedOverModuleTimers :many +SELECT id +FROM ( + SELECT id, app_id, installed_at, grace_resolved, + row_number() OVER (ORDER BY installed_at, id) AS rn + FROM ms_billing.app_module_overage_timers + WHERE account_id = @account_id::uuid + AND removed_at IS NULL +) ranked +WHERE app_id = @app_id::uuid + AND installed_at = @created_at::timestamptz + AND grace_resolved = false + AND rn > @included_modules::int +ORDER BY installed_at, id; + +-- CountLiveModuleTimersForAccount returns the account's currently-live +-- (removed_at IS NULL) install-timer count — the DISPLAY read behind +-- GetAccountBill's account-overage line under the per-module-instance model +-- (migration 033). The steady-state estimate $3 × max(0, live − included) counts +-- the live "over" rows (the FIFO tail past the included 5); reading the timer +-- table (the overage model's source of truth) rather than SUM(apps.module_count) +-- keeps the shown overage tied to the rows the charge legs actually tier on. +-- ::bigint keeps the aggregate a non-nullable scalar. +-- name: CountLiveModuleTimersForAccount :one +SELECT COALESCE(count(*), 0)::bigint AS live_count +FROM ms_billing.app_module_overage_timers +WHERE account_id = $1 + AND removed_at IS NULL; diff --git a/internal/account/usage/account_bill_integration_test.go b/internal/account/usage/account_bill_integration_test.go index ff58f6a..200904f 100644 --- a/internal/account/usage/account_bill_integration_test.go +++ b/internal/account/usage/account_bill_integration_test.go @@ -31,8 +31,8 @@ func seedMirrorApp(t *testing.T, pool *pgxpool.Pool, acct, app uuid.UUID, create del = deletedAt } _, err := pool.Exec(context.Background(), - `INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_at, deleted_at) - VALUES ($1, $2, 0, $3, $4)`, + `INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, deleted_at) + VALUES ($1, $2, 0, 0, $3, $4)`, app.String(), acct.String(), createdAt, del) require.NoError(t, err) } diff --git a/internal/account/usage/accountbill.go b/internal/account/usage/accountbill.go index daebdb9..dd2544d 100644 --- a/internal/account/usage/accountbill.go +++ b/internal/account/usage/accountbill.go @@ -66,8 +66,11 @@ const ( // 5. applies the PaaS credit ONCE at the ACCOUNT level: the same ACTIVE-SaaS // gate as the per-app credit (v1 has no subscription system → always 0), // capped at ModuleUsageTotal + InfraTotal so it never eats base fees, -// 6. TotalMicros = BaseFeeTotal + ModuleUsageTotal + InfraTotal − PaasCredit -// (≥ 0 by the cap), plus the v1 plan stub with RenewsAt = the period end. +// 6. adds the account-wide POOLED module overage (migration 032) once, +// snapshot-first (the frozen charge, else a live estimate from the pool), +// 7. TotalMicros = BaseFeeTotal + ModuleUsageTotal + InfraTotal + AccountOverage +// − PaasCredit (≥ 0 by the cap), plus the v1 plan stub with RenewsAt = the +// period end. func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) (*GetAccountBillResponse, error) { if req.OwnerUserID == uuid.Nil && req.OwnerOrgID == uuid.Nil { return nil, billing.InvalidInput("owner_user_id or owner_org_id required") @@ -168,6 +171,21 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) infraTotal += parts.InfraTotalMicros } + // Account overage line (migration 033): the steady-state monthly estimate + // from the CURRENT live install timers — $3 × max(0, live − IncludedModules). + // Under the per-module-instance model overage is billed per install on its own + // grace timer (Leg 1) / at the boundary (Leg 2); the display sums the live + // "over" timer rows and prices them at the steady-state $3 each — the timer + // table is the overage model's source of truth. The display does not prorate + // (the charge legs own proration), so this is an estimate of the period's + // overage, not a per-line replay of the exact prorated amounts invoiced (that + // would need a per-timer charged-micros column — a follow-up). It is an + // ACCOUNT line, never allocated back per app. + accountOverage, err := s.accountOverageMicros(ctx, accountID) + if err != nil { + return nil, err + } + // TODO(subscription): same gate as GetAppBill — v1 has no subscription // system, so the credit is subscription-gated OFF and resolves to 0. const subscriptionActive = false @@ -185,11 +203,28 @@ func (s *Service) GetAccountBill(ctx context.Context, req GetAccountBillRequest) BaseFeeTotalMicros: baseFeeTotal, ModuleUsageTotalMicros: moduleUsageTotal, InfraTotalMicros: infraTotal, + AccountOverageMicros: accountOverage, PaasCreditMicros: paasCredit, - TotalMicros: baseFeeTotal + moduleUsageTotal + infraTotal - paasCredit, + TotalMicros: baseFeeTotal + moduleUsageTotal + infraTotal + accountOverage - paasCredit, }, nil } +// accountOverageMicros resolves the account overage shown on the bill: the +// steady-state monthly estimate AccountOverageMicros(live timer count) = $3 × +// max(0, live − IncludedModules), where `live` is the count of the account's +// currently-live install timers (migration 033) — the overage model's source of +// truth. The first IncludedModules live timers (by FIFO) are "included"; the rest +// are "over", so max(0, live − included) is exactly the live over-count the charge +// legs tier on. The PaaS credit never offsets it (overage rides ON TOP, like the +// base fee), so it is resolved outside the credit math. +func (s *Service) accountOverageMicros(ctx context.Context, accountID uuid.UUID) (int64, error) { + live, err := s.store.LiveModuleTimerCountForAccount(ctx, accountID) + if err != nil { + return 0, billing.Internal("live module timer count lookup failed", err) + } + return AccountOverageMicros(live), nil +} + // accountPaasCreditMicros is the ACCOUNT-level PaaS credit: the same // subscription-gated pct-of-infra magnitude as GetAppBill's per-app // paasCreditMicros, computed over the ACCOUNT-WIDE infra total and applied diff --git a/internal/account/usage/accountbill_test.go b/internal/account/usage/accountbill_test.go index 6d86162..f32a94f 100644 --- a/internal/account/usage/accountbill_test.go +++ b/internal/account/usage/accountbill_test.go @@ -35,9 +35,11 @@ func TestGetAccountBill_AggregatesUsageMirrorAndBothApps(t *testing.T) { // half; base = legacy flat + usage-proxy overage (1 module → flat). // appB — mirror-only, ZERO usage (just created, nothing metered): must // still appear with its full base (created long before the period). - // appC — both halves: synced count 7 → base $20 + 2×$3; usage + module- - // attributed infra. - // Every per-app number is exactly what GetAppBill would return for that app. + // appC — both halves: synced count 7 → FLAT $20 base (overage is pooled, + // migration 032); usage + module-attributed infra. + // The account-wide pool is 0 (appB) + 7 (appC) = 7 → 2 over → $6 pooled + // overage on the ACCOUNT (not on any app). Every per-app number is exactly + // what GetAppBill would return for that app. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -79,26 +81,33 @@ func TestGetAccountBill_AggregatesUsageMirrorAndBothApps(t *testing.T) { require.EqualValues(t, 200, resp.Apps[1].InfraMicros) require.Equal(t, usage.BaseFeeMicros+1200, resp.Apps[1].TotalMicros) - // appC: synced count 7 → base + 2 × overage; module-attributed infra folds in. - wantBaseC := usage.BaseFeeMicros + 2*usage.ModuleOverageFeeMicros - require.Equal(t, wantBaseC, resp.Apps[2].BaseFeeMicros) + // appC: synced count 7 → FLAT base (overage is pooled, not per-app); + // module-attributed infra folds in. + require.Equal(t, usage.BaseFeeMicros, resp.Apps[2].BaseFeeMicros) require.EqualValues(t, 500, resp.Apps[2].ModuleUsageMicros) require.EqualValues(t, 24, resp.Apps[2].InfraMicros) - require.Equal(t, wantBaseC+524, resp.Apps[2].TotalMicros) + require.Equal(t, usage.BaseFeeMicros+524, resp.Apps[2].TotalMicros) - // Account totals are the column sums; credit is gated off (v1) → total is - // the plain sum and apps[].total_micros (pre-credit) reconcile exactly. - require.Equal(t, 2*usage.BaseFeeMicros+wantBaseC, resp.BaseFeeTotalMicros) + // Account totals are the column sums plus the account-wide POOLED overage. + // All three apps are flat, so BaseFeeTotal = 3 × $20. Pool 7 → 2 over → $6. + require.Equal(t, 3*usage.BaseFeeMicros, resp.BaseFeeTotalMicros) require.EqualValues(t, 1500, resp.ModuleUsageTotalMicros) require.EqualValues(t, 224, resp.InfraTotalMicros) + require.Equal(t, usage.AccountOverageMicros(7), resp.AccountOverageMicros) + require.EqualValues(t, 6_000_000, resp.AccountOverageMicros) // 2 over × $3 require.Zero(t, resp.PaasCreditMicros) - require.Equal(t, resp.BaseFeeTotalMicros+resp.ModuleUsageTotalMicros+resp.InfraTotalMicros, resp.TotalMicros) - var preCredit int64 + require.Equal(t, + resp.BaseFeeTotalMicros+resp.ModuleUsageTotalMicros+resp.InfraTotalMicros+resp.AccountOverageMicros, + resp.TotalMicros) + // apps[].total_micros are PRE-credit AND exclude the account-level pooled + // overage (it is never allocated per-app), so Σ apps == account total − + // overage + credit. + var perApp int64 for _, a := range resp.Apps { - preCredit += a.TotalMicros + perApp += a.TotalMicros } - require.Equal(t, resp.TotalMicros+resp.PaasCreditMicros, preCredit, - "Σ apps[].total (pre-credit) == account total + credit") + require.Equal(t, resp.TotalMicros-resp.AccountOverageMicros+resp.PaasCreditMicros, perApp, + "Σ apps[].total (pre-credit) == account total − pooled overage + credit") } func TestGetAccountBill_DeterministicAppOrdering(t *testing.T) { @@ -171,10 +180,11 @@ func TestGetAccountBill_MirrorLifecycleAcrossTheWindow(t *testing.T) { // --- snapshot-first base ---------------------------------------------------- func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { - // The account bill inherits GetAppBill's snapshot-first base: the May - // period was CHARGED at count 5 (flat $20 snapshot), then SyncAppModules - // moved the mirror to 8. The frozen period must show the snapshot base; - // the CURRENT (un-snapshotted) period estimates from the live count 8. + // The account bill inherits GetAppBill's snapshot-first PER-APP base: the May + // period was CHARGED at a flat $20 snapshot, then SyncAppModules moved the + // mirror to 8. The per-app base is flat in both periods (overage is pooled). + // The CURRENT (un-snapshotted) period's account-wide overage estimates from + // the live pool 8 → 3 over → $9. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -182,7 +192,7 @@ func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { app := seqUUID(1) store.appMirrors[app] = usage.AppMirrorInfo{ModuleCount: 8, CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)} store.baseSnapshots[baseSnapKey(app, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC))] = usage.AppBaseSnapshotInfo{ - BaseMicros: usage.AppBaseFeeMicros(usage.BaseFeeMicros, 5), + BaseMicros: usage.BaseFeeMicros, } resp, err := newService(store).GetAccountBill(context.Background(), usage.GetAccountBillRequest{ @@ -190,13 +200,14 @@ func TestGetAccountBill_SnapshotBaseFlowsThroughChargedPeriod(t *testing.T) { }) require.NoError(t, err) require.Len(t, resp.Apps, 1) - require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "charged period shows the invoiced snapshot, not the resynced count") + require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "charged period shows the invoiced flat snapshot") resp, err = newService(store).GetAccountBill(context.Background(), usage.GetAccountBillRequest{OwnerUserID: owner}) require.NoError(t, err) require.Len(t, resp.Apps, 1) - require.Equal(t, usage.BaseFeeMicros+3*usage.ModuleOverageFeeMicros, resp.Apps[0].BaseFeeMicros, - "un-snapshotted current period estimates from the synced count 8") + require.Equal(t, usage.BaseFeeMicros, resp.Apps[0].BaseFeeMicros, "per-app base is flat regardless of count") + require.EqualValues(t, 9_000_000, resp.AccountOverageMicros, + "un-snapshotted current period estimates the pooled overage from the live pool 8 → 3 over → $9") } // --- period resolution -------------------------------------------------------- diff --git a/internal/account/usage/basefee.go b/internal/account/usage/basefee.go index da47975..c1e8c45 100644 --- a/internal/account/usage/basefee.go +++ b/internal/account/usage/basefee.go @@ -2,29 +2,38 @@ package usage import "time" -// This file is the SINGLE home of the base-fee math (owner spec 2026-07-05, -// DESIGN.md "Base fee — v1 spec"). Both consumers — the display read -// (GetAppBill, this package) and the charge spine (cycle: the RegisterApp -// creation-proration charge + the boundary advance leg) — compute an app's -// per-period base through these two functions, so the bill page, the invoice, -// and the mirror can never disagree by construction. All money is integer -// micro-dollars; the arithmetic here is pure int64 (no big.Rat needed: the -// operands are bounded — see ProratedBaseMicros). +// This file is the SINGLE home of the base-fee + overage display math (owner +// spec 2026-07-05, DESIGN.md "Base fee — v2: creation grace + per-module overage +// timers"). Both consumers — the display read (GetAppBill / GetAccountBill, this +// package) and the charge spine (cycle: the creation/combined charge, the +// boundary advance leg, the per-module grace sweep) — compute the per-app FLAT +// base and the account overage through these functions, so the bill page, the +// invoice, and the mirror can never disagree by construction. All money is +// integer micro-dollars; the arithmetic here is pure int64 (no big.Rat needed: +// the operands are bounded — see ProratedBaseMicros). +// +// The flat $20/app base is per-app; the $3/module surcharge applies to the +// account's over-count, max(0, live module count − IncludedModules). Under the +// per-module-instance model (migration 033) the charge legs tier per install +// TIMER (each on its own grace), while the DISPLAY reads the live timer count +// through AccountOverageMicros. There is deliberately NO per-app overage helper: +// an app's base is just the flat (plan-resolved) fee. -// AppBaseFeeMicros is an app's FULL per-period base fee: +// AccountOverageMicros is the account's module overage shown for one period: // -// base + ModuleOverageFeeMicros × max(0, moduleCount − IncludedModules) +// ModuleOverageFeeMicros × max(0, liveModuleCount − IncludedModules) // -// baseFeeMicros is the plan-resolved flat fee (resolveBaseFeeMicros; the -// charge spine passes BaseFeeMicros until a plan resolver exists) so the -// plan seam stays in ONE place and this function stays pure tier math. -// A negative moduleCount cannot occur (DB CHECK module_count >= 0); the -// max(0, …) clamp still makes the function total. -func AppBaseFeeMicros(baseFeeMicros int64, moduleCount int) int64 { - if extra := moduleCount - IncludedModules; extra > 0 { - return baseFeeMicros + ModuleOverageFeeMicros*int64(extra) +// liveModuleCount is the account's live installed-module count (the count of live +// install timers, migration 033 — one pool of IncludedModules for the WHOLE +// account, not per app). The first IncludedModules live installs (by FIFO) are +// "included"; the rest are "over", so max(0, live − included) is exactly the live +// over-count. A liveModuleCount ≤ IncludedModules yields 0 (the max(0, …) clamp +// makes the function total; a negative count cannot occur — a live-row count). +func AccountOverageMicros(liveModuleCount int) int64 { + if extra := liveModuleCount - IncludedModules; extra > 0 { + return ModuleOverageFeeMicros * int64(extra) } - return baseFeeMicros + return 0 } // ProratedBaseMicros prorates an app's per-period base fee for the period diff --git a/internal/account/usage/basefee_test.go b/internal/account/usage/basefee_test.go index 69db875..aa5c41f 100644 --- a/internal/account/usage/basefee_test.go +++ b/internal/account/usage/basefee_test.go @@ -13,24 +13,27 @@ func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } -// --- AppBaseFeeMicros: overage tier boundaries ------------------------------ +// --- AccountOverageMicros: account-wide POOLED overage tier boundaries ------- -func TestAppBaseFeeMicros_OverageBoundaries(t *testing.T) { - // Owner spec 2026-07-05: base + $3 × max(0, count − 5). The boundary cases - // pin the tier edges: 5 included modules cost nothing extra, the 6th costs - // exactly one overage, 0 modules never discount below the base. +func TestAccountOverageMicros_PooledBoundaries(t *testing.T) { + // Owner spec 2026-07-05 / migration 032: $3 × max(0, pooledCount − 5), where + // pooledCount is Σ live-app module_count for the WHOLE account. The boundary + // cases pin the tier edges: ≤5 pooled modules cost nothing, the 6th costs + // exactly one overage, and the overage is NOT the flat base (it is the + // account-level surcharge only). for _, tc := range []struct { - name string - count int - want int64 + name string + pooled int + want int64 }{ - {"zero modules → flat base (no negative overage)", 0, usage.BaseFeeMicros}, - {"exactly included (5) → flat base", usage.IncludedModules, usage.BaseFeeMicros}, - {"included+1 (6) → one $3 overage", usage.IncludedModules + 1, usage.BaseFeeMicros + usage.ModuleOverageFeeMicros}, - {"included+10 → ten overages", usage.IncludedModules + 10, usage.BaseFeeMicros + 10*usage.ModuleOverageFeeMicros}, + {"zero pooled → no overage", 0, 0}, + {"under pool (3) → no overage", 3, 0}, + {"exactly included (5) → no overage", usage.IncludedModules, 0}, + {"included+1 (6) → one $3 overage", usage.IncludedModules + 1, usage.ModuleOverageFeeMicros}, + {"included+10 → ten overages", usage.IncludedModules + 10, 10 * usage.ModuleOverageFeeMicros}, } { t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, usage.AppBaseFeeMicros(usage.BaseFeeMicros, tc.count)) + require.Equal(t, tc.want, usage.AccountOverageMicros(tc.pooled)) }) } } @@ -123,11 +126,3 @@ func TestProratedBaseMicros_ClampedAnchorMonth(t *testing.T) { got = usage.ProratedBaseMicros(usage.BaseFeeMicros, day(2026, 3, 1), day(2026, 2, 28), day(2026, 3, 31)) require.EqualValues(t, 19_354_839, got) } - -func TestProratedBaseMicros_ProratesTheOverageToo(t *testing.T) { - // The prorated amount is the FULL app base (base + overage), not the flat - // fee: 7 modules → 26e6; half of a 30-day period (15 days) → 13e6. - appBase := usage.AppBaseFeeMicros(usage.BaseFeeMicros, 7) - got := usage.ProratedBaseMicros(appBase, day(2026, 6, 19), day(2026, 6, 4), day(2026, 7, 4)) - require.EqualValues(t, 13_000_000, got) -} diff --git a/internal/account/usage/bill.go b/internal/account/usage/bill.go index d33d262..296aeb7 100644 --- a/internal/account/usage/bill.go +++ b/internal/account/usage/bill.go @@ -32,9 +32,10 @@ import ( const ( // BaseFeeMicros is 基本費用 — the fixed per-app/period platform base fee on the - // DEFAULT plan. It BUNDLES the PaaS infra credit (surfaced as PaasCreditMicros) - // and INCLUDES up to IncludedModules installed modules; each installed module - // beyond that adds ModuleOverageFeeMicros. Tunable. Default $20. + // DEFAULT plan. It BUNDLES the PaaS infra credit (surfaced as PaasCreditMicros). + // It is FLAT per app: the IncludedModules allowance + the ModuleOverageFeeMicros + // surcharge are ACCOUNT-WIDE POOLED (migration 032 — see AccountOverageMicros), + // NOT folded into this per-app fee. Tunable. Default $20. BaseFeeMicros int64 = 20_000_000 // $20.00 // ProBaseFeeMicros is the base fee on the Pro org plan. TODO(plan): wire a real @@ -43,16 +44,17 @@ const ( // value — tune with the real Pro plan. ProBaseFeeMicros int64 = 50_000_000 // $50.00 (placeholder) - // IncludedModules is how many installed modules the base fee bundles before the - // per-module surcharge kicks in. Owner spec 2026-07-05 (base-fee v1, DESIGN.md - // D1): 5 included. Tunable ("may change"); becomes plan-resolved later. + // IncludedModules is the ACCOUNT-WIDE POOL of installed modules the base fee + // bundles before the per-module surcharge kicks in (migration 032 — ONE pool + // of 5 for the whole account, summed over its live apps, NOT 5 per app). Owner + // spec 2026-07-05. Tunable ("may change"); becomes plan-resolved later. IncludedModules = 5 - // ModuleOverageFeeMicros is the surcharge added to the base fee for EACH - // installed module beyond IncludedModules. Owner spec 2026-07-05 (base-fee v1): - // $3.00/module/period. Tunable; becomes plan-resolved later. App base for a - // period = BaseFee + Overage × max(0, module_count − IncludedModules) — see - // AppBaseFeeMicros, the ONE place that formula lives. + // ModuleOverageFeeMicros is the surcharge for EACH installed module beyond the + // account-wide pooled IncludedModules. Owner spec 2026-07-05: $3.00/module/ + // period. Account overage for a period = Overage × max(0, Σ live-app + // module_count − IncludedModules) — see AccountOverageMicros, the ONE place + // that formula lives. Tunable; becomes plan-resolved later. ModuleOverageFeeMicros int64 = 3_000_000 // $3.00 // PaasCreditPct is PaaS 額度 — the percentage of the 基礎設施 InfraTotal credited @@ -61,6 +63,16 @@ const ( // Only reached once a subscription earns it; subscription-gated OFF in v1 (see // paasCreditMicros), so today the credit is always 0. PaasCreditPct = 30 + + // GraceDays is the creation grace window (owner spec 2026-07-05, D1e + // follow-up). A newly created app is NOT charged its creation-period base + // synchronously; a periodic sweep (cmd/billing-cycle) charges it only once it + // has SURVIVED this many whole days past created_at, so an app soft-deleted + // within the window is NEVER billed. A survivor is charged the SAME + // creation-period proration (identical ProratedBaseMicros math, anchored to + // the TRUE created_at) — grace delays WHEN the charge fires, never WHAT it + // covers. Tunable. + GraceDays = 3 ) // Plan is the account/org billing plan. v1 has NO real plan system — this is the @@ -133,11 +145,12 @@ func pctMicros(base int64, pct int) (int64, error) { // match), so the displayed base IS what the invoice charged even after // later SyncAppModules count changes. Only an un-snapshotted period // (pre-feature history, unactivated account, in-progress period) falls -// back to the live ESTIMATE from the mirror (migration 027): -// AppBaseFeeMicros(resolveBaseFeeMicros(plan), module_count), PRORATED via +// back to the live ESTIMATE from the mirror (migration 027): the FLAT +// plan-resolved base (resolveBaseFeeMicros(plan)), PRORATED via // ProratedBaseMicros when the app's created_at falls inside the period. -// An app ABSENT from the mirror (pre-backfill) falls back to the usage-proxy -// count below — today's behavior, until the api-platform backfill lands, +// Module overage is NOT in the per-app base anymore — it is account-wide +// pooled (migration 032, surfaced on GetAccountBill). An app ABSENT from +// the mirror (pre-backfill) falls back to the flat plan base below, // 5. computes PaaS 額度 credit = PaasCreditPct% of the infra total, but ONLY when // an active SaaS subscription earns it — v1 has no subscription system, so the // credit is subscription-gated OFF and is 0 (the wire field stays for back-compat), @@ -293,10 +306,10 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found // Keep only the non-reserved rows → module usage (displayed lines); the // reserved infra.* / platform.* rows are dropped here (infra is sourced - // per-metric from the catalog below). Count DISTINCT non-reserved modules as - // the installed-module proxy for the base-fee tier. + // per-metric from the catalog below). Module overage is now account-wide + // pooled (migration 032), so the per-app base no longer needs a + // distinct-module proxy count here. moduleUsage := make([]AppMetricUsage, 0, len(lines)) - installedModules := make(map[uuid.UUID]struct{}) var moduleUsageTotal int64 for _, r := range lines { if isReservedMetric(r.Metric) { @@ -317,7 +330,6 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found ChargedMicros: r.ChargedMicros, }) moduleUsageTotal += r.ChargedMicros - installedModules[r.ModuleID] = struct{}{} } // 基礎設施: source infra per-metric from the CATALOG (metric_definitions), NOT @@ -394,14 +406,16 @@ func (s *Service) computeAppBill(ctx context.Context, accountID uuid.UUID, found // period leaves that period's base spent, so it falls through below. baseFee = 0 case mirrored: - // No snapshot → estimate from the mirror's current count, the SAME - // AppBaseFeeMicros + ProratedBaseMicros math the charge legs bill. - baseFee = ProratedBaseMicros( - AppBaseFeeMicros(resolveBaseFeeMicros(plan), mirror.ModuleCount), - mirror.CreatedAt, periodStart, periodEnd, - ) + // No snapshot → estimate the FLAT per-app base from the plan (module + // overage is now account-wide pooled, migration 032 — never folded + // into the per-app base here), the SAME ProratedBaseMicros math the + // charge legs bill, prorated when created_at falls inside the period. + baseFee = ProratedBaseMicros(resolveBaseFeeMicros(plan), mirror.CreatedAt, periodStart, periodEnd) default: - baseFee = AppBaseFeeMicros(resolveBaseFeeMicros(plan), len(installedModules)) + // No mirror row (pre-backfill): the flat per-app base. The pre-032 + // usage-proxy overage is gone — overage is pooled at the account + // level, so a per-app read never estimates it. + baseFee = resolveBaseFeeMicros(plan) } } diff --git a/internal/account/usage/bill_test.go b/internal/account/usage/bill_test.go index 392ac80..7594156 100644 --- a/internal/account/usage/bill_test.go +++ b/internal/account/usage/bill_test.go @@ -59,8 +59,11 @@ func TestGetAppBill_BaseFeeBundlesUpToIncludedModules(t *testing.T) { require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "5 modules is within the bundle → no surcharge") } -func TestGetAppBill_BaseFeeSurchargesModulesBeyondIncluded(t *testing.T) { - // 7 distinct modules → base = BaseFeeMicros + ModuleOverageFeeMicros × (7 − 5). +func TestGetAppBill_PerAppBaseIsFlatEvenWithManyModules(t *testing.T) { + // Migration 032: module overage is ACCOUNT-WIDE POOLED, so the PER-APP base + // is the FLAT $20 regardless of how many modules the app uses — 7 distinct + // modules no longer surcharge THIS app's base (the pooled overage surfaces on + // GetAccountBill instead). store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -70,8 +73,7 @@ func TestGetAppBill_BaseFeeSurchargesModulesBeyondIncluded(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: uuid.New()}) require.NoError(t, err) - want := usage.BaseFeeMicros + usage.ModuleOverageFeeMicros*2 - require.Equal(t, want, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "per-app base is flat; overage is pooled at the account") } func TestGetAppBill_ModuleCountIsDistinctModulesNotLines(t *testing.T) { @@ -518,10 +520,10 @@ func mirrorPeriod(store *fakeStore) uuid.UUID { return pid } -func TestGetAppBill_MirroredAppUsesSyncedModuleCount(t *testing.T) { - // A mirrored app's overage comes from the SYNCED module_count (7 → +2×$3), - // NOT the usage-proxy distinct-module count (only 1 module has usage here - // — the proxy would have said "flat base"). +func TestGetAppBill_MirroredAppShowsFlatBase(t *testing.T) { + // Migration 032: a mirrored app's per-app base is the FLAT $20 regardless of + // its synced module_count (7 here) — module overage is account-wide pooled, + // no longer part of the per-app base. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -535,7 +537,7 @@ func TestGetAppBill_MirroredAppUsesSyncedModuleCount(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app, PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+2*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "per-app base is flat (overage is pooled)") } func TestGetAppBill_MirroredAppProratesCreationPeriod(t *testing.T) { @@ -602,9 +604,10 @@ func TestGetAppBill_AppDeletedDuringPeriodKeepsSpentBase(t *testing.T) { require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros) } -func TestGetAppBill_UnmirroredAppKeepsProxyFallback(t *testing.T) { - // No mirror row (pre-backfill) → today's behavior survives verbatim: flat - // base + the usage-proxy overage (7 distinct modules with usage → +2×$3). +func TestGetAppBill_UnmirroredAppShowsFlatBase(t *testing.T) { + // No mirror row (pre-backfill) → the FLAT per-app base. Migration 032 retired + // the pre-032 usage-proxy overage: overage is account-wide pooled, never + // estimated per app, so even 7 distinct modules with usage show flat $20 here. store := newFakeStore() owner := uuid.New() store.accounts[owner] = uuid.New() @@ -615,7 +618,7 @@ func TestGetAppBill_UnmirroredAppKeepsProxyFallback(t *testing.T) { resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: uuid.New(), PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+2*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros) + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros) } func TestGetAppBill_InternalOnAppMirrorError(t *testing.T) { @@ -645,21 +648,22 @@ func TestGetAppBill_SnapshotFreezesChargedPeriodBaseAfterSync(t *testing.T) { CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), } store.baseSnapshots[baseSnapKey(app, time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC))] = usage.AppBaseSnapshotInfo{ - BaseMicros: usage.AppBaseFeeMicros(usage.BaseFeeMicros, 5), // what the boundary actually invoiced + BaseMicros: usage.BaseFeeMicros, // what the boundary actually invoiced (flat base) } - // The charged period displays the frozen count-5 base, NOT a recompute - // from the mirror's current count 8. + // The charged period displays the frozen snapshot base, NOT a recompute from + // the mirror's current count 8. resp, err := newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app, PeriodID: pid}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "charged period shows what was invoiced (count 5 → flat base)") + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, "charged period shows what was invoiced (flat base)") - // The CURRENT period has no snapshot yet → live ESTIMATE from the synced - // count 8. + // The CURRENT period has no snapshot yet → live ESTIMATE, which is the FLAT + // per-app base (migration 032 — overage is pooled, not per-app), unaffected + // by the synced count 8. resp, err = newService(store).GetAppBill(context.Background(), usage.GetAppBillRequest{OwnerUserID: owner, AppID: app}) require.NoError(t, err) - require.Equal(t, usage.BaseFeeMicros+3*usage.ModuleOverageFeeMicros, resp.BaseFeeMicros, - "un-snapshotted period estimates from the current count 8") + require.Equal(t, usage.BaseFeeMicros, resp.BaseFeeMicros, + "un-snapshotted period estimates the flat per-app base regardless of count") } func TestGetAppBill_ProrationSnapshotFreezesCreationPeriodBase(t *testing.T) { diff --git a/internal/account/usage/invoices.go b/internal/account/usage/invoices.go index f1979c1..94fa49a 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 034): 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/service_test.go b/internal/account/usage/service_test.go index cc1b9de..04b3a48 100644 --- a/internal/account/usage/service_test.go +++ b/internal/account/usage/service_test.go @@ -40,6 +40,13 @@ type fakeStore struct { appMirrors map[uuid.UUID]usage.AppMirrorInfo // app_id → ms_billing.apps roster row (migration 027) baseSnapshots map[string]usage.AppBaseSnapshotInfo // app_id/period_start → charged-base snapshot (migration 028) + // per-module overage display (migration 033): LiveModuleTimerCountForAccount + // counts the account's live install timers — the sole input to + // GetAccountBill's steady-state account-overage estimate. The single-account + // usage fake models it as Σ module_count over its live appMirrors (one live + // timer per installed module), so display tests set counts on appMirrors. + errLiveTimerCount error + // usageAppIDs is what AppIDsWithUsage enumerates (the usage half of // GetAccountBill's roster); the mirror half is DERIVED from appMirrors with // the real overlap rule (see MirroredAppIDs). @@ -183,6 +190,24 @@ func (f *fakeStore) AppBaseSnapshot(_ context.Context, appID uuid.UUID, periodSt return s, ok, nil } +// LiveModuleTimerCountForAccount returns the account's live install-timer count +// — the sole input to GetAccountBill's steady-state account-overage estimate +// (migration 033). The single-account usage fake models one live timer per +// installed module, so it sums module_count over the non-deleted appMirrors +// (numerically identical to counting live timer rows). +func (f *fakeStore) LiveModuleTimerCountForAccount(_ context.Context, _ uuid.UUID) (int, error) { + if f.errLiveTimerCount != nil { + return 0, f.errLiveTimerCount + } + sum := 0 + for _, m := range f.appMirrors { + if !m.Deleted { + sum += m.ModuleCount + } + } + return sum, nil +} + // AccountAnchorDay returns the configured anchor day for an account, defaulting // to 1 (the UTC calendar month) so tests that don't set one keep the pre-anchor // window behavior. diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index 3a737d3..452d3c8 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -208,6 +208,15 @@ type Store interface { // deleted BEFORE the period opened is excluded (base 0, no new usage — // residual ledger rows still enumerate through AppIDsWithUsage). MirroredAppIDs(ctx context.Context, accountID uuid.UUID, periodStart, periodEnd time.Time) ([]uuid.UUID, error) + + // LiveModuleTimerCountForAccount returns the account's currently-live install- + // timer count (removed_at IS NULL) — the DISPLAY input to GetAccountBill's + // account-overage line under the per-module-instance overage model (migration + // 033), shown as the steady-state estimate $3 × max(0, live − IncludedModules) + // (usage.AccountOverageMicros). Reads the timer table (the overage model's + // source of truth) rather than SUM(apps.module_count), so the shown overage + // stays tied to the exact rows the charge legs tier on. + LiveModuleTimerCountForAccount(ctx context.Context, accountID uuid.UUID) (int, error) } // AppBaseSnapshotInfo is the display-read projection of a @@ -401,6 +410,11 @@ type InvoiceMirrorRaw struct { CreatedAt time.Time HostedInvoiceURL string InvoicePDF string + // IsLargeAutoCollect is the server-computed post-hoc disclosure flag + // (migration 034): 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 @@ -605,6 +619,20 @@ func (s *pgxStore) MirroredAppIDs(ctx context.Context, accountID uuid.UUID, peri return parseAppIDs(rows) } +// LiveModuleTimerCountForAccount counts the account's currently-live install +// timers (removed_at IS NULL) — the live input to GetAccountBill's steady-state +// account-overage estimate ($3 × max(0, live − IncludedModules)). Under the +// per-module-instance overage model (migration 033) the display reads the timer +// table (the model's source of truth) instead of SUM(apps.module_count), so the +// shown overage stays tied to the exact rows the charge legs tier on. +func (s *pgxStore) LiveModuleTimerCountForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { + n, err := s.q.CountLiveModuleTimersForAccount(ctx, accountID.String()) + if err != nil { + return 0, err + } + return int(n), nil +} + // parseAppIDs decodes a generated query's text app_id column into uuid.UUIDs, // shared by the two GetAccountBill enumeration reads. func parseAppIDs(rows []string) ([]uuid.UUID, error) { @@ -976,16 +1004,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/internal/account/usage/types.go b/internal/account/usage/types.go index 5b0f1f6..f471e66 100644 --- a/internal/account/usage/types.go +++ b/internal/account/usage/types.go @@ -392,9 +392,10 @@ type GetAppBillResponse struct { PeriodStart time.Time `json:"period_start"` PeriodEnd time.Time `json:"period_end"` - // BaseFeeMicros is 基本費用 — the fixed per-app/period platform fee, PLUS the - // per-module surcharge for each installed module beyond IncludedModules (see - // the bill.go consts). Bundles the PaaS infra credit surfaced below. + // BaseFeeMicros is 基本費用 — the FLAT fixed per-app/period platform fee (see + // the bill.go consts). Module overage is NO LONGER folded in here: it is + // account-wide pooled (migration 032) and surfaced on GetAccountBill's + // AccountOverageMicros. Bundles the PaaS infra credit surfaced below. BaseFeeMicros int64 `json:"base_fee_micros"` // ModuleUsage is 模組使用量 — one line per (module, metric, model, @@ -551,6 +552,14 @@ type GetAccountBillResponse struct { ModuleUsageTotalMicros int64 `json:"module_usage_total_micros"` InfraTotalMicros int64 `json:"infra_total_micros"` + // AccountOverageMicros is the account's module overage for the period + // (migration 033): $3 × max(0, Σ live-app module_count − IncludedModules), + // an ACCOUNT line (NOT per app, NOT folded into any Apps[].base_fee_micros). + // Under the per-module-instance model overage is billed per install on its + // own grace timer (Leg 1); this display value is the steady-state estimate + // from the CURRENT live pool. Included in TotalMicros below. + AccountOverageMicros int64 `json:"account_overage_micros"` + // PaasCreditMicros is the ACCOUNT-level PaaS credit, applied ONCE here // (never per-app): the same ACTIVE-SaaS-subscription gate as GetAppBill's // per-app credit (v1 has no subscription system → always 0), CAPPED at @@ -558,8 +567,8 @@ type GetAccountBillResponse struct { // the same usage-only offset posture as the charge spine's allowance. PaasCreditMicros int64 `json:"paas_credit_micros"` - // TotalMicros is 最終費用 = BaseFeeTotal + ModuleUsageTotal + InfraTotal − - // PaasCredit, ≥ 0 by the credit cap. + // TotalMicros is 最終費用 = BaseFeeTotal + ModuleUsageTotal + InfraTotal + + // AccountOverage − PaasCredit, ≥ 0 by the credit cap. TotalMicros int64 `json:"total_micros"` } diff --git a/internal/shared/stripe/client.go b/internal/shared/stripe/client.go index c8eb45e..6e05b43 100644 --- a/internal/shared/stripe/client.go +++ b/internal/shared/stripe/client.go @@ -2,6 +2,7 @@ package stripe import ( "context" + "fmt" stripego "github.com/stripe/stripe-go/v85" stripeclient "github.com/stripe/stripe-go/v85/client" @@ -113,16 +114,46 @@ func (c *realClient) SetDefaultPaymentMethod(ctx context.Context, stripeCustomer return err } -// CreateInvoiceItem creates a pending invoice item on the Customer for one -// metered metric line. amountCents is whole cents (the caller converts -// micro-dollars → cents round-half-up before this call; Stripe amounts are -// integer minor units). The deterministic Idempotency-Key (ii--) -// makes a re-run safe — Stripe returns the original item instead of creating a -// second one. We project to a plain InvoiceItem (id only) so consumers stay -// off stripe-go. -func (c *realClient) CreateInvoiceItem(ctx context.Context, custID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) { +// CreateDraftInvoice creates an EMPTY draft invoice line items are then +// pinned to (CreateInvoiceItem) and that charges only on FinalizeInvoice. +// PendingInvoiceItemsBehavior=exclude is load-bearing (review 2026-07-06, C2): +// it guarantees this invoice can never sweep up another charge leg's orphaned +// customer-level pending item — with several independent item→invoice +// sequences per account, the legacy include behavior pooled a crashed leg's +// item onto the next leg's unrelated invoice. ref is stamped as the +// ms_charge_ref metadata anchor for crash reconciliation. The deterministic +// Idempotency-Key (inv-) makes a re-run reuse the original draft. +func (c *realClient) CreateDraftInvoice(ctx context.Context, custID, ref, idemKey string) (Invoice, error) { + params := &stripego.InvoiceParams{ + Customer: stripego.String(custID), + CollectionMethod: stripego.String(string(stripego.InvoiceCollectionMethodChargeAutomatically)), + AutoAdvance: stripego.Bool(false), + PendingInvoiceItemsBehavior: stripego.String("exclude"), + } + if ref != "" { + params.AddMetadata("ms_charge_ref", ref) + } + params.Context = ctx + params.SetIdempotencyKey(idemKey) + inv, err := c.sc.Invoices.New(params) + if err != nil { + return Invoice{}, err + } + return projectInvoice(inv), nil +} + +// CreateInvoiceItem creates an invoice item PINNED to the given draft invoice +// (never a floating customer-level pending item — see CreateDraftInvoice). +// amountCents is whole cents (the caller converts micro-dollars → cents +// round-half-up before this call; Stripe amounts are integer minor units). The +// deterministic Idempotency-Key makes a re-run safe — Stripe returns the +// original item (already pinned to the same replayed draft) instead of +// creating a second one. We project to a plain InvoiceItem (id only) so +// consumers stay off stripe-go. +func (c *realClient) CreateInvoiceItem(ctx context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) { params := &stripego.InvoiceItemParams{ Customer: stripego.String(custID), + Invoice: stripego.String(invoiceID), Amount: stripego.Int64(amountCents), Currency: stripego.String(currency), } @@ -138,35 +169,58 @@ func (c *realClient) CreateInvoiceItem(ctx context.Context, custID string, amoun return InvoiceItem{ID: item.ID}, nil } -// CreateInvoice creates a draft invoice with -// collection_method=charge_automatically that sweeps up the Customer's pending -// invoice items. With autoAdvance=true Stripe finalizes the draft and runs the -// off-session PaymentIntent against the default PM automatically — the metered -// auto-charge. PendingInvoiceItemsBehavior=include pulls the pending items -// created just above into this invoice (the default behavior changed across -// Stripe API versions; pinning it keeps the line items deterministic). The -// deterministic Idempotency-Key (inv-) makes a re-run reuse the original -// invoice. Projected to a plain Invoice (id/status/amounts) for the mirror. -func (c *realClient) CreateInvoice(ctx context.Context, custID string, autoAdvance bool, idemKey string) (Invoice, error) { - params := &stripego.InvoiceParams{ - Customer: stripego.String(custID), - CollectionMethod: stripego.String(string(stripego.InvoiceCollectionMethodChargeAutomatically)), - AutoAdvance: stripego.Bool(autoAdvance), - PendingInvoiceItemsBehavior: stripego.String("include"), +// FinalizeInvoice finalizes the draft with auto_advance=true — Stripe runs the +// off-session PaymentIntent against the default PM (the metered auto-charge). +// The ONLY money-moving step of the draft→items→finalize flow. The +// deterministic Idempotency-Key (fin-) makes a re-run replay the original +// finalization instead of double-charging. Projected to a plain Invoice +// (id/status/amounts) for the mirror. +func (c *realClient) FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) { + params := &stripego.InvoiceFinalizeInvoiceParams{ + AutoAdvance: stripego.Bool(true), } params.Context = ctx params.SetIdempotencyKey(idemKey) - inv, err := c.sc.Invoices.New(params) + inv, err := c.sc.Invoices.FinalizeInvoice(invoiceID, params) if err != nil { return Invoice{}, err } + return projectInvoice(inv), nil +} + +// FindInvoiceByRef searches the Customer's invoices for the ms_charge_ref +// metadata anchor — the crash-recovery read for retries past Stripe's ~24h +// idempotency-key window (see the interface comment). Uses the Stripe Search +// API; at most one invoice can carry a given ref (the ref is the deterministic +// charge identity and the draft that carries it is created under an idem key). +func (c *realClient) FindInvoiceByRef(ctx context.Context, custID, ref string) (Invoice, bool, error) { + params := &stripego.InvoiceSearchParams{ + SearchParams: stripego.SearchParams{ + Query: fmt.Sprintf(`customer:"%s" AND metadata["ms_charge_ref"]:"%s"`, custID, ref), + Limit: stripego.Int64(1), + Context: ctx, + }, + } + it := c.sc.Invoices.Search(params) + if it.Next() { + return projectInvoice(it.Invoice()), true, nil + } + if err := it.Err(); err != nil { + return Invoice{}, false, err + } + return Invoice{}, false, nil +} + +// projectInvoice maps a stripe-go invoice to the trust-boundary-edge Invoice +// projection the cycle consumer mirrors. +func projectInvoice(inv *stripego.Invoice) Invoice { return Invoice{ ID: inv.ID, Status: string(inv.Status), AmountDue: inv.AmountDue, AmountPaid: inv.AmountPaid, Currency: string(inv.Currency), - }, nil + } } // NewVerifier returns a Verifier for the configured webhook signing diff --git a/internal/shared/stripe/types.go b/internal/shared/stripe/types.go index c6cef76..9d5d920 100644 --- a/internal/shared/stripe/types.go +++ b/internal/shared/stripe/types.go @@ -51,28 +51,51 @@ type Client interface { // webhook syncs is_default across the account's mirror rows. SetDefaultPaymentMethod(ctx context.Context, stripeCustomerID, stripePaymentMethodID string) error - // CreateInvoiceItem creates a pending invoice item on the Customer — one - // per metered metric line — that the next CreateInvoice draft sweeps up. - // amountCents is the whole-cent customer charge (micro-dollars are - // converted to cents round-half-up by the caller BEFORE reaching Stripe — - // Stripe amounts are integer minor units, never float). desc is the line - // description shown on the invoice. idemKey is a deterministic Stripe - // Idempotency-Key (ii--) so a re-run / partial-failure resume - // never creates a duplicate line. Unlike the card-management methods this - // returns a plain InvoiceItem (not a stripe-go type) so the cycle consumer - // stays free of stripe-go imports — the charge path is the trust-boundary - // edge and the consumer needs only the id. - CreateInvoiceItem(ctx context.Context, custID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) + // CreateDraftInvoice creates an EMPTY draft invoice + // (collection_method=charge_automatically, auto_advance=false, + // pending_invoice_items_behavior=exclude) that line items are then PINNED + // to via CreateInvoiceItem, and that charges only once FinalizeInvoice + // runs. The exclude behavior is load-bearing (review 2026-07-06, C2): the + // legacy include behavior swept up ALL of the Customer's pending items, so + // with several independent item→invoice sequences per account (boundary + + // per-timer Leg 1 + combined creation) an orphaned item from any crashed + // leg leaked onto the NEXT leg's unrelated invoice — money collected on the + // wrong invoice and a permanent idempotency wedge for the crashed leg. ref + // is the deterministic charge identity ("run:" / "timer:" / + // "app-proration:"), stamped as metadata for crash reconciliation. + // idemKey (inv-) makes a re-run reuse the SAME draft. + CreateDraftInvoice(ctx context.Context, custID, ref, idemKey string) (Invoice, error) - // CreateInvoice creates a draft invoice with - // collection_method=charge_automatically that sweeps up the Customer's - // pending invoice items. When autoAdvance is true, Stripe finalizes the - // draft and attempts an off-session PaymentIntent on the Customer's default - // payment method automatically (the off-session metered charge). idemKey is - // the deterministic per-run Stripe Idempotency-Key (inv-). Returns a - // plain Invoice (id + status + amounts) so the cycle consumer can mirror it - // without importing stripe-go. - CreateInvoice(ctx context.Context, custID string, autoAdvance bool, idemKey string) (Invoice, error) + // CreateInvoiceItem creates an invoice item PINNED to the given draft + // invoice — never a floating customer-level pending item. amountCents is + // the whole-cent customer charge (micro-dollars are converted to cents + // round-half-up by the caller BEFORE reaching Stripe — Stripe amounts are + // integer minor units, never float). desc is the line description shown on + // the invoice. idemKey is a deterministic Stripe Idempotency-Key + // (ii- / mod-overage-ii- / app-ii-) so a re-run / + // partial-failure resume never creates a duplicate line (the replayed item + // is already pinned to the same replayed draft). Returns a plain + // InvoiceItem so the cycle consumer stays free of stripe-go imports. + CreateInvoiceItem(ctx context.Context, custID, invoiceID string, amountCents int64, currency, desc, idemKey string) (InvoiceItem, error) + + // FinalizeInvoice finalizes a draft invoice with auto_advance=true: Stripe + // runs the off-session PaymentIntent against the Customer's default payment + // method (the metered auto-charge). This is the ONLY step that moves money — + // a crash before it leaves an inert draft that can never charge and never + // pollute another leg's invoice. idemKey (fin-) makes a re-run replay + // the original finalization. Returns the finalized invoice projection + // (id/status/amounts) for the mirror. + FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) + + // FindInvoiceByRef looks a Customer's invoice up by its ms_charge_ref + // metadata anchor (stamped by CreateDraftInvoice) — the crash-recovery read + // (review 2026-07-06, H5): Stripe prunes idempotency keys after ~24h, so a + // charge leg retrying past that window can no longer rely on key replay to + // find what a crashed attempt created. found=false when no invoice carries + // the ref. Backed by the Stripe Search API; its indexing lags writes by up + // to ~1 minute, which the retry cadences (daily sweeps) sit far above — + // short-window retries are still covered by idem-key replay. + FindInvoiceByRef(ctx context.Context, custID, ref string) (Invoice, bool, error) } // InvoiceItem is the trust-boundary-edge projection of a Stripe invoice item diff --git a/migrations/billing/029_apps_proration_sweep_idx.down.sql b/migrations/billing/029_apps_proration_sweep_idx.down.sql new file mode 100644 index 0000000..4c16c2e --- /dev/null +++ b/migrations/billing/029_apps_proration_sweep_idx.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; diff --git a/migrations/billing/029_apps_proration_sweep_idx.up.sql b/migrations/billing/029_apps_proration_sweep_idx.up.sql new file mode 100644 index 0000000..aa934b2 --- /dev/null +++ b/migrations/billing/029_apps_proration_sweep_idx.up.sql @@ -0,0 +1,24 @@ +-- Migration 029 — index for the creation-proration grace sweep. +-- +-- Creation grace (owner spec 2026-07-05, D1e follow-up): a newly created app is +-- no longer charged its creation-period base synchronously in RegisterApp. A +-- periodic sweep (cmd/billing-cycle) instead charges apps that have SURVIVED the +-- grace window, so an app soft-deleted within grace is never billed. The sweep's +-- work list is: +-- +-- SELECT app_id FROM ms_billing.apps +-- WHERE created_at <= now() - '3 days' +-- AND proration_invoice_id IS NULL -- one-shot guard: not yet charged +-- AND deleted_at IS NULL -- excludes apps deleted within grace +-- +-- A PARTIAL index on created_at, restricted to the exact NULL predicates the +-- sweep filters on, keeps the index tiny (it holds ONLY the still-pending apps — +-- every charged or deleted app drops out) and lets the sweep range-scan by +-- created_at without touching the full roster. It is preferred over a plain +-- composite (deleted_at, proration_invoice_id, created_at) because almost every +-- row is eventually charged (proration_invoice_id set) and thus excluded from +-- the partial index, so it stays a fraction of the table's size. + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL AND proration_invoice_id IS NULL; diff --git a/migrations/billing/030_apps_created_module_count.down.sql b/migrations/billing/030_apps_created_module_count.down.sql new file mode 100644 index 0000000..316524c --- /dev/null +++ b/migrations/billing/030_apps_created_module_count.down.sql @@ -0,0 +1,6 @@ +-- Down for 030 — drop the frozen creation-time module-count column. Reverting +-- returns ChargeCreationProration to pricing off the live module_count (the +-- pre-030, retroactive-tier-drift posture). + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS created_module_count; diff --git a/migrations/billing/030_apps_created_module_count.up.sql b/migrations/billing/030_apps_created_module_count.up.sql new file mode 100644 index 0000000..7ae2ddd --- /dev/null +++ b/migrations/billing/030_apps_created_module_count.up.sql @@ -0,0 +1,40 @@ +-- Migration 030 — freeze the module count the creation-proration charge prices +-- (review finding, creation-grace PR #46). +-- +-- ms_billing.apps.module_count is a LIVE snapshot: SyncAppModules overwrites it +-- on every install/uninstall. Under creation grace (migration 029) the +-- creation-proration charge no longer fires synchronously at RegisterApp — it +-- is delayed until the app survives GraceDays and the sweep gets to it, which +-- can be days after creation. A customer is free to install/uninstall modules +-- via SyncAppModules during that window, so by the time the sweep reads +-- module_count it may no longer be the count that actually applied on the +-- historical creation-period days being priced — the charge would retroactively +-- apply a tier that never existed on those days. +-- +-- created_module_count freezes the count AT REGISTRATION (RegisterApp's INSERT +-- — the same instant that stamps created_at) and is NEVER touched again: it is +-- absent from every SyncAppModules write path. The creation-proration charge +-- (ChargeCreationProration) prices its historical window off THIS column; +-- module_count remains exactly what it was — the LIVE count the boundary +-- advance leg (and the display read for all FUTURE periods) uses. +-- +-- Backfill: for rows that predate this migration, the live module_count is the +-- best available approximation of what applied at creation (no per-day history +-- exists to reconstruct it precisely) — acceptable because every pre-migration +-- row not yet proration-charged, if any, is still within its grace/pending +-- window and its module_count has had comparatively little time to drift. + +ALTER TABLE ms_billing.apps + ADD COLUMN created_module_count INT NULL CHECK (created_module_count >= 0); + +UPDATE ms_billing.apps + SET created_module_count = module_count + WHERE created_module_count IS NULL; + +ALTER TABLE ms_billing.apps + ALTER COLUMN created_module_count SET NOT NULL; + +COMMENT ON COLUMN ms_billing.apps.created_module_count IS + 'Module count frozen at RegisterApp time (immutable — SyncAppModules never ' + 'writes this column). ChargeCreationProration prices the creation-period ' + 'window off THIS value, never the live module_count.'; diff --git a/migrations/billing/031_apps_proration_skipped.down.sql b/migrations/billing/031_apps_proration_skipped.down.sql new file mode 100644 index 0000000..3b59e3d --- /dev/null +++ b/migrations/billing/031_apps_proration_skipped.down.sql @@ -0,0 +1,12 @@ +-- Down for 031 — drop the permanent-skip marker and restore the migration-029 +-- partial index predicate (deleted_at IS NULL AND proration_invoice_id IS +-- NULL only). + +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL AND proration_invoice_id IS NULL; + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS proration_skipped_at; diff --git a/migrations/billing/031_apps_proration_skipped.up.sql b/migrations/billing/031_apps_proration_skipped.up.sql new file mode 100644 index 0000000..d2165e2 --- /dev/null +++ b/migrations/billing/031_apps_proration_skipped.up.sql @@ -0,0 +1,46 @@ +-- Migration 031 — permanent-skip marker for the creation-proration charge +-- (review finding, creation-grace PR #46, D1d). +-- +-- D1d: v1 never retroactively catches up an app's creation-period base once +-- the window it covers has already closed with the account never having been +-- chargeable during it (never activated). Pre-grace, RegisterApp charged +-- synchronously at creation, so this could only happen if activation itself +-- landed after the creation period ended — and the code skipped the charge +-- (0 cents, guard left unarmed) in exactly that case. +-- +-- Creation grace (migration 029) removed that gate outright when it moved the +-- charge into the async sweep, reasoning (wrongly, in general) that the +-- creation period is billed by no other leg so charging it whenever the guard +-- is unarmed "can never double-bill". That reasoning misses D1d: an app whose +-- account sat unactivated across the app's ENTIRE creation period and only +-- activates months later would, on the very next sweep, be retroactively +-- billed for a period it was never eligible to be charged for. +-- +-- proration_skipped_at records that this decision was made and is PERMANENT: +-- once an app's creation-proration charge is determined to be a would-be +-- retroactive catch-up (the account only activated at/after the app's +-- anchored creation period had already closed), the app is marked here and +-- dropped from the sweep's pending work list for good — never charged, and +-- never re-evaluated on every future sweep (which would otherwise happen +-- forever, since proration_invoice_id would stay NULL). +-- +-- The apps_pending_proration_idx partial index (migration 029) is redefined to +-- add the same predicate so the sweep's work-list query keeps hitting the +-- index. + +ALTER TABLE ms_billing.apps + ADD COLUMN proration_skipped_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.apps.proration_skipped_at IS + 'Set once (never unset) when ChargeCreationProration determines the ' + 'account only activated at/after this app''s anchored creation period had ' + 'already closed — a would-be retroactive catch-up charge (D1d). The app ' + 'is permanently excluded from the proration sweep from then on.'; + +DROP INDEX IF EXISTS ms_billing.apps_pending_proration_idx; + +CREATE INDEX IF NOT EXISTS apps_pending_proration_idx + ON ms_billing.apps (created_at) + WHERE deleted_at IS NULL + AND proration_invoice_id IS NULL + AND proration_skipped_at IS NULL; diff --git a/migrations/billing/032_account_wide_overage.down.sql b/migrations/billing/032_account_wide_overage.down.sql new file mode 100644 index 0000000..08a0a34 --- /dev/null +++ b/migrations/billing/032_account_wide_overage.down.sql @@ -0,0 +1,10 @@ +-- Down for 032 — drop the account-wide pooled overage timer + snapshot ledger. +-- Rolling back reverts overage to whatever the code expects: the display read +-- treats a missing snapshot as "no pooled overage charged" and a missing +-- overage_since column as never-over. Money already charged stays in +-- invoices/billing_runs (unaffected). Drop the table first, then the column. + +DROP TABLE IF EXISTS ms_billing.account_overage_snapshots; + +ALTER TABLE ms_billing.accounts + DROP COLUMN IF EXISTS overage_since; diff --git a/migrations/billing/032_account_wide_overage.up.sql b/migrations/billing/032_account_wide_overage.up.sql new file mode 100644 index 0000000..f8fa943 --- /dev/null +++ b/migrations/billing/032_account_wide_overage.up.sql @@ -0,0 +1,100 @@ +-- Migration 032 — account-wide POOLED module overage (owner spec 2026-07-05, +-- confirmed reversal of the per-app overage tier). +-- +-- Base-fee v1 shipped module overage PER-APP: each app's base was +-- BaseFee + $3 × max(0, module_count − 5) +-- with 5 included modules PER APP. This migration moves the overage to an +-- ACCOUNT-WIDE POOL: one allowance of 5 included modules for the ENTIRE +-- account, charged $3/month for each module beyond the pooled 5 across ALL of +-- the account's live (non-deleted) apps. The FLAT per-app base fee ($20/app) +-- is UNCHANGED and stays per-app — only the $3/module overage math moves from +-- per-app to pooled. +-- +-- Two schema additions carry the pool + its grace timer: +-- +-- 1. accounts.overage_since — the UTC instant the account's pooled +-- SUM(module_count) over live apps FIRST crossed the included 5. NULL +-- means the account is not currently over the pool. It arms ONE grace +-- timer per ACCOUNT (not per app, not per module): when the pool first +-- exceeds 5 the timer starts; if the pool drops back to ≤5 before it +-- expires the timer is CLEARED (overage_since → NULL, no charge); if the +-- pool is still >5 after 3 days the mid-period sweep charges the pooled +-- overage prorated from grace-end to the period end. The timer is +-- recomputed by RegisterApp / SyncAppModules after every module_count +-- write (SUM(module_count) WHERE account_id = ? AND deleted_at IS NULL). +-- +-- 2. account_overage_snapshots — the ACCOUNT-scoped analogue of +-- app_base_snapshots (migration 028): one row per (account, period) that +-- FREEZES the pooled overage a charge leg actually billed for that +-- period, so the mid-period grace charge and the boundary advance leg can +-- never double-charge the same period (both consult / write this ledger, +-- keyed (account_id, period_start), the same double-charge guard +-- app_base_snapshots is for the per-app base). source records which leg +-- billed it: 'grace' (the mid-period sweep, prorated from grace-end) or +-- 'advance' (the boundary, full pooled overage for a period no sweep +-- caught) — mirroring app_base_snapshots' 'proration' vs 'advance'. +-- +-- No refunds (D1e philosophy): removing a module that drops the pool back +-- under 5 clears overage_since (stops FUTURE accrual) but never refunds +-- overage already charged this period. +-- +-- Born clean at slot 032. Companion docs update pending in +-- mirrorstack-docs/db/ms_billing/ (tables.md#account_overage_snapshots + +-- the accounts.overage_since column). + +ALTER TABLE ms_billing.accounts + ADD COLUMN overage_since TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.accounts.overage_since IS + 'UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 ' + '(account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. ' + 'Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account.'; + +CREATE TABLE IF NOT EXISTS ms_billing.account_overage_snapshots ( + -- The billing account whose pooled overage this row froze. Cascade: + -- dropping the account drops its overage charge history with it. + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + + -- The FULL anchored billing-period window this overage charge covers. + -- period_start is the display + double-charge lookup key (exact match from + -- both the mid-period sweep and the boundary leg); a grace row keys on the + -- full window's start even though its charged_micros covers only + -- [grace-end, period_end). + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + + -- The pooled over-count the charge tiered on: SUM(module_count) over the + -- account's live apps MINUS the included 5, snapshotted at charge time. + over_count INT NOT NULL CHECK (over_count >= 0), + + -- The pooled overage actually billed for this account-period, integer + -- micro-dollars (NEVER float). + charged_micros BIGINT NOT NULL CHECK (charged_micros >= 0), + + -- Which leg billed it: the mid-period grace sweep ('grace', prorated from + -- grace-end) or the boundary advance leg ('advance', full pooled overage). + source TEXT NOT NULL CHECK (source IN ('grace', 'advance')), + + -- CRASH-SAFE claim marker (PR #47 review fix — the cross-leg double-charge + -- finding). 'pending' is written BEFORE the leg calls Stripe (the row is the + -- durable "I am about to charge this period's overage" claim); 'charged' is + -- written only AFTER Stripe actually created the invoice item/invoice. The + -- OTHER leg (grace vs boundary) treats ANY row for the period — pending or + -- charged — as claimed and must never independently charge it, closing the + -- crash window where the ORIGINAL code only wrote this row AFTER Stripe + -- succeeded (a crash between the two let the other leg see "no row" and + -- double-charge under a disjoint Idempotency-Key namespace). + status TEXT NOT NULL CHECK (status IN ('pending', 'charged')), + + -- The Stripe invoice item id of the overage charge (empty/NULL while + -- status='pending', or for a 0-cent rounded charge that recorded nothing) + -- — audit trail only. + invoice_item_id TEXT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- One pooled-overage charge per account-period — the "charged exactly + -- once" invariant, enforced at the ledger (the same guard shape as + -- app_base_snapshots' PRIMARY KEY (app_id, period_start)). + PRIMARY KEY (account_id, period_start) +); diff --git a/migrations/billing/033_app_module_overage_timers.down.sql b/migrations/billing/033_app_module_overage_timers.down.sql new file mode 100644 index 0000000..ce8020e --- /dev/null +++ b/migrations/billing/033_app_module_overage_timers.down.sql @@ -0,0 +1,27 @@ +-- Down for 033 — drop the per-module-instance overage timers and RESTORE +-- migration 032's account-wide pooled overage schema (overage_since column + +-- account_overage_snapshots ledger), so an up/down/up round-trip against 032 is +-- clean. The restored definitions are copied verbatim from 032's up. + +DROP TABLE IF EXISTS ms_billing.app_module_overage_timers; + +ALTER TABLE ms_billing.accounts + ADD COLUMN IF NOT EXISTS overage_since TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.accounts.overage_since IS + 'UTC instant the account-wide pooled SUM(module_count) over live apps first crossed the included 5 ' + '(account-wide overage grace anchor, owner spec 2026-07-05). NULL = not currently over the pool. ' + 'Recomputed by RegisterApp / SyncAppModules; arms one 3-day grace timer per account.'; + +CREATE TABLE IF NOT EXISTS ms_billing.account_overage_snapshots ( + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + over_count INT NOT NULL CHECK (over_count >= 0), + charged_micros BIGINT NOT NULL CHECK (charged_micros >= 0), + source TEXT NOT NULL CHECK (source IN ('grace', 'advance')), + status TEXT NOT NULL CHECK (status IN ('pending', 'charged')), + invoice_item_id TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, period_start) +); diff --git a/migrations/billing/033_app_module_overage_timers.up.sql b/migrations/billing/033_app_module_overage_timers.up.sql new file mode 100644 index 0000000..33b6270 --- /dev/null +++ b/migrations/billing/033_app_module_overage_timers.up.sql @@ -0,0 +1,109 @@ +-- Migration 033 — per-module-instance overage timers (owner session amendment +-- 2026-07-05, DESIGN.md "Base fee — v2: creation grace + per-module overage +-- timers"). SUPERSEDES the account-wide single-timer pooled overage model +-- (migration 032): overage is no longer ONE grace timer per account tiering on +-- SUM(module_count); it is now ONE independently-anchored grace timer per module +-- INSTALL EVENT, each priced from its OWN install date. +-- +-- Migration 032's account-wide schema never shipped to main, so this fully +-- REPLACES it (not deprecate-in-place): the up drops accounts.overage_since and +-- ms_billing.account_overage_snapshots outright, and the down recreates them so +-- up/down/up round-trips cleanly against 032. +-- +-- The model (DESIGN.md v2): +-- * RegisterApp(count=K) inserts K rows anchored installed_at = created_at; +-- SyncAppModules N→M inserts M−N new rows (installed_at = now) when growing, +-- LIFO soft-removes N−M rows (newest installs first) when shrinking; app +-- deletion soft-removes all the app's still-live rows. +-- * "included vs over" is derived LIVE at every grace-check, never cached: across +-- the account's currently-live rows ordered (installed_at ASC, id ASC), the +-- first IncludedModules (5) are "included", the rest are "over". Monotonicity: +-- a new install always gets the latest installed_at, so an existing row's rank +-- can only improve (over → included, never included → over) — so once a +-- grace-check finds a row "included" that verdict is PERMANENT (grace_resolved). +-- * Leg 1 (the per-module grace charge): once removed_at IS NULL AND +-- grace_expires_at <= now AND grace_resolved = false, the sweep charges the +-- "over" rows $3 prorated from installed_at's UTC day to the current period end +-- (install-anchored), via a per-timer Stripe invoice, then stamps +-- grace_charged_at / grace_resolved and the REAL Stripe ids. +-- +-- Money never lives on this table — the overage rate is a code constant +-- (usage.ModuleOverageFeeMicros); the table carries only the per-install timer +-- lifecycle. Companion docs update pending in mirrorstack-docs/db/ms_billing/. + +-- Retire the superseded account-wide pooled overage schema (migration 032). +-- Drop the snapshot ledger first, then the grace-anchor column. +DROP TABLE IF EXISTS ms_billing.account_overage_snapshots; + +ALTER TABLE ms_billing.accounts + DROP COLUMN IF EXISTS overage_since; + +CREATE TABLE IF NOT EXISTS ms_billing.app_module_overage_timers ( + -- Surrogate PK: one row per module INSTALL EVENT (not per module identity — + -- the RPC layer carries only an integer module_count, so instances are + -- synthesized). The id is the stable charge identity: the per-timer Stripe + -- Idempotency-Keys (mod-overage-ii- / mod-overage-inv-) derive from it. + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- The billing account the install belongs to. Cascade: dropping the account + -- drops its timers. Denormalized from the app (rather than joined every + -- FIFO-check) so the live-FIFO ordering query is a single-table scan. + account_id UUID NOT NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + + -- The app the module was installed into. Cascade off the apps mirror row. + app_id UUID NOT NULL REFERENCES ms_billing.apps(app_id) ON DELETE CASCADE, + + -- The install instant — BOTH the FIFO ordering key (earliest installs are the + -- "included" 5) AND the proration anchor (Leg 1 prorates $3 from this UTC day + -- to the period end). For RegisterApp's co-created modules this is created_at; + -- for a later SyncAppModules install it is that sync's now(). + installed_at TIMESTAMPTZ NOT NULL, + + -- installed_at + the 3-day grace window. Stored (not derived on read) so the + -- sweep's work-list is a plain indexed range scan on this column. + grace_expires_at TIMESTAMPTZ NOT NULL, + + -- NULL while the install is LIVE; set to the removal instant on a + -- SyncAppModules uninstall (LIFO) or an app deletion. A removed row leaves the + -- live-FIFO ordering and the sweep work-list. No refund (D1e): removing a + -- module already charged this period never claws back money. + removed_at TIMESTAMPTZ NULL, + + -- The instant Leg 1 actually charged this install's overage. NULL until a + -- grace-check decides "over" and the Stripe charge succeeds (an "included" + -- resolution leaves it NULL — nothing was charged). + grace_charged_at TIMESTAMPTZ NULL, + + -- TRUE once a grace-check has run and reached a TERMINAL verdict for this + -- install: either "included" (permanent, monotonicity — never charge, never + -- re-check) or "over and charged". Drops the row from the sweep work-list. + grace_resolved BOOLEAN NOT NULL DEFAULT false, + + -- The genuine Stripe invoice / invoice-item ids of the Leg 1 overage charge + -- (NULL until charged, or for an "included" resolution). The item id is the + -- REAL Stripe object id, never the idempotency-key string. + grace_invoice_id TEXT NULL, + grace_invoice_item_id TEXT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Live-FIFO ordering: the "which installs are included vs over" query scans an +-- account's live rows ordered (installed_at, id). Partial on removed_at IS NULL so +-- the index holds only live installs. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_live_fifo_idx + ON ms_billing.app_module_overage_timers (account_id, installed_at, id) + WHERE removed_at IS NULL; + +-- Sweep work-list: rows whose grace has elapsed and are not yet resolved. Partial +-- on the exact predicates the sweep filters (removed_at IS NULL AND grace_resolved +-- = false) so it stays tiny — every resolved or removed row drops out. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_sweep_idx + ON ms_billing.app_module_overage_timers (grace_expires_at) + WHERE removed_at IS NULL AND grace_resolved = false; + +-- LIFO removal: SyncAppModules shrink removes the app's NEWEST live installs +-- first, ordered (installed_at DESC, id DESC). Partial on the app's live rows. +CREATE INDEX IF NOT EXISTS app_module_overage_timers_app_live_idx + ON ms_billing.app_module_overage_timers (app_id, installed_at DESC, id DESC) + WHERE removed_at IS NULL; diff --git a/migrations/billing/034_auto_collect_disclosure.down.sql b/migrations/billing/034_auto_collect_disclosure.down.sql new file mode 100644 index 0000000..8e8e9ec --- /dev/null +++ b/migrations/billing/034_auto_collect_disclosure.down.sql @@ -0,0 +1,9 @@ +-- Down migration 034 — 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/034_auto_collect_disclosure.up.sql b/migrations/billing/034_auto_collect_disclosure.up.sql new file mode 100644 index 0000000..30b75f8 --- /dev/null +++ b/migrations/billing/034_auto_collect_disclosure.up.sql @@ -0,0 +1,50 @@ +-- Migration 034 — 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 034 (renumbered from 031 when merged into +-- feat/unified-base-fee-overage, whose creation-grace stack already owns +-- 031/032). 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.'; diff --git a/migrations/billing/035_billing_run_frozen_charge.down.sql b/migrations/billing/035_billing_run_frozen_charge.down.sql new file mode 100644 index 0000000..f54d046 --- /dev/null +++ b/migrations/billing/035_billing_run_frozen_charge.down.sql @@ -0,0 +1,8 @@ +-- Down migration 035 — drop the boundary-charge freeze columns. Both are pure +-- additive nullable columns (frozen per billing_run before its first Stripe +-- charge), so dropping them fully reverts the migration; up/down/up round-trips +-- cleanly. + +ALTER TABLE ms_billing.billing_runs + DROP COLUMN IF EXISTS frozen_charge_cents, + DROP COLUMN IF EXISTS frozen_charge_with_base; diff --git a/migrations/billing/035_billing_run_frozen_charge.up.sql b/migrations/billing/035_billing_run_frozen_charge.up.sql new file mode 100644 index 0000000..4df90d7 --- /dev/null +++ b/migrations/billing/035_billing_run_frozen_charge.up.sql @@ -0,0 +1,32 @@ +-- Migration 035 — freeze the boundary charge amount per billing_run (crash-safe +-- idempotency for the base-fee/overage boundary leg, DESIGN.md "Base fee — v2", +-- scenario 6 / Leg 2). +-- +-- The boundary charge (cycle/charge.go RunBillingCycle) computes its total from +-- LIVE state — Σ live apps' base + a live count of ongoing over-modules — and +-- charges it under two DETERMINISTIC Stripe Idempotency-Keys derived from the +-- billing_run id (ii- / inv-). InsertBillingRun RECLAIMS a non-terminal +-- ('pending'/'failed'/'skipped_*') run under the SAME id, so a re-fire keeps the +-- SAME idem keys. But if the live state shifts between a crash (Stripe already +-- charged, MarkBillingRun not yet committed) and the retry — a module uninstalled +-- flipping an over-module to included, an app deleted — the retry RECOMPUTES a +-- DIFFERENT amount and re-sends the SAME idem key with different parameters, which +-- Stripe REJECTS (idempotency-key reuse with a mismatched body), permanently +-- stalling the run ('failed' every cycle) even though the account was already +-- charged. +-- +-- This is the exact bug class the superseded account-wide model fixed once (commit +-- ee5043c) via account_overage_snapshots' pending/charged freeze — which migration +-- 033 DROPPED with no replacement for the boundary leg. This column is that +-- replacement, scoped to the boundary run: the amount (and the base/overage +-- description determinant) is FROZEN here BEFORE the first Stripe call, and a +-- reclaimed run REUSES the frozen values instead of the freshly-recomputed ones, +-- so every attempt sends Stripe a byte-identical request under the stable idem key. +-- +-- Both columns are NULL until a run reaches its first boundary Stripe charge; a +-- zero-charge / skipped run never freezes. InsertBillingRun's ON CONFLICT DO UPDATE +-- deliberately does NOT touch these columns, so the freeze SURVIVES a reclaim. + +ALTER TABLE ms_billing.billing_runs + ADD COLUMN IF NOT EXISTS frozen_charge_cents BIGINT NULL, + ADD COLUMN IF NOT EXISTS frozen_charge_with_base BOOLEAN NULL; diff --git a/migrations/billing/036_charge_attempt_markers.down.sql b/migrations/billing/036_charge_attempt_markers.down.sql new file mode 100644 index 0000000..d331e19 --- /dev/null +++ b/migrations/billing/036_charge_attempt_markers.down.sql @@ -0,0 +1,7 @@ +-- 036 down: drop the charge-attempt recovery markers. + +ALTER TABLE ms_billing.app_module_overage_timers + DROP COLUMN IF EXISTS charge_attempted_at; + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS proration_attempted_at; diff --git a/migrations/billing/036_charge_attempt_markers.up.sql b/migrations/billing/036_charge_attempt_markers.up.sql new file mode 100644 index 0000000..8f4437e --- /dev/null +++ b/migrations/billing/036_charge_attempt_markers.up.sql @@ -0,0 +1,33 @@ +-- 036: charge-attempt markers (review 2026-07-06, H5/H9). +-- +-- The grace legs' crash-recovery story used to rest ENTIRELY on deterministic +-- Stripe idempotency keys: a crash between the Stripe charge and the DB mark +-- was healed by the next sweep replaying the same keys. But Stripe prunes +-- idempotency keys once they are at least 24 hours old, and the retry driver +-- is a DAILY cron — the first retry lands exactly on that boundary (a coin +-- flip) and any later retry is guaranteed pruned, at which point the "replay" +-- mints brand-new Stripe objects and double-charges. +-- +-- These markers are the durable "a prior attempt reached the Stripe section" +-- bit (the grace-leg analogue of migration 035's billing_runs freeze). A +-- retry that sees the marker set goes RECOVERY-FIRST: it looks the invoice up +-- on Stripe by the ms_charge_ref metadata anchor (stamped on every draft +-- since the C2 pinning fix) and reconciles what it finds — finalized → mirror +-- + mark; draft → finish it; nothing → charge fresh (nothing ever reached +-- Stripe). Recovery runs BEFORE the live FIFO verdict, so a timer whose rank +-- improved over→included between the crash and the retry can never resolve +-- "included" while charged money sits unmirrored (H9). +-- +-- Stamped first-write-wins BEFORE the first Stripe call; never cleared. + +ALTER TABLE ms_billing.app_module_overage_timers + ADD COLUMN charge_attempted_at timestamptz; + +COMMENT ON COLUMN ms_billing.app_module_overage_timers.charge_attempted_at IS + 'First instant a Leg-1 (or combined-invoice) charge attempt for this timer reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set reconciles against Stripe (ms_charge_ref) before recomputing any live verdict.'; + +ALTER TABLE ms_billing.apps + ADD COLUMN proration_attempted_at timestamptz; + +COMMENT ON COLUMN ms_billing.apps.proration_attempted_at IS + 'First instant a creation-proration charge attempt for this app reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set and an unarmed guard reconciles against Stripe (ms_charge_ref app-proration:) before minting new Stripe objects.';