Skip to content

feat: disclose large auto-collected charges (migration 031)#45

Closed
I-am-nothing wants to merge 3 commits into
mainfrom
feat/auto-collect-disclosure
Closed

feat: disclose large auto-collected charges (migration 031)#45
I-am-nothing wants to merge 3 commits into
mainfrom
feat/auto-collect-disclosure

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

What

A post-hoc transparency disclosure for off-session charges that already succeeded, once they cross a per-account size threshold — so customers aren't surprised by a large automatic debit.

This is complementary to, and deliberately distinct from, the spend-ceiling gate (internal/account/collection ExceedsSpendCeiling): the ceiling skips a charge that would breach a bill-shock cap before it fires. This feature 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.

Migration number

Uses 031 (031_auto_collect_disclosure.{up,down}.sql). Latest existing was 028; 029/030 were reserved by sibling worktrees in flight when this was written. If those land elsewhere first, this is still the next free slot after them.

Changes

  • Schema (031):
    • accounts.auto_collect_threshold_micros BIGINT NULL — per-account threshold; NULL = platform default ($100 = 100_000_000 micros).
    • invoices.is_large_auto_collect BOOLEAN NOT NULL DEFAULT false — server-computed verdict, frozen per invoice.
  • collection package: DefaultAutoCollectThresholdMicros ($100) + ResolveAutoCollectThreshold + IsLargeAutoCollect.
    • Exactly-at-threshold decision: strict greater-than (>), so a charge equal to the threshold is not flagged — "large" means above. Consistent with ExceedsSpendCeiling's precise-dollar-cap reading and documented in code + tests.
  • Server-computed at charge time (threshold resolved when the charge fires, not later): both off-session charge legs — RunBillingCycle (arrears + advance base) and RegisterApp proration — set the flag on the InvoiceMirror.
  • Wired through InvoiceMirror / UpsertInvoice and exposed on the wire as InvoiceRow.is_large_auto_collect (additive only).
  • sqlc regenerated (v1.30.0).

Tests

collection unit tests (below / above / exactly-at / custom override respected / NULL→default) + cycle wiring tests (large charge flags the mirror, small does not, per-account override governs over the default). make test, go vet ./..., go build ./... all clean.

Follow-ups (out of scope here)

  • api-client-shared: its TypeScript Invoice type (billing/types.ts) needs a matching is_large_auto_collect: boolean field added so web-account can consume the real server flag. Until then web-account ships against an interim client-side threshold check (separate web-account PR).
  • mirrorstack-docs: companion db/ms_billing/{tables,migrations}.md update for the two new columns (per this repo's schema/doc cross-repo convention).

🤖 Generated with Claude Code

I-am-nothing and others added 3 commits July 5, 2026 05:57
Add a post-hoc TRANSPARENCY flag for off-session charges that ALREADY
succeeded, once they cross a per-account size threshold — so customers
aren't surprised by a large automatic debit. This changes NO charging
behaviour; it is complementary to (and distinct from) the spend-ceiling
gate, which SKIPS a charge before it fires.

- migration 031: accounts.auto_collect_threshold_micros (BIGINT NULL,
  NULL = $100 default) + invoices.is_large_auto_collect (BOOLEAN NOT
  NULL DEFAULT false).
- collection.DefaultAutoCollectThresholdMicros ($100) +
  ResolveAutoCollectThreshold + IsLargeAutoCollect (strict-> : exactly-at
  threshold is NOT flagged, matching ExceedsSpendCeiling's dollar-cap
  reading).
- Server-computed at charge time (threshold resolved when the charge
  fires, not later) in BOTH off-session charge legs: RunBillingCycle
  (arrears + advance base) and RegisterApp proration.
- Wired through InvoiceMirror/UpsertInvoice and exposed on the wire as
  InvoiceRow.is_large_auto_collect (additive) for the web billing page.
- Tests: collection flag logic (below/above/at/override/NULL) + cycle
  wiring (large flags, small doesn't, per-account override respected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stripe only ever charges whole cents (round-half-up, cycle.centsFromMicros).
IsLargeAutoCollect compared the raw pre-rounding micros amount against the
threshold, so a charge strictly between the threshold and threshold+5,000
micros (half a cent) rounds DOWN to exactly the threshold's dollar amount
when actually charged, yet was still flagged "large" — contradicting the
"exactly at threshold is not large" contract. Round both the charged amount
and the threshold to cents (the same conversion Stripe applies) before
comparing, so the flag can never disagree with the money that actually moved.

Corrects one existing test (TestIsLargeAutoCollect_ZeroOverrideFlagsAny...)
whose old assertion baked in the same raw-micros-vs-charged-cents mismatch
(a 1-micro "charge" rounds to $0.00 actually charged, so it must not be
flagged over a $0 threshold either) — documented inline as a judgment call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…legs

RunBillingCycle resolved the account's auto_collect_threshold_micros near
the top of the function — before the risk gate, the PM check, and both
Stripe HTTP calls — while RegisterApp's proration leg already resolved it
AFTER its own Stripe charge succeeded. A concurrent threshold edit landing
mid-charge was therefore honored inconsistently between the two call sites,
even though both are documented as resolving "at charge time".

RunBillingCycle now re-resolves the threshold immediately after its Stripe
charge succeeds (the same point RegisterApp already used), so a race lands
identically on both legs.

Also adds an end-to-end regression for the earlier IsLargeAutoCollect
rounding fix, proving the concrete cents Stripe is charged (not just
"no error").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
I-am-nothing added a commit that referenced this pull request Jul 5, 2026
…number migration 031→034

PR #45 (feat/auto-collect-disclosure) adds collection.IsLargeAutoCollect /
AutoCollectThresholdMicros + the invoices.is_large_auto_collect mirror column
(migration 034, renumbered from 031 to clear this branch's creation-grace
031_apps_proration_skipped + 032_account_wide_overage).

Conflict resolution, preserving all three intents:
- apps.go: kept this branch's creation-grace RegisterApp (mirror-only, no
  synchronous charge). #45's IsLargeAutoCollect wiring targeted the OLD
  synchronous-charge proration path that no longer lives in RegisterApp, so it
  is ported to proration.go's ChargeCreationProration callback instead (the
  threshold is resolved right after the Stripe call succeeds, matching the
  boundary leg).
- models.go / *.sql.go: regenerated via sqlc from the merged migrations
  (032 account-wide overage + 034 auto-collect both present).
- charge_test.go: the #45 RegisterApp post-charge-resolution test targeted the
  removed synchronous path; rewritten to drive ChargeCreationProration with the
  unified model's flat $20 base ($10 pre-charge vs $30 mid-charge threshold
  straddle), preserving the "resolved after Stripe succeeds" assertion.

build / vet / test / gofmt all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@I-am-nothing

Copy link
Copy Markdown
Contributor Author

Superseded by #48 — the auto-collect threshold/flag logic carries forward as-is into the unified branch (migration renumbered 031→034), now wired into every charge site via one shared helper instead of just the boundary leg. Branch feat/auto-collect-disclosure kept on origin.

I-am-nothing added a commit that referenced this pull request Jul 6, 2026
feat: unified base-fee + per-module overage timer model (supersedes #45, #46, #47)
I-am-nothing added a commit that referenced this pull request Jul 6, 2026
Replaces the account-wide pooled overage model with per-module-instance
overage timers (migration 033), merges in the app-creation grace period
and the auto-collect disclosure flag, and unifies base fee + module
overage into one charge concept. Supersedes #45, #46, #47.

Key mechanics: 3-day creation grace (deleted WITHIN grace = never
charged; after = still owes); 5 included modules account-wide with
$3/module beyond, one install-anchored timer per module instance with
live FIFO re-evaluation (over->included only); coverage contract —
every grace charge covers install/creation day through the END of the
period its grace elapses into, boundary legs count only entities whose
(immutable) install + grace-expiry predate the new period; all Stripe
charges via draft->pinned-items->finalize with ms_charge_ref metadata;
migration 035/036 markers + FindInvoiceByRef recovery for retries past
Stripe's idempotency-key window (gates bypassed only when a finalized
invoice actually exists); per-app advisory-locked timer synthesis and
deletion. Migrations 029-036.

Squash of the 32-commit PR branch, including two adversarial-review fix
waves (23 + 12 confirmed findings fixed; full record on PR #48 and in
docs-temp/pr48-review/findings.md). go build/vet/gofmt clean; full unit
+ integration suites green against real Postgres (migrations 001->036).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant