Skip to content

fix(billing): dead-letter mechanism + dispute.funds_withdrawn handler#3603

Merged
PierreBrisorgueil merged 2 commits into
masterfrom
fix/billing-deadletter-dispute
May 5, 2026
Merged

fix(billing): dead-letter mechanism + dispute.funds_withdrawn handler#3603
PierreBrisorgueil merged 2 commits into
masterfrom
fix/billing-deadletter-dispute

Conversation

@PierreBrisorgueil
Copy link
Copy Markdown
Contributor

Summary

Two post-merge audit fixes on the billing module before LIVE.

Fix 1 — Dead-letter mechanism unreachable (architectural bug)

Previous design deleted the ProcessedStripeEvent doc on handler exception, so attempts reset to 0 on every Stripe redelivery and the attempts >= 5 dead-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. attempts persists across redeliveries.

tryRecord now returns 3-state semantics derived from existing fields (no new pendingRetry column — the (attempts, deadLetter) pair already encodes the four states unambiguously):

state tryRecord result flow
new doc { recorded: true, retry: false } handler runs
existing, attempts > 0 && !deadLetter { recorded: true, retry: true } handler re-enters
existing, attempts === 0 && !deadLetter { recorded: false, reason: 'already_processed' } skip (succeeded last time — handler never increments on success)
existing, deadLetter: true { recorded: false, reason: 'dead_letter' } skip (terminal — return 200 to Stripe)

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_withdrawn webhook handler

charge.dispute.created was handled (log + emit, no debit — conservative). But charge.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:

  • Resolves org/session/pack via charge.metadata, falls back to PaymentIntent backfill on SENTINEL_PENDING (mirrors handleChargeRefunded).
  • Debits via BillingExtraService.refundPartial(orgId, sessionId, dispute.amount, packId, 'dispute_<id>').
  • Stable refId per dispute → ledger-layer idempotency makes Stripe redeliveries no-ops.
  • No per-family event-newer guard — disputes are independent of subscription/invoice cycles, so we deliberately rely on refundPartial's ledger-layer idempotency on refId exclusively (combined with the persistent dead-letter machinery from Fix 1).
  • Emits billing.dispute.lost on success, billing.refund.unresolved (reason: 'dispute_unresolved') when org/session can't be resolved.
  • Critical logger.error on the success path — money has actually left the account, ops must be notified.

Test plan

  • npm run lint — clean
  • NODE_ENV=test npm run test:unit — 1137/1137 pass
  • NODE_ENV=test npm run test:integration — 335/335 pass
  • Updated repository tests for 3-state tryRecord (deadLetter / in-flight retry / already_processed / race-window)
  • Updated withIdempotency tests to assert no rollback on failure (doc must persist)
  • New tests for handleChargeDisputeFundsWithdrawn: fully-resolved debit, sentinel backfill, unrelated-charge unresolved emit, idempotent refId per dispute, missing-fields/zero-amount guards

…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.
Copilot AI review requested due to automatic review settings May 5, 2026 09:07
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Warning

Rate limit exceeded

@PierreBrisorgueil has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 5 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60c4436d-4f72-4c23-864e-adbebc8ccb11

📥 Commits

Reviewing files that changed from the base of the PR and between 7b89555 and 98eb671.

📒 Files selected for processing (7)
  • modules/billing/controllers/billing.webhook.controller.js
  • modules/billing/repositories/billing.processedStripeEvent.repository.js
  • modules/billing/services/billing.webhook.service.js
  • modules/billing/tests/billing.controller.unit.tests.js
  • modules/billing/tests/billing.processedStripeEvent.repository.unit.tests.js
  • modules/billing/tests/billing.webhook.hardening.unit.tests.js
  • modules/billing/tests/billing.webhook.idempotency.unit.tests.js
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-deadletter-dispute

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 and usage tips.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 5, 2026

Not up to standards ⛔

🔴 Issues 5 high

Alerts:
⚠ 5 issues (≤ 0 issues of at least minor severity)

Results:
5 new issues

Category Results
ErrorProne 5 high

View in Codacy

🟢 Metrics 52 complexity · 40 duplication

Metric Results
Complexity 52
Duplication 40

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.39%. Comparing base (7b89555) to head (98eb671).
⚠️ Report is 1 commits behind head on master.

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              
Flag Coverage Δ
integration 61.73% <1.31%> (-1.00%) ⬇️
unit 61.04% <100.00%> (+0.64%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7b89555...98eb671. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@codacy-production codacy-production Bot left a comment

Choose a reason for hiding this comment

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

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) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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:

Suggested change
const stubExisting = (existing) => {
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js test, not Qwik
const stubExisting = (existing) => {

See Issue in Codacy

return { skipped: true, reason: 'duplicate_event_or_dead_letter', detail: claim.reason };
}
try {
return await handler(event);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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, update withIdempotency to reset the attempts counter to 0 in the repository after handler(event) succeeds if claim.retry is true. You will also need to implement a resetAttempts(eventId) method in modules/billing/repositories/billing.processedStripeEvent.repository.js.

amount,
eventId: event?.id,
});
try {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 wraps billingEvents.emit in a try/catch block with the standard logging seen in handleChargeDisputeFundsWithdrawn, then refactor the event emissions in this file to use it.

See Clone in Codacy

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_withdrawn to 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.

Comment on lines 114 to 116
try {
return await handler(event);
} catch (err) {
Comment on lines +95 to +96
* 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.
Comment on lines +62 to +64
if ((existing.attempts ?? 0) > 0) {
// Pending retry — previous run failed, attempts persisted. Allow handler re-entry.
return { recorded: true, retry: true };
Comment on lines 16 to +20
* @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):
Comment on lines +772 to +776
if (!stripe) {
logger.error('[billing.webhook] dispute.funds_withdrawn — Stripe client unavailable, cannot resolve charge', {
disputeId,
chargeId,
});
Comment on lines +866 to +867
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
@PierreBrisorgueil PierreBrisorgueil merged commit f51e40a into master May 5, 2026
5 of 6 checks passed
@PierreBrisorgueil PierreBrisorgueil deleted the fix/billing-deadletter-dispute branch May 5, 2026 09:24
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.

2 participants