Skip to content

Commit f51e40a

Browse files
fix(billing): dead-letter mechanism + dispute.funds_withdrawn handler (#3603)
* fix(billing): make dead-letter mechanism work + add charge.dispute.funds_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. * test(billing): cover dispute handler defensive paths + funds_withdrawn 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
1 parent 7b89555 commit f51e40a

7 files changed

Lines changed: 772 additions & 114 deletions

modules/billing/controllers/billing.webhook.controller.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ const handleWebhook = async (req, res) => {
7272
BillingWebhookService.handleChargeDisputeCreated(e.data.object, e),
7373
);
7474
break;
75+
case 'charge.dispute.funds_withdrawn':
76+
await BillingWebhookService.withIdempotency(event, (e) =>
77+
BillingWebhookService.handleChargeDisputeFundsWithdrawn(e.data.object, e),
78+
);
79+
break;
7580
default:
7681
break;
7782
}

modules/billing/repositories/billing.processedStripeEvent.repository.js

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,24 @@ const ProcessedStripeEvent = () => mongoose.model('ProcessedStripeEvent');
1414

1515
/**
1616
* @function tryRecord
17-
* @description Atomically insert a new processed event document.
18-
* If a document with the same eventId already exists (E11000 duplicate key),
19-
* returns `{ recorded: false }` instead of throwing — idempotency by design.
20-
* On success, returns `{ recorded: true }`.
17+
* @description Atomically claim a Stripe event for processing using the unique index on eventId.
18+
* Returns 3-state semantics so withIdempotency can persist `attempts` across Stripe
19+
* redeliveries (the previous design deleted the doc on rollback, which reset attempts
20+
* and made the dead-letter branch unreachable):
21+
* - New doc inserted → `{ recorded: true, retry: false }` (first delivery)
22+
* - Existing doc, `deadLetter: true` → `{ recorded: false, reason: 'dead_letter' }`
23+
* (we already gave up — return success to Stripe so it stops retrying)
24+
* - Existing doc, `attempts > 0 && !deadLetter` → `{ recorded: true, retry: true }`
25+
* (a previous run failed and was kept on disk; allow re-entry so attempts persists)
26+
* - Existing doc, `attempts === 0 && !deadLetter` → `{ recorded: false,
27+
* reason: 'already_processed' }` (handler succeeded last time; never increment on
28+
* success, so attempts === 0 is the terminal-success signal)
29+
*
30+
* No `pendingRetry` field is added — the (attempts, deadLetter) pair already encodes
31+
* the four states unambiguously.
2132
* @param {string} eventId - Stripe event ID (unique idempotency key).
2233
* @param {string} type - Stripe event type (e.g. 'checkout.session.completed').
23-
* @returns {Promise<{recorded: boolean}>}
34+
* @returns {Promise<{recorded: boolean, retry?: boolean, reason?: string}>}
2435
*/
2536
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js repository, not Qwik
2637
const tryRecord = async (eventId, type) => {
@@ -33,12 +44,27 @@ const tryRecord = async (eventId, type) => {
3344

3445
try {
3546
await ProcessedStripeEvent().create({ eventId, type, processedAt: new Date() });
36-
return { recorded: true };
47+
return { recorded: true, retry: false };
3748
} catch (err) {
38-
if (isDuplicateKeyError(err)) {
39-
return { recorded: false };
49+
if (!isDuplicateKeyError(err)) {
50+
throw err;
4051
}
41-
throw err;
52+
// Existing doc — inspect state to decide whether to allow re-entry.
53+
const existing = await ProcessedStripeEvent().findOne({ eventId }).lean();
54+
if (!existing) {
55+
// Race window: doc was deleted between insert-fail and lookup. Treat as already_processed
56+
// (TTL or admin cleanup) — safest is to skip and keep Stripe quiet.
57+
return { recorded: false, reason: 'already_processed' };
58+
}
59+
if (existing.deadLetter) {
60+
return { recorded: false, reason: 'dead_letter' };
61+
}
62+
if ((existing.attempts ?? 0) > 0) {
63+
// Pending retry — previous run failed, attempts persisted. Allow handler re-entry.
64+
return { recorded: true, retry: true };
65+
}
66+
// attempts === 0 && !deadLetter → handler succeeded on first delivery (we never increment on success).
67+
return { recorded: false, reason: 'already_processed' };
4268
}
4369
};
4470

modules/billing/services/billing.webhook.service.js

Lines changed: 201 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -78,32 +78,45 @@ const syncOrganizationPlan = async (organizationId, plan) => {
7878
/**
7979
* @description Wrap a webhook handler with idempotency using ProcessedStripeEvent.
8080
*
81-
* Atomic-claim semantics (closes TOCTOU race):
81+
* Atomic-claim semantics (closes TOCTOU race) + persistent retry counter:
8282
* 1. tryRecord atomically inserts the event record BEFORE running the handler.
83-
* The unique index on eventId means only the first concurrent delivery succeeds;
84-
* all others get E11000 → { recorded: false } → skip.
85-
* 2. If the handler throws, deleteByEventId rolls back the claim so Stripe can retry.
86-
* 3. On handler success the record stays permanently — subsequent Stripe retries are
87-
* skipped via tryRecord returning { recorded: false }.
83+
* The unique index on eventId means only the first concurrent delivery succeeds.
84+
* 2. tryRecord uses 3-state semantics so attempts persists across Stripe redeliveries:
85+
* - First delivery → { recorded: true, retry: false } → handler runs
86+
* - In-flight retry (attempts > 0, !deadLetter) → { recorded: true, retry: true } → handler runs again
87+
* - Already succeeded (attempts === 0) → { recorded: false, reason: 'already_processed' } → skip
88+
* - Dead-letter (deadLetter: true) → { recorded: false, reason: 'dead_letter' } → skip
89+
* 3. On handler exception we DO NOT delete the doc (the previous design did, which reset
90+
* attempts to 0 on every Stripe redelivery and made BILLING_WEBHOOK_MAX_ATTEMPTS unreachable).
91+
* Instead we increment attempts, then either:
92+
* - attempts >= MAX → markDeadLetter, log critical, return success to Stripe (no throw)
93+
* - attempts < MAX → throw → Stripe gets 5xx → redelivers ~24h later → tryRecord returns
94+
* { recorded: true, retry: true } → handler runs again, attempts persists.
95+
* 4. On handler success the record stays with attempts === 0 (we never increment on success),
96+
* which is the terminal-success signal for tryRecord on subsequent redeliveries.
8897
*
8998
* @param {Object} event - Full Stripe event object (must have event.id and event.type).
90-
* @param {Function} handler - Async function (event) => result called when event is new.
91-
* @returns {Promise<Object>} Handler result or skip sentinel { skipped: true, reason: 'duplicate_event' }.
99+
* @param {Function} handler - Async function (event) => result called when event is new or retrying.
100+
* @returns {Promise<Object>} Handler result, or skip sentinel
101+
* { skipped: true, reason: 'duplicate_event_or_dead_letter' }, or
102+
* dead-letter sentinel { deadLettered: true, eventId, eventType, attempts }.
92103
*/
93104
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
94105
const withIdempotency = async (event, handler) => {
95106
const eventId = event.id;
96107
const eventType = event.type;
97108

98-
// Atomically claim the eventonly the first delivery wins
99-
const { recorded } = await ProcessedStripeEventRepository.tryRecord(eventId, eventType);
100-
if (!recorded) {
101-
return { skipped: true, reason: 'duplicate_event' };
109+
// Atomically claim or re-entersee tryRecord for 3-state semantics.
110+
const claim = await ProcessedStripeEventRepository.tryRecord(eventId, eventType);
111+
if (!claim.recorded) {
112+
return { skipped: true, reason: 'duplicate_event_or_dead_letter', detail: claim.reason };
102113
}
103114
try {
104115
return await handler(event);
105116
} catch (err) {
106117
// Increment attempt counter and record error details before deciding fate.
118+
// We MUST NOT delete the doc on failure — attempts must persist across Stripe redeliveries
119+
// for BILLING_WEBHOOK_MAX_ATTEMPTS to be reachable.
107120
let attempts = 1;
108121
try {
109122
const updated = await ProcessedStripeEventRepository.incrementAttempts(eventId, err?.message ?? String(err));
@@ -113,26 +126,19 @@ const withIdempotency = async (event, handler) => {
113126
}
114127

115128
if (attempts >= BILLING_WEBHOOK_MAX_ATTEMPTS) {
116-
// Dead-letter: keep claim permanently so Stripe stops retrying after 3-day window.
129+
// Dead-letter: keep claim permanently with deadLetter=true so subsequent redeliveries skip.
117130
try {
118131
await ProcessedStripeEventRepository.markDeadLetter(eventId);
119132
} catch (dlErr) {
120133
logger.error('[billing] webhook markDeadLetter failed', { eventId, eventType, error: dlErr?.message });
121134
}
122135
logger.error('[billing] webhook dead-letter', { eventId, eventType, attempts, error: err?.message ?? String(err) });
123-
// Return success to Stripe so it stops retrying
136+
// Return success to Stripe so it stops retrying.
124137
return { deadLettered: true, eventId, eventType, attempts };
125138
}
126139

127-
// Rollback claim so Stripe can retry on a fresh delivery.
128-
// Swallow rollback errors so the original handler error is always propagated.
129-
await ProcessedStripeEventRepository.deleteByEventId(event.id).catch((rollbackErr) => {
130-
logger.error('[billing.webhook] rollback deleteByEventId failed — event may be stuck', {
131-
eventId: event.id,
132-
error: rollbackErr?.message ?? String(rollbackErr),
133-
stack: rollbackErr?.stack,
134-
});
135-
});
140+
// Below MAX: keep the doc (attempts persists), throw so Stripe retries on next delivery.
141+
// The next delivery will hit tryRecord → { recorded: true, retry: true } and re-enter here.
136142
throw err;
137143
}
138144
};
@@ -714,6 +720,177 @@ const handleChargeDisputeCreated = async (dispute, event) => {
714720
}
715721
};
716722

723+
/**
724+
* @description Handle charge.dispute.funds_withdrawn event — debit ledger (dispute lost).
725+
* Triggered by Stripe when funds are actually withdrawn from the merchant bank account
726+
* because the dispute was lost (or accepted). At this point the customer kept their
727+
* meter units but Stripe has reclaimed the cash, so we MUST debit the ledger or we
728+
* lose money.
729+
*
730+
* Resolution path mirrors handleChargeRefunded:
731+
* 1. Retrieve the charge by `dispute.charge`.
732+
* 2. Read organizationId / stripeSessionId / packId from charge.metadata.
733+
* 3. If stripeSessionId is unresolved (absent or SENTINEL_PENDING), backfill via the
734+
* PaymentIntent (charge.metadata is a one-time snapshot taken at charge creation).
735+
* 4. Debit the ledger via BillingExtraService.refundPartial with a stable refId
736+
* 'dispute_<dispute.id>' — refundPartial's own idempotency on the refId guarantees
737+
* this is a no-op on Stripe redelivery, even outside the per-family event guard
738+
* (disputes do not fit the subscription/invoice ordering families, so we rely on
739+
* the ledger-layer idempotency exclusively).
740+
*
741+
* When organizationId / charge / pack cannot be resolved, emits
742+
* 'billing.refund.unresolved' for ops + logs a critical alert (mirrors handleChargeRefunded).
743+
* @param {Object} dispute - Stripe dispute object (data.object of charge.dispute.funds_withdrawn).
744+
* @param {Object} event - Full Stripe event (for traceability).
745+
* @returns {Promise<void>}
746+
*/
747+
// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
748+
const handleChargeDisputeFundsWithdrawn = async (dispute, event) => {
749+
const chargeId = dispute?.charge;
750+
const disputeId = dispute?.id;
751+
const amount = dispute?.amount ?? 0;
752+
753+
if (!chargeId || !disputeId || amount <= 0) {
754+
logger.error('[billing.webhook] dispute.funds_withdrawn missing required fields', {
755+
disputeId,
756+
chargeId,
757+
amount,
758+
eventId: event?.id,
759+
});
760+
try {
761+
billingEvents.emit('billing.refund.unresolved', { reason: 'dispute_missing_fields', disputeId, chargeId, amount });
762+
} catch (evtErr) {
763+
logger.error('[billing.webhook] billing.refund.unresolved listener error (non-fatal)', {
764+
error: evtErr?.message ?? String(evtErr),
765+
stack: evtErr?.stack,
766+
});
767+
}
768+
return;
769+
}
770+
771+
const stripe = getStripe();
772+
if (!stripe) {
773+
logger.error('[billing.webhook] dispute.funds_withdrawn — Stripe client unavailable, cannot resolve charge', {
774+
disputeId,
775+
chargeId,
776+
});
777+
return;
778+
}
779+
780+
let organizationId = null;
781+
let stripeSessionId = null;
782+
let packId = null;
783+
let paymentIntentId = null;
784+
785+
try {
786+
const charge = await stripe.charges.retrieve(chargeId);
787+
const meta = charge?.metadata ?? {};
788+
organizationId = meta.organizationId ?? null;
789+
stripeSessionId = meta.stripeSessionId ?? null;
790+
packId = meta.packId ?? null;
791+
paymentIntentId = charge?.payment_intent ?? null;
792+
} catch (err) {
793+
logger.error('[billing.webhook] dispute.funds_withdrawn charge fetch failed', {
794+
chargeId,
795+
disputeId,
796+
error: err?.message ?? String(err),
797+
stack: err?.stack,
798+
});
799+
// Surface to ops; do NOT throw — withIdempotency would dead-letter after 5 retries on a
800+
// permanently-broken Stripe lookup, but Stripe transient failures should still go through
801+
// the retry machinery, so we re-throw to let it count.
802+
throw err;
803+
}
804+
805+
// Backfill via PaymentIntent when charge metadata is missing or carries the sentinel.
806+
if ((isUnresolved(stripeSessionId) || !organizationId || !packId) && paymentIntentId) {
807+
try {
808+
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
809+
const piMeta = paymentIntent?.metadata ?? {};
810+
if (isUnresolved(stripeSessionId)) stripeSessionId = piMeta.stripeSessionId ?? stripeSessionId;
811+
if (!packId) packId = piMeta.packId ?? null;
812+
if (!organizationId || !mongoose.Types.ObjectId.isValid(organizationId)) {
813+
const piOrgId = piMeta.organizationId;
814+
if (piOrgId && mongoose.Types.ObjectId.isValid(piOrgId)) {
815+
organizationId = piOrgId;
816+
}
817+
}
818+
} catch (err) {
819+
logger.error('[billing.webhook] dispute.funds_withdrawn PI fetch failed', {
820+
paymentIntentId,
821+
disputeId,
822+
chargeId,
823+
error: err?.message ?? String(err),
824+
stack: err?.stack,
825+
});
826+
// PI fetch failure is non-fatal — we may still have what we need from charge.metadata.
827+
}
828+
}
829+
830+
// Could not resolve org / session — this charge is unrelated to billing extras (or metadata
831+
// was never propagated). Emit alert for manual reconciliation, do NOT crash.
832+
if (
833+
!organizationId ||
834+
!mongoose.Types.ObjectId.isValid(organizationId) ||
835+
isUnresolved(stripeSessionId)
836+
) {
837+
logger.error('[billing] dispute.funds_withdrawn unresolved — manual reconciliation required', {
838+
disputeId,
839+
chargeId,
840+
paymentIntentId,
841+
organizationId,
842+
stripeSessionId,
843+
amount,
844+
});
845+
try {
846+
billingEvents.emit('billing.refund.unresolved', {
847+
reason: 'dispute_unresolved',
848+
disputeId,
849+
chargeId,
850+
paymentIntentId,
851+
amount,
852+
});
853+
} catch (evtErr) {
854+
logger.error('[billing.webhook] billing.refund.unresolved listener error (non-fatal)', {
855+
error: evtErr?.message ?? String(evtErr),
856+
stack: evtErr?.stack,
857+
});
858+
}
859+
return;
860+
}
861+
862+
// Stable refId per dispute — refundPartial's ledger idempotency makes redelivery a no-op.
863+
// Disputes are independent of subscription/invoice cycles, so we deliberately skip the
864+
// per-family event-newer guard and rely on ledger-layer refId idempotency exclusively.
865+
const refId = `dispute_${disputeId}`;
866+
await BillingExtraService.refundPartial(organizationId, stripeSessionId, amount, packId, refId);
867+
868+
// Critical alert — money has actually left the account.
869+
logger.error('[billing] dispute lost — funds withdrawn, ledger debited', {
870+
disputeId,
871+
chargeId,
872+
organizationId,
873+
stripeSessionId,
874+
amount,
875+
eventId: event?.id,
876+
});
877+
878+
try {
879+
billingEvents.emit('billing.dispute.lost', {
880+
disputeId,
881+
chargeId,
882+
organizationId,
883+
stripeSessionId,
884+
amount,
885+
});
886+
} catch (evtErr) {
887+
logger.error('[billing.webhook] billing.dispute.lost listener error (non-fatal)', {
888+
error: evtErr?.message ?? String(evtErr),
889+
stack: evtErr?.stack,
890+
});
891+
}
892+
};
893+
717894
export default {
718895
withIdempotency,
719896
handleCheckoutSessionCompleted,
@@ -726,4 +903,5 @@ export default {
726903
handleChargeRefunded,
727904
handleCustomerDeleted,
728905
handleChargeDisputeCreated,
906+
handleChargeDisputeFundsWithdrawn,
729907
};

modules/billing/tests/billing.controller.unit.tests.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ describe('Billing webhook controller unit tests:', () => {
2525
handleInvoicePaymentFailed: jest.fn().mockResolvedValue(),
2626
handleInvoicePaymentSucceeded: jest.fn().mockResolvedValue(),
2727
handleChargeRefunded: jest.fn().mockResolvedValue(),
28+
handleCustomerDeleted: jest.fn().mockResolvedValue(),
29+
handleChargeDisputeCreated: jest.fn().mockResolvedValue(),
30+
handleChargeDisputeFundsWithdrawn: jest.fn().mockResolvedValue(),
2831
};
2932

3033
jest.unstable_mockModule('../services/billing.webhook.service.js', () => ({
@@ -178,6 +181,28 @@ describe('Billing webhook controller unit tests:', () => {
178181
expect(res.status).toHaveBeenCalledWith(200);
179182
});
180183

184+
test('should handle charge.dispute.funds_withdrawn event', async () => {
185+
jest.unstable_mockModule('../../../config/index.js', () => ({
186+
default: { stripe: { secretKey: 'sk_test_fw', webhookSecret: 'whsec_fw' } },
187+
}));
188+
189+
const eventData = { type: 'charge.dispute.funds_withdrawn', data: { object: { id: 'dp_fw_1', charge: 'ch_1', amount: 2900 } } };
190+
mockStripeInstance.webhooks.constructEvent.mockReturnValue(eventData);
191+
192+
const mod = await import('../controllers/billing.webhook.controller.js');
193+
BillingWebhookController = mod.default;
194+
195+
const req = { headers: { 'stripe-signature': 'sig_ok' }, body: 'raw' };
196+
await BillingWebhookController.handleWebhook(req, res);
197+
198+
expect(mockBillingWebhookService.handleChargeDisputeFundsWithdrawn).toHaveBeenCalledWith(
199+
{ id: 'dp_fw_1', charge: 'ch_1', amount: 2900 },
200+
eventData,
201+
);
202+
expect(mockBillingWebhookService.withIdempotency).toHaveBeenCalledWith(eventData, expect.any(Function));
203+
expect(res.status).toHaveBeenCalledWith(200);
204+
});
205+
181206
test('should return 200 for unknown event types', async () => {
182207
jest.unstable_mockModule('../../../config/index.js', () => ({
183208
default: { stripe: { secretKey: 'sk_test_6', webhookSecret: 'whsec_6' } },

0 commit comments

Comments
 (0)