fix(cloud): atomic + spend-clamped ad-campaign refunds — stop concurrent/retry double-refund + decrease over-refund (#11292) - #11369
Conversation
…ent/retry double-refund and decrease-over-refund (#11292) Two live-money leaks in the ad-campaign refund paths (follow-up #11255, re-verified against merged code): 1) No atomic claim → concurrent/retry DOUBLE-REFUND. updateCampaign budget-decrease and deleteCampaign both did findById → compute refund → refundCredits with no row-level claim, so two in-flight decreases (or a delete retried after the refund succeeded but the delete threw) both refunded. Fixed with compare-and-swap claims: - claimAllocationChange: UPDATE ... WHERE credits_allocated=<observed> RETURNING — only the winner refunds a decrease; a lost CAS throws a retryable conflict instead of double-refunding. - claimDelete: DELETE ... RETURNING — only the caller that removes the row refunds; a concurrent second delete gets nothing. 2) updateCampaign decrease OVER-REFUNDED after spend. It refunded the full allocation delta ignoring credits_spent/total_spend, so lowering a budget after real ad spend returned credits already spent. Now refunds only min(freed, unused), reusing deleteCampaign's proven two-column spend clamp (#11151) — extracted into a shared computeCreditsSpent helper (rule-2 reuse). Crucially, credits_allocated is kept == newBudget*markup (the REFUND is clamped, never the stored allocation) so the markup derived at delete (allocated/budget) stays correct — the coupling that made a naive fix corrupt future refund math. Money-lane review requested (@lalalune): implemented under Fable's rate cap by the main model, mutation-checked, do not self-merge.
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…M spend accounting + no dangling-FK ledger insert This branch was cut after #11271's stale-base squash reverted #11255, so it re-derived two things #11255 had already settled: 1. computeCreditsSpent took MAX(internal, external). The streams are additive (findEligibleAd serves external campaigns through the internal SSP too), so MAX under-counts dual-stream spend and over-refunds. Restored the SUM-with-clamp from merged #11255. 2. The delete-path refund ledger insert used campaign_id after claimDelete removed the row — a 23503 FK violation on real Postgres, firing AFTER refundCredits committed (endpoint 500s, ledger row dropped; invisible to the repository-spy tests). Restored the external_reference carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Your trial has ended. Reactivate Greptile to resume code reviews.
|
Maintainer review (money path): the atomic claim design is right (claimDelete DELETE…RETURNING winner-refunds, update-path CAS is a genuine addition over #11255), and adversarial review confirmed this branch re-lands what #11271's stale-base squash accidentally reverted. Pushed 4960873 restoring two things merged-#11255 had already settled, which this branch (cut post-revert) re-derived differently: (1) SUM-with-clamp spend accounting — the streams are additive since findEligibleAd serves external campaigns through the internal SSP too, so MAX under-counts dual-stream spend and over-refunds; (2) the delete-path ledger insert used campaign_id after claimDelete removed the row — a 23503 FK violation on real Postgres firing AFTER the refund committed (invisible to the repository-spy tests); restored the external_reference carry. 12/12 + typecheck post-fix. Residual for the #11292 thread (pre-existing, both paths): claim and refundCredits are separate commits with no idempotency key — a refund throw after the claim permanently strands the customer's refund. Wants claim+refund in one tx or a sweep. |
|
Claude encountered an error —— View job I'll analyze this and get back to you. |
Fixes #11292 (follow-up to #11255).
Two live-money leaks in the ad-campaign refund paths
1. No atomic claim → concurrent / retry double-refund.
updateCampaign(budget decrease) anddeleteCampaignboth didfindById→ compute refund →refundCreditswith no row-level claim, so two concurrent decreases — or a delete retried after the refund succeeded but the row-delete threw — both refunded the same unused budget.2.
updateCampaigndecrease over-refunded after spend.It refunded the full allocation delta (
old_allocated - new_allocated), ignoringcredits_spent/total_spend— so lowering a budget after real ad spend returned credits already spent on impressions. (deleteCampaignalready clamps;updateCampaigndid not.)Fix
incrementSpendatomic-SQL pattern):claimAllocationChange—UPDATE … WHERE credits_allocated = <observed> RETURNING; only the winner refunds a decrease, a lost CAS throws a retryable conflict (never double-refunds).claimDelete—DELETE … RETURNING; only the caller that removes the row refunds.refund = min(freed, unused), reusingdeleteCampaign's proven two-column (security/money: internal SSP campaign delete refunds full budget after real spend (total_spend vs credits_spent) #11151) spend logic, now extracted into a sharedcomputeCreditsSpenthelper (rule-2 reuse — one calculation for both paths).credits_allocatedstays= newBudget × markup(only the refund is clamped, never the stored allocation), so the markup derived at delete (allocated / budget) stays correct. This is the coupling that would make a naive clamp corrupt future refund math (see money: ad-campaign budget-decrease + delete refunds aren't atomically claimed (concurrent/retry double-refund) + decrease ignores spend (over-refund) — follow-up #11255 #11292 for the full trace).Evidence
bun test packages/cloud/shared/src/lib/services/__tests__/ad-campaign-credit-reconciliation.test.tsThe 5 existing #11151 delete tests + 3 existing update tests still pass (helper extraction is behavior-preserving); 4 new tests cover the fixes:
Mutation-checked (proving the tests catch the bugs, not just pass):
Math.max(0, freed)→ the decrease-after-spend test fails (Received: 99, Expected: 22, the exact 77 over-refund);throw→ the concurrent-decrease test fails (promise resolves instead of rejecting).Biome clean.
N/AUI/trajectory — backend money-arithmetic change, no UI/model surface; pinned by the unit suite driving the realadvertisingService(only repo/credits/provider boundaries spied).Implemented by the main (Opus) model while the Fable agent was rate-capped — money code, so please scrutinize; do not self-merge, this is the money lane (@lalalune). The atomic-claim + markup-invariant reasoning is in #11292.