fix(billing): per-advertiser aggregate charging via stable batch id + single-flight lock (M5-T3)#31
Merged
Merged
Conversation
…+ 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why (M5-T3 — first real charge)
CPVA bills ~€0.05/view, permanently below Stripe's 50¢ minimum. The old per-impression
/chargeskipped every impression and a live run wrote a terminalbelow_stripe_minimumrow 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(migration20260704140000adds the column +billing_run_lock+billing_lock_acquire|release):planAdvertiserCharges→skip_house | skip_below_min(non-terminal, no row)| charge.chargeFreshBatch: resolve PM first (no PM ⇒ pause+skip, no reserve) → mintcharge_batch_id→reservePending(claim viaon_conflict=entry_group_id) →settleReservedBatch: one PI withidempotencyKey = lumaline_agg_<batch_id>(immutable) → promote rows to succeeded.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
prmsonskzrubqsazmpwd) and the first real charge ran: PIpi_3TpUM5CChUMF5SBO015yREeI, €1.10, 22 impressions, reconcile GREEN (discrepancy 0). This PR landsmainon 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 2auth-devicegrace tests, which resolve once #30 (grace-window) lands on main — not billing-related.🤖 Generated with Claude Code