@@ -565,6 +565,155 @@ const handleChargeRefunded = async (charge) => {
565565 }
566566} ;
567567
568+ /**
569+ * @description Handle customer.deleted event — null out Stripe customer/subscription refs.
570+ * Triggered when an admin deletes a customer in the Stripe dashboard. If we keep the
571+ * stale stripeCustomerId in our DB, the next _ensureStripeCustomer / checkout call
572+ * will hit "No such customer" and crash the user's checkout flow.
573+ *
574+ * Conservative recovery:
575+ * 1. Find the subscription by Stripe customer id.
576+ * 2. Null out stripeCustomerId + stripeSubscriptionId, force plan='free' / status='canceled'.
577+ * 3. Sync organization plan to free.
578+ * 4. Force meter rotation so the org no longer carries the paid quota snapshot.
579+ *
580+ * No-op when no matching subscription exists in our DB (deletion of a customer we never
581+ * provisioned, or already cleaned up).
582+ * @param {Object } customer - Stripe customer object (data.object of customer.deleted event).
583+ * @param {Object } event - Full Stripe event (for ordering / event-newer guard).
584+ * @returns {Promise<void> }
585+ */
586+ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
587+ const handleCustomerDeleted = async ( customer , event ) => {
588+ const stripeCustomerId = customer ?. id ;
589+ if ( ! stripeCustomerId ) return ;
590+
591+ const existing = await SubscriptionRepository . findByStripeCustomerId ( stripeCustomerId ) ;
592+ if ( ! existing ) return ;
593+
594+ const updated = await SubscriptionRepository . updateIfEventNewer (
595+ String ( existing . _id ) ,
596+ event . created ,
597+ event . id ,
598+ {
599+ stripeCustomerId : null ,
600+ stripeSubscriptionId : null ,
601+ plan : 'free' ,
602+ status : 'canceled' ,
603+ } ,
604+ 'subscription' ,
605+ ) ;
606+ if ( ! updated ) {
607+ logger . info ( '[billing.webhook] skipped stale event' , { eventId : event . id , type : event . type } ) ;
608+ return ;
609+ }
610+
611+ const organizationId = String ( existing . organization ?. _id || existing . organization ) ;
612+ await syncOrganizationPlan ( organizationId , 'free' ) ;
613+
614+ // Force meter reset so the cancelled org no longer retains a paid-plan snapshot.
615+ try {
616+ await BillingResetService . forceRotateForPlanChange ( organizationId , { preserveUsage : false } ) ;
617+ } catch ( err ) {
618+ logger . error ( '[billing.webhook] forceRotateForPlanChange on customer.deleted failed (non-fatal)' , {
619+ organizationId,
620+ error : err ?. message ?? String ( err ) ,
621+ stack : err ?. stack ,
622+ } ) ;
623+ }
624+ } ;
625+
626+ /**
627+ * @description Handle charge.dispute.created event — log + emit (no auto-debit).
628+ * Triggered when a cardholder files a chargeback. Stripe will eventually debit the
629+ * merchant bank account, but disputes are often won by the merchant (~50% are user
630+ * error / "I don't recognise this charge"). Aggressive auto-debit on dispute opening
631+ * would punish customers who are filing legitimate disputes that we eventually win.
632+ *
633+ * Conservative policy: emit a `billing.dispute.opened` event for downstream listeners
634+ * (admin notifications) and log a critical alert. Real claw-back happens later via
635+ * `charge.dispute.funds_withdrawn` (when the dispute is lost) or `charge.refunded`
636+ * (when we choose to refund), both of which already debit the ledger via
637+ * handleChargeRefunded / refundPartial.
638+ *
639+ * Resolves organizationId via charge metadata first, then falls back to the
640+ * PaymentIntent metadata patched by handleCheckoutPaymentCompleted.
641+ * @param {Object } dispute - Stripe dispute object (data.object of charge.dispute.created).
642+ * @param {Object } event - Full Stripe event.
643+ * @returns {Promise<void> }
644+ */
645+ // biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
646+ const handleChargeDisputeCreated = async ( dispute , event ) => {
647+ const chargeId = dispute ?. charge ;
648+ const disputeId = dispute ?. id ;
649+ const amount = dispute ?. amount ?? 0 ;
650+ const reason = dispute ?. reason ?? null ;
651+
652+ let organizationId = null ;
653+ let stripeSessionId = null ;
654+ let paymentIntentId = null ;
655+
656+ // Best-effort: fetch charge → resolve org via charge or PaymentIntent metadata
657+ // (mirrors the resolver pattern in handleChargeRefunded).
658+ if ( chargeId ) {
659+ const stripe = getStripe ( ) ;
660+ if ( stripe ) {
661+ try {
662+ const charge = await stripe . charges . retrieve ( chargeId ) ;
663+ const meta = charge ?. metadata ?? { } ;
664+ organizationId = meta . organizationId ?? null ;
665+ stripeSessionId = meta . stripeSessionId ?? null ;
666+ paymentIntentId = charge ?. payment_intent ?? null ;
667+
668+ if ( ( ! organizationId || ! mongoose . Types . ObjectId . isValid ( organizationId ) ) && paymentIntentId ) {
669+ const paymentIntent = await stripe . paymentIntents . retrieve ( paymentIntentId ) ;
670+ const piMeta = paymentIntent ?. metadata ?? { } ;
671+ if ( ! organizationId && piMeta . organizationId && mongoose . Types . ObjectId . isValid ( piMeta . organizationId ) ) {
672+ organizationId = piMeta . organizationId ;
673+ }
674+ if ( ! stripeSessionId ) stripeSessionId = piMeta . stripeSessionId ?? null ;
675+ }
676+ } catch ( err ) {
677+ logger . error ( '[billing.webhook] dispute charge/PI fetch failed' , {
678+ chargeId,
679+ disputeId,
680+ error : err ?. message ?? String ( err ) ,
681+ stack : err ?. stack ,
682+ } ) ;
683+ }
684+ }
685+ }
686+
687+ // Critical alert — ops MUST react manually (decide to fight or accept the dispute).
688+ // No auto-debit: real claw-back happens on charge.dispute.funds_withdrawn / charge.refunded.
689+ logger . error ( '[billing] dispute opened — manual review required' , {
690+ disputeId,
691+ chargeId,
692+ organizationId,
693+ stripeSessionId,
694+ amount,
695+ reason,
696+ eventId : event ?. id ,
697+ } ) ;
698+
699+ try {
700+ billingEvents . emit ( 'billing.dispute.opened' , {
701+ disputeId,
702+ chargeId,
703+ organizationId,
704+ stripeSessionId,
705+ amount,
706+ reason,
707+ } ) ;
708+ } catch ( evtErr ) {
709+ // Listener errors must not break webhook processing — log for traceability.
710+ logger . error ( '[billing.webhook] billing.dispute.opened listener error (non-fatal)' , {
711+ error : evtErr ?. message ?? String ( evtErr ) ,
712+ stack : evtErr ?. stack ,
713+ } ) ;
714+ }
715+ } ;
716+
568717export default {
569718 withIdempotency,
570719 handleCheckoutSessionCompleted,
@@ -575,4 +724,6 @@ export default {
575724 handleInvoicePaymentFailed,
576725 handleInvoicePaymentSucceeded,
577726 handleChargeRefunded,
727+ handleCustomerDeleted,
728+ handleChargeDisputeCreated,
578729} ;
0 commit comments