Skip to content

fix(billing): make usage reporting exactly-once (outbox) - #69

Open
keithfawcett wants to merge 2 commits into
mainfrom
fix/usage-exactly-once
Open

fix(billing): make usage reporting exactly-once (outbox)#69
keithfawcett wants to merge 2 commits into
mainfrom
fix/usage-exactly-once

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

Problem (audit #4 — HIGH)

The Stripe meter-event identifier was tied to a per-run rangeEnd (new Date()), while the high-water mark (LastUsageReportedAt) only advances after Stripe accepts. So a crash/timeout after Stripe accepted but before the mark advanced left the next run to aggregate an overlapping window with a new identifier — double-billing the tenant for the same GMV. The old comment claimed dedupe, but it only covered a re-run within the same second.

Fix (outbox, no schema change)

Freeze {rangeStart, rangeEnd, amount, identifier} to a pending Config row before the Stripe call; clear it only after the mark advances. On the next run, a surviving pending row is re-sent verbatim (same identifier ⇒ Stripe's Meter Events API dedupes) instead of re-aggregating. Uses the per-tenant Config table (added a deleteConfig helper) — no migration.

Crash windows:

  • before Stripe call → pending re-sent (first delivery)
  • after Stripe accept, before mark advance → pending re-sent, same identifier ⇒ dedup no-op, then mark advances
  • after mark advance → pending already cleared

Tests

usage-billing.test.ts: a clean run freezes-then-clears and advances the mark; a simulated crash (meter call throws after "accepting") leaves the pending row, and the retry re-sends the same identifier + amount even though extra GMV accrued in between — proving exactly-once. Typecheck clean.

🤖 Generated with Claude Code

keithfawcett and others added 2 commits August 8, 2026 12:21
The Stripe meter-event identifier was tied to a per-run rangeEnd (new Date()),
while the high-water mark only advances after Stripe accepts. A crash (or
timeout) after Stripe accepted but before the mark advanced meant the next
run aggregated an OVERLAPPING window with a NEW identifier — double-billing
the tenant for the same GMV.

Freeze {rangeStart, rangeEnd, amount, identifier} to a pending Config row
BEFORE the Stripe call; clear it only after the mark advances. On the next
run, a surviving pending row is re-sent VERBATIM (same identifier ⇒ Stripe's
Meter Events API dedupes) rather than re-aggregating. No schema change
(uses the per-tenant Config table); added deleteConfig helper.

Tests: a clean run freezes-then-clears; a simulated crash after the meter
call leaves the pending row, and the retry re-sends the SAME identifier +
amount (ignoring GMV accrued in between), proving exactly-once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings on the exactly-once outbox:

- (concurrency) The meter-event identifier was keyed on a per-run rangeEnd,
  so two runs racing the same period (scheduler + manual /billing/report-usage,
  or two replicas) each froze a different rangeEnd/identifier and submitted the
  same GMV twice. Key the identifier on the period START (the high-water mark)
  instead — identical for concurrent same-period runs, so Stripe dedupes;
  distinct periods still get distinct keys.

- (stale pending) A pending row left by a multi-week outage could never be
  resent (Stripe rejects meter events past its ~35-day timestamp window),
  wedging the tenant's reporting forever, and past Stripe's ≥24h dedup window a
  resend risks double-billing. If the frozen rangeEnd is >30d old, abandon it:
  advance the mark and ALERT for manual reconciliation (a rare, loud
  under-report beats a silent double-charge or a permanent wedge).

Tests: identifier carries the period-start suffix (not rangeEnd); a 40-day-old
pending row is abandoned (no Stripe call, mark advanced, pending cleared).

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