@@ -12,6 +12,19 @@ import OrganizationRepository from '../../organizations/repositories/organizatio
1212import BillingExtraService from './billing.extra.service.js' ;
1313import BillingResetService from './billing.reset.service.js' ;
1414import billingEvents from '../lib/events.js' ;
15+ import { SENTINEL_PENDING } from '../lib/billing.constants.js' ;
16+
17+ /**
18+ * Treats a stripeSessionId as "unresolved" when absent, empty, or still the
19+ * creation-time sentinel placeholder written before Stripe assigned a real cs_* id.
20+ * Stripe Charge metadata is a one-time snapshot copied at charge creation — later
21+ * PaymentIntent metadata patches do NOT propagate back, so charge.metadata.stripeSessionId
22+ * may permanently carry SENTINEL_PENDING for any charge created during the brief window
23+ * between session.create and checkout.session.completed.
24+ * @param {string|undefined } id
25+ * @returns {boolean }
26+ */
27+ const isUnresolved = ( id ) => ! id || id === SENTINEL_PENDING ;
1528
1629/**
1730 * Maximum number of handler execution attempts before an event is dead-lettered.
@@ -112,14 +125,14 @@ const withIdempotency = async (event, handler) => {
112125 }
113126
114127 // Rollback claim so Stripe can retry on a fresh delivery.
115- try {
116- await ProcessedStripeEventRepository . deleteByEventId ( eventId ) ;
117- } catch ( rollbackErr ) {
128+ // Swallow rollback errors so the original handler error is always propagated.
129+ await ProcessedStripeEventRepository . deleteByEventId ( event . id ) . catch ( ( rollbackErr ) => {
118130 logger . error ( '[billing.webhook] rollback deleteByEventId failed — event may be stuck' , {
119- eventId,
120- error : rollbackErr ?. message ?? rollbackErr ,
131+ eventId : event . id ,
132+ error : rollbackErr ?. message ?? String ( rollbackErr ) ,
133+ stack : rollbackErr ?. stack ,
121134 } ) ;
122- }
135+ } ) ;
123136 throw err ;
124137 }
125138} ;
@@ -185,6 +198,11 @@ const handleCheckoutCompleted = async (session) => {
185198 * @description Handle checkout.session.completed for mode='payment' — credit extras pack.
186199 * Extracts organizationId, packId, kind from session metadata.
187200 * Skips silently if payment_status !== 'paid', kind !== 'extras', or metadata is incomplete.
201+ * Backfills PaymentIntent metadata with the real cs_* session ID so that
202+ * charge.refunded events can correlate the charge back to the correct ledger entry.
203+ * At session creation time stripeSessionId is set to SENTINEL_PENDING (Stripe forbids
204+ * self-reference). Charge.metadata is a one-time snapshot, so the PI patch is best-effort;
205+ * handleChargeRefunded has a backfill resolver as a secondary defence.
188206 * @param {Object } session - Stripe checkout session object (mode='payment')
189207 * @returns {Promise<void> }
190208 */
@@ -203,7 +221,7 @@ const handleCheckoutPaymentCompleted = async (session) => {
203221
204222 // Backfill PaymentIntent metadata with the real session ID so that charge.refunded
205223 // events can correlate the charge back to this ledger entry.
206- // At session.create time stripeSessionId was set to '__pending__' (Stripe forbids
224+ // At session.create time stripeSessionId was set to SENTINEL_PENDING (Stripe forbids
207225 // self-reference). Propagating the real cs_* ID here ensures charge.metadata carries
208226 // it when a refund is issued later.
209227 if ( paymentIntentId ) {
@@ -215,12 +233,16 @@ const handleCheckoutPaymentCompleted = async (session) => {
215233 organizationId,
216234 packId,
217235 kind : 'extras' ,
218- stripeSessionId, // real cs_* ID
236+ stripeSessionId, // real cs_* ID (replaces SENTINEL_PENDING)
219237 } ,
220238 } ) ;
221239 } catch ( err ) {
222- // Log but don't fail — refund correlation may need fallback path
223- console . warn ( '[billing.webhook] PaymentIntent metadata update failed:' , err . message ) ;
240+ // Log but don't fail — refund correlation may use the backfill resolver path
241+ logger . warn ( '[billing.webhook] PaymentIntent metadata update failed' , {
242+ paymentIntentId,
243+ error : err ?. message ?? String ( err ) ,
244+ stack : err ?. stack ,
245+ } ) ;
224246 }
225247 }
226248 }
@@ -289,7 +311,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
289311 } ) ;
290312 } catch ( evtErr ) {
291313 // Listener errors must not disrupt webhook processing — log for traceability
292- console . error ( '[billing.webhook] plan.changed listener error (non-fatal):' , evtErr ?. message ?? evtErr ) ;
314+ logger . error ( '[billing.webhook] plan.changed listener error (non-fatal)' , {
315+ error : evtErr ?. message ?? String ( evtErr ) ,
316+ stack : evtErr ?. stack ,
317+ } ) ;
293318 }
294319
295320 // Plan switch mid-cycle = refresh the active week snapshot to the new plan.
@@ -307,10 +332,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
307332 await BillingResetService . forceRotateForPlanChange ( organizationId , { preserveUsage : true } ) ;
308333 } catch ( err ) {
309334 planChangeResetTriggered = false ;
310- console . error (
311- '[billing.webhook] forceRotateForPlanChange failed, falling back to resetWeek:' ,
312- err ?. message ?? err ,
313- ) ;
335+ logger . error ( '[billing.webhook] forceRotateForPlanChange failed, falling back to resetWeek' , {
336+ error : err ?. message ?? String ( err ) ,
337+ stack : err ?. stack ,
338+ } ) ;
314339 }
315340 }
316341 }
@@ -328,7 +353,10 @@ const handleSubscriptionUpdated = async (subscription, event) => {
328353 await BillingResetService . resetWeek ( organizationId , newPeriodStart ) ;
329354 } catch ( err ) {
330355 // Log for monitoring — not thrown so webhook processing continues
331- console . error ( '[billing.webhook] resetWeek failed (non-fatal):' , err ?. message ?? err ) ;
356+ logger . error ( '[billing.webhook] resetWeek failed (non-fatal)' , {
357+ error : err ?. message ?? String ( err ) ,
358+ stack : err ?. stack ,
359+ } ) ;
332360 }
333361 }
334362} ;
@@ -360,7 +388,10 @@ const handleSubscriptionDeleted = async (subscription, event) => {
360388 await BillingResetService . forceRotateForPlanChange ( organizationId , { preserveUsage : false } ) ;
361389 } catch ( err ) {
362390 // Log for monitoring — not thrown so webhook processing continues
363- console . error ( '[billing.webhook] forceRotateForPlanChange on cancel failed (non-fatal):' , err ?. message ?? err ) ;
391+ logger . error ( '[billing.webhook] forceRotateForPlanChange on cancel failed (non-fatal)' , {
392+ error : err ?. message ?? String ( err ) ,
393+ stack : err ?. stack ,
394+ } ) ;
364395 }
365396} ;
366397
@@ -398,7 +429,10 @@ const handleInvoicePaymentFailed = async (invoice, event) => {
398429 billingEvents . emit ( 'payment.failed' , { organizationId } ) ;
399430 } catch ( evtErr ) {
400431 // Listener errors must not disrupt webhook processing — log for traceability
401- console . error ( '[billing.webhook] payment.failed listener error (non-fatal):' , evtErr ?. message ?? evtErr ) ;
432+ logger . error ( '[billing.webhook] payment.failed listener error (non-fatal)' , {
433+ error : evtErr ?. message ?? String ( evtErr ) ,
434+ stack : evtErr ?. stack ,
435+ } ) ;
402436 }
403437} ;
404438
@@ -452,6 +486,12 @@ const handleInvoicePaymentSucceeded = async (invoice, event) => {
452486 * Each refund's rf_ id is used as the idempotency key, making webhook replay safe.
453487 * Individual entries are silently skipped when: metadata is incomplete, refund amount
454488 * is absent/zero, or the refund object has no id.
489+ *
490+ * SENTINEL handling: at session.create time stripeSessionId is set to SENTINEL_PENDING
491+ * ('__pending__'). Stripe Charge metadata is a one-time snapshot — even though
492+ * checkout.session.completed patches the PaymentIntent with the real cs_* id,
493+ * charge.metadata.stripeSessionId may permanently carry SENTINEL_PENDING.
494+ * Both absent AND sentinel values trigger the PI backfill resolver path.
455495 * @param {Object } charge - Stripe charge object
456496 * @returns {Promise<void> }
457497 */
@@ -466,8 +506,10 @@ const handleChargeRefunded = async (charge) => {
466506
467507 if ( ! organizationId || ! mongoose . Types . ObjectId . isValid ( organizationId ) ) return ;
468508
469- // Backfill resolver: if stripeSessionId is missing, fetch the PaymentIntent to find it.
470- if ( ! stripeSessionId && paymentIntentId ) {
509+ // Backfill resolver: if stripeSessionId is missing OR carries SENTINEL_PENDING,
510+ // fetch the PaymentIntent to find the real session ID patched by checkout.session.completed.
511+ // isUnresolved() treats both falsy values and the sentinel string as "unresolved".
512+ if ( isUnresolved ( stripeSessionId ) && paymentIntentId ) {
471513 const stripe = getStripe ( ) ;
472514 if ( stripe ) {
473515 try {
@@ -482,12 +524,17 @@ const handleChargeRefunded = async (charge) => {
482524 }
483525 }
484526 } catch ( err ) {
485- logger . error ( '[billing] refund PI fetch failed' , { chargeId : charge . id , paymentIntentId, error : err ?. message } ) ;
527+ logger . error ( '[billing] refund PI fetch failed' , {
528+ chargeId : charge . id ,
529+ paymentIntentId,
530+ error : err ?. message ?? String ( err ) ,
531+ stack : err ?. stack ,
532+ } ) ;
486533 }
487534 }
488535 }
489536
490- if ( ! stripeSessionId ) {
537+ if ( isUnresolved ( stripeSessionId ) ) {
491538 // Could not resolve stripeSessionId from charge or PaymentIntent metadata.
492539 // Emit alert event and log for manual reconciliation.
493540 const refundAmount = charge . refunds ?. data ?. reduce ( ( sum , r ) => sum + ( r . amount ?? 0 ) , 0 ) ?? 0 ;
@@ -499,7 +546,10 @@ const handleChargeRefunded = async (charge) => {
499546 try {
500547 billingEvents . emit ( 'billing.refund.unresolved' , { chargeId : charge . id , paymentIntentId, refundAmount } ) ;
501548 } catch ( evtErr ) {
502- console . error ( '[billing.webhook] billing.refund.unresolved listener error (non-fatal):' , evtErr ?. message ?? evtErr ) ;
549+ logger . error ( '[billing.webhook] billing.refund.unresolved listener error (non-fatal)' , {
550+ error : evtErr ?. message ?? String ( evtErr ) ,
551+ stack : evtErr ?. stack ,
552+ } ) ;
503553 }
504554 return ;
505555 }
0 commit comments