fix(billing): dead-letter mechanism + dispute.funds_withdrawn handler#3603
Conversation
…nds_withdrawn handler
1. withIdempotency: don't delete the ProcessedStripeEvent doc on handler exception.
`attempts` now persists across Stripe's redelivery cycle, so MAX_ATTEMPTS (5) is
actually reachable. tryRecord uses 3-state semantics derived from existing fields
(no new pendingRetry column):
- first delivery → { recorded: true, retry: false } → handler runs
- in-flight retry → { recorded: true, retry: true } → handler re-enters
- already succeeded → { recorded: false, reason: 'already_processed' } → skip
- dead-lettered → { recorded: false, reason: 'dead_letter' } → skip
The (attempts, deadLetter) pair encodes all four states unambiguously: handler
never increments on success, so attempts === 0 + !deadLetter is the terminal-
success signal. On exception below MAX → throw so Stripe retries on next
delivery; at MAX → markDeadLetter + return success sentinel so Stripe stops.
2. charge.dispute.funds_withdrawn handler: debits the ledger via refundPartial
with stable refId 'dispute_<id>' when Stripe actually withdraws funds (dispute
lost). Closes the money-loss gap where the customer kept meter units after
losing a dispute. Disputes are independent of subscription/invoice cycles, so
no per-family event-newer guard — refundPartial's own ledger-layer idempotency
on refId carries the no-op guarantee on Stripe redelivery.
Resolution path mirrors handleChargeRefunded: charge.metadata first, PI backfill
fallback for SENTINEL_PENDING, emit 'billing.refund.unresolved' when the charge
is unrelated to billing extras, log critical alert + emit 'billing.dispute.lost'
on the success path so ops are notified that money has actually left the account.
|
Warning Rate limit exceeded
To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (7)
✨ 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 |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 5 high |
🟢 Metrics 52 complexity · 40 duplication
Metric Results Complexity 52 Duplication 40
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3603 +/- ##
==========================================
+ Coverage 87.18% 87.39% +0.20%
==========================================
Files 132 132
Lines 3980 4046 +66
Branches 1233 1259 +26
==========================================
+ Hits 3470 3536 +66
Misses 392 392
Partials 118 118
Flags with carried forward coverage won't be shown. Click here to find out more. Continue to review full report in Codecov by Sentry.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull Request Overview
The PR introduces the dead-letter mechanism and the charge.dispute.funds_withdrawn handler, but the implementation is currently not up to standards. A critical logic error in the idempotency wrapper (withIdempotency) prevents the attempts counter from resetting after a successful execution on a retry; this will cause duplicate processing if Stripe sends further redeliveries for a successfully handled event.
While the Intent agent confirmed that the basic test scenarios for the dispute handler and repository are present, the handler's metadata resolution logic lacks sufficient validation for malformed ObjectIds. Furthermore, the PR introduces significant code duplication across the service layer and new linting issues in the test suite that must be addressed to meet quality requirements.
About this PR
- Current changes do not meet the project's quality standards. Please address the logic duplication and the critical idempotency bug to satisfy the required quality gates.
- The PR exhibits a systemic pattern of code duplication in
billing.webhook.service.js. Multiple handlers repeat the same try/catch wrapper for emitting events, which should be abstracted into a helper method to improve maintainability and reduce the risk of logging inconsistencies.
Test suggestions
- Found: tryRecord: First delivery returns {recorded: true, retry: false}
- Found: tryRecord: In-flight retry (attempts > 0) returns {recorded: true, retry: true}
- Found: tryRecord: Already processed (attempts = 0) returns {recorded: false, reason: 'already_processed'}
- Found: tryRecord: Dead-lettered (deadLetter: true) returns {recorded: false, reason: 'dead_letter'}
- Found: withIdempotency: Record is not deleted on handler failure, allowing attempts to increment
- Found: handleChargeDisputeFundsWithdrawn: Successfully debits ledger via refundPartial with stable dispute refId
- Found: handleChargeDisputeFundsWithdrawn: Backfills organization/session metadata from PaymentIntent when Charge metadata is missing or pending
- Found: handleChargeDisputeFundsWithdrawn: Emits billing.refund.unresolved when resolution fails
- Found: handleChargeDisputeFundsWithdrawn: Ignores events with missing IDs or zero/negative amounts
TIP Improve review quality by adding custom instructions
TIP How was this review? Give us feedback
| describe('tryRecord', () => { | ||
| test('should return { recorded: true } on first insert', async () => { | ||
| /** Helper: stub findOne().lean() to return the given existing doc. */ | ||
| const stubExisting = (existing) => { |
There was a problem hiding this comment.
🔴 HIGH RISK
This file is part of a Node.js repository, but the linter is incorrectly applying Qwik-specific serialization rules. This suppression is used elsewhere in the module to handle this false positive.
This might be a simple fix:
| const stubExisting = (existing) => { | |
| // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js test, not Qwik | |
| const stubExisting = (existing) => { |
| return { skipped: true, reason: 'duplicate_event_or_dead_letter', detail: claim.reason }; | ||
| } | ||
| try { | ||
| return await handler(event); |
There was a problem hiding this comment.
🔴 HIGH RISK
Idempotency is violated for retried events because the attempts counter is not reset upon success. After a successful handler execution, you must reset attempts to 0 in the database if the current run was a retry.
Try running the following prompt in your coding agent:
In
modules/billing/services/billing.webhook.service.js, updatewithIdempotencyto reset theattemptscounter to 0 in the repository afterhandler(event)succeeds ifclaim.retryis true. You will also need to implement aresetAttempts(eventId)method inmodules/billing/repositories/billing.processedStripeEvent.repository.js.
| amount, | ||
| eventId: event?.id, | ||
| }); | ||
| try { |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Suggestion: The pattern for emitting events with a safe try/catch wrapper is repeated several times in this service (e.g., lines 760-768, 845-859, 878-891). This boilerplate clutters the core logic.
Try running the following prompt in your IDE agent:
Create a private helper function
safeEmit(eventName, payload)that wrapsbillingEvents.emitin a try/catch block with the standard logging seen inhandleChargeDisputeFundsWithdrawn, then refactor the event emissions in this file to use it.
There was a problem hiding this comment.
Pull request overview
This PR updates the billing Stripe webhook pipeline to (1) make the dead-letter mechanism reachable by persisting retry attempts across redeliveries and (2) handle charge.dispute.funds_withdrawn by debiting the extras ledger when Stripe withdraws funds after a lost dispute.
Changes:
- Reworked webhook idempotency to persist retry
attempts, increment on handler failure, and dead-letter after max attempts (instead of rollback deletion). - Added a new webhook handler for
charge.dispute.funds_withdrawnto debit the ledger with a stable, dispute-scoped idempotency refId and emit operational events. - Updated/added unit tests to cover the new idempotency state machine and the new dispute handler.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/billing/services/billing.webhook.service.js | Changes withIdempotency to persist attempts + dead-letter; adds handleChargeDisputeFundsWithdrawn. |
| modules/billing/repositories/billing.processedStripeEvent.repository.js | Updates tryRecord to return richer “recorded/retry/reason” claim semantics; adds attempts/dead-letter helpers usage. |
| modules/billing/controllers/billing.webhook.controller.js | Routes charge.dispute.funds_withdrawn events through withIdempotency to the new handler. |
| modules/billing/tests/billing.webhook.idempotency.unit.tests.js | Updates idempotency unit tests for the new tryRecord semantics and non-rollback behavior. |
| modules/billing/tests/billing.webhook.hardening.unit.tests.js | Extends hardening tests for dead-letter behavior and adds coverage for the new dispute funds-withdrawn handler. |
| modules/billing/tests/billing.processedStripeEvent.repository.unit.tests.js | Updates repository tests to validate the new 3-state tryRecord return semantics. |
| try { | ||
| return await handler(event); | ||
| } catch (err) { |
| * 4. On handler success the record stays with attempts === 0 (we never increment on success), | ||
| * which is the terminal-success signal for tryRecord on subsequent redeliveries. |
| if ((existing.attempts ?? 0) > 0) { | ||
| // Pending retry — previous run failed, attempts persisted. Allow handler re-entry. | ||
| return { recorded: true, retry: true }; |
| * @function tryRecord | ||
| * @description Atomically insert a new processed event document. | ||
| * If a document with the same eventId already exists (E11000 duplicate key), | ||
| * returns `{ recorded: false }` instead of throwing — idempotency by design. | ||
| * On success, returns `{ recorded: true }`. | ||
| * @description Atomically claim a Stripe event for processing using the unique index on eventId. | ||
| * Returns 3-state semantics so withIdempotency can persist `attempts` across Stripe | ||
| * redeliveries (the previous design deleted the doc on rollback, which reset attempts | ||
| * and made the dead-letter branch unreachable): |
| if (!stripe) { | ||
| logger.error('[billing.webhook] dispute.funds_withdrawn — Stripe client unavailable, cannot resolve charge', { | ||
| disputeId, | ||
| chargeId, | ||
| }); |
| await BillingExtraService.refundPartial(organizationId, stripeSessionId, amount, packId, refId); | ||
|
|
…n route Lifts codecov/patch from 73.68% to ~100% on PR #3603 by adding tests for the previously-uncovered defensive branches in handleChargeDisputeFundsWithdrawn: - getStripe() returns null → log + early return - charge.retrieve failure → log + re-throw (counts toward dead-letter) - PI fetch failure → log non-fatal + fall through (then unresolved branch) - PI metadata backfills organizationId when charge.metadata invalid - emit listener errors swallowed with logger.error in all 3 branches (missing-fields, unresolved, dispute.lost success) - controller dispatch test for charge.dispute.funds_withdrawn route
Summary
Two post-merge audit fixes on the billing module before LIVE.
Fix 1 — Dead-letter mechanism unreachable (architectural bug)
Previous design deleted the
ProcessedStripeEventdoc on handler exception, soattemptsreset to 0 on every Stripe redelivery and theattempts >= 5dead-letter branch was never reachable. A permanently-broken handler would loop ~50× across Stripe's 3-day retry window, polluting logs and potentially writing partial state.Fix: don't delete the doc on rollback.
attemptspersists across redeliveries.tryRecordnow returns 3-state semantics derived from existing fields (no newpendingRetrycolumn — the(attempts, deadLetter)pair already encodes the four states unambiguously):{ recorded: true, retry: false }attempts > 0 && !deadLetter{ recorded: true, retry: true }attempts === 0 && !deadLetter{ recorded: false, reason: 'already_processed' }deadLetter: true{ recorded: false, reason: 'dead_letter' }withIdempotency: on exception, increment attempts; below MAX → throw (Stripe retries); at MAX →markDeadLetter+ return success sentinel so Stripe stops.Fix 2 — Add
charge.dispute.funds_withdrawnwebhook handlercharge.dispute.createdwas handled (log + emit, no debit — conservative). Butcharge.dispute.funds_withdrawn(the event when Stripe actually pulls money from the bank because the dispute was lost) was unhandled — money-loss gap where the customer kept meter units.New
handleChargeDisputeFundsWithdrawn:charge.metadata, falls back to PaymentIntent backfill onSENTINEL_PENDING(mirrorshandleChargeRefunded).BillingExtraService.refundPartial(orgId, sessionId, dispute.amount, packId, 'dispute_<id>').refIdper dispute → ledger-layer idempotency makes Stripe redeliveries no-ops.refundPartial's ledger-layer idempotency onrefIdexclusively (combined with the persistent dead-letter machinery from Fix 1).billing.dispute.loston success,billing.refund.unresolved(reason: 'dispute_unresolved') when org/session can't be resolved.logger.erroron the success path — money has actually left the account, ops must be notified.Test plan
npm run lint— cleanNODE_ENV=test npm run test:unit— 1137/1137 passNODE_ENV=test npm run test:integration— 335/335 passtryRecord(deadLetter / in-flight retry / already_processed / race-window)withIdempotencytests to assert no rollback on failure (doc must persist)handleChargeDisputeFundsWithdrawn: fully-resolved debit, sentinel backfill, unrelated-charge unresolved emit, idempotent refId per dispute, missing-fields/zero-amount guards