@@ -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
94105const withIdempotency = async ( event , handler ) => {
95106 const eventId = event . id ;
96107 const eventType = event . type ;
97108
98- // Atomically claim the event — only 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-enter — see 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+
717894export default {
718895 withIdempotency,
719896 handleCheckoutSessionCompleted,
@@ -726,4 +903,5 @@ export default {
726903 handleChargeRefunded,
727904 handleCustomerDeleted,
728905 handleChargeDisputeCreated,
906+ handleChargeDisputeFundsWithdrawn,
729907} ;
0 commit comments