Skip to content

fix(billing): per-advertiser aggregate charging via stable batch id + single-flight lock (M5-T3)#31

Merged
JaamesBond merged 2 commits into
mainfrom
fix/billing-aggregation
Jul 4, 2026
Merged

fix(billing): per-advertiser aggregate charging via stable batch id + single-flight lock (M5-T3)#31
JaamesBond merged 2 commits into
mainfrom
fix/billing-aggregation

Conversation

@JaamesBond

Copy link
Copy Markdown
Owner

What & why (M5-T3 — first real charge)

CPVA bills ~€0.05/view, permanently below Stripe's 50¢ minimum. The old per-impression /charge skipped every impression and a live run wrote a terminal below_stripe_minimum row per group (the uncharged view excludes any group with any charge row) — which would have permanently stranded the advertiser's balance while the publisher earning legs stood. A €0.05/view advertiser could never be charged.

This aggregates an advertiser's uncharged groups into one PaymentIntent, keyed off a stable charge_batch_id (migration 20260704140000 adds the column + billing_run_lock + billing_lock_acquire|release):

  • planAdvertiserChargesskip_house | skip_below_min (non-terminal, no row) | charge.
  • chargeFreshBatch: resolve PM first (no PM ⇒ pause+skip, no reserve) → mint charge_batch_idreservePending (claim via on_conflict=entry_group_id) → settleReservedBatch: one PI with idempotencyKey = lumaline_agg_<batch_id> (immutable) → promote rows to succeeded.
  • Batch id is frozen at reserve, so a crash/ambiguous-error retry — or the recovery pass — re-issues the same batch → same PI (no double charge). New accrual = new batch, never merged.
  • Recovery settles every pending batch by charge_batch_id, independent of the fresh plans (no same-advertiser stranding). House/sub-min batches are released pre-PI. billing_run_lock = single-flight (defense-in-depth atop per-group claiming).

Review

Adversarial review found + this redesign closes F1 (double-charge via mutable set-derived key), F2 (no single-flight), F3 (stranded pending), F5 (house chargeable). Re-review done inline (workflow hit a session limit); lock verified end-to-end through PostgREST. Fast-follow (cannot fire on a single manual charge): F10 Stripe 24h idempotency-key TTL — keep recovery cadence <24h or retrieve-before-create by metadata.charge_batch_id.

Status

⚠️ Already deployed to prod (prmsonskzrubqsazmpwd) and the first real charge ran: PI pi_3TpUM5CChUMF5SBO015yREeI, €1.10, 22 impressions, reconcile GREEN (discrepancy 0). This PR lands main on the code that is already live.

Tests

test/billing-aggregation.test.mjs — 50 pass (batch-key immutability, partition-by-batch, 50¢ boundary + rounding, house/below-min/multi-advertiser). Full suite: the only failures are the 2 auth-device grace tests, which resolve once #30 (grace-window) lands on main — not billing-related.

🤖 Generated with Claude Code

JaamesBond and others added 2 commits July 4, 2026 15:44
…+ single-flight lock

CPVA bills ~€0.05/view, permanently below Stripe's 50-cent minimum, so the per-impression
charge loop skipped every impression and a live run wrote a TERMINAL below_stripe_minimum
row per group (excluded from the uncharged view forever) — permanently stranding the
advertiser's balance while the publisher earning legs stood. A €0.05/view advertiser could
never be charged.

Aggregate an advertiser's uncharged groups into ONE PaymentIntent, keyed off a STABLE batch id:
- planAdvertiserCharges (pure): group the view by advertiser → skip_house | skip_below_min
  (NON-terminal — no row, stays in the view to accumulate) | charge.
- chargeFreshBatch: resolve PM first (no PM ⇒ pause + skip, no reserve) → mint charge_batch_id →
  reservePending (claim groups via on_conflict=entry_group_id; each group belongs to exactly one
  batch) → settleReservedBatch: reselect the batch, one PI with idempotencyKey =
  lumaline_agg_<batch_id> (IMMUTABLE), promote the rows to succeeded with the shared PI id.
- The batch id is frozen at reserve time, so a crash/ambiguous-error retry — or the recovery pass
  — re-issues the SAME batch → SAME key → Stripe returns the SAME PI (no double charge). Freshly
  accrued impressions form a NEW batch and can never merge into an already-attempted charge.
  (Fixes adversarial review F1: the earlier set-derived key re-charged when the set grew.)
- Recovery settles EVERY pending batch grouped by charge_batch_id, independent of the fresh plans,
  so a pending batch is never stranded by a same-advertiser skip (F3). Sub-minimum/house claimed
  batches are RELEASED back to the view, never charged (F5). A definitive decline terminal-fails +
  pauses; ambiguous errors leave the batch pending for an idempotent retry.
- billing_run_lock + billing_lock_acquire/release: single-flight guard around the cycle (F2),
  atomic compare-and-swap with a 10-min stale-reclaim TTL.

Migration 20260704140000 adds advertiser_charges.charge_batch_id + the lock table/RPCs.
/reconcile + /refund contracts unchanged. Exhaustive pure unit tests (batch key immutability,
partition-by-batch, 50c boundary + rounding, house/below-min/multi-advertiser); T23/T46 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@JaamesBond JaamesBond merged commit b55ec94 into main Jul 4, 2026
7 checks passed
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