Skip to content

fix(cloud): atomic + spend-clamped ad-campaign refunds — stop concurrent/retry double-refund + decrease over-refund (#11292) - #11369

Merged
lalalune merged 2 commits into
developfrom
nubs/ad-refund-safety
Jul 2, 2026
Merged

lalalune merged 2 commits into
developfrom
nubs/ad-refund-safety

Conversation

@NubsCarson

Copy link
Copy Markdown
Member

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) and deleteCampaign both did findById → compute refund → refundCredits with 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. updateCampaign decrease over-refunded after spend.
It refunded the full allocation delta (old_allocated - new_allocated), ignoring credits_spent/total_spend — so lowering a budget after real ad spend returned credits already spent on impressions. (deleteCampaign already clamps; updateCampaign did not.)

Fix

Evidence

bun test packages/cloud/shared/src/lib/services/__tests__/ad-campaign-credit-reconciliation.test.ts

 12 pass
 0 fail
 30 expect() calls
Ran 12 tests across 1 file.

The 5 existing #11151 delete tests + 3 existing update tests still pass (helper extraction is behavior-preserving); 4 new tests cover the fixes:

  • decrease-after-spend refunds only the unused 22, not the full 99;
  • decrease with no spend refunds the full 99;
  • concurrent decrease (lost CAS) throws and refunds nothing;
  • concurrent delete (lost claimDelete) refunds nothing.

Mutation-checked (proving the tests catch the bugs, not just pass):

  • revert the clamp to Math.max(0, freed) → the decrease-after-spend test fails (Received: 99, Expected: 22, the exact 77 over-refund);
  • remove the conflict throw → the concurrent-decrease test fails (promise resolves instead of rejecting).

Biome clean. N/A UI/trajectory — backend money-arithmetic change, no UI/model surface; pinned by the unit suite driving the real advertisingService (only repo/credits/provider boundaries spied).

⚠️ Review note

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.

…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.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 711e89a9-c00f-456c-b207-cfa1e45c146d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nubs/ad-refund-safety

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@lalalune

lalalune commented Jul 2, 2026

Copy link
Copy Markdown
Member

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

claude Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants