From 0ea35e9d6f9bc91163cb09d3e2a38fd2c680a8c5 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Thu, 8 Jan 2026 18:12:58 -0500 Subject: [PATCH] handling when payout is canceled --- backend/routes/stripeWebhooks.js | 8 ++ backend/services/stripeWebhookService.js | 128 ++++++++++++++++++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/backend/routes/stripeWebhooks.js b/backend/routes/stripeWebhooks.js index 4644a00..307a200 100644 --- a/backend/routes/stripeWebhooks.js +++ b/backend/routes/stripeWebhooks.js @@ -71,6 +71,14 @@ router.post("/", async (req, res) => { ); break; + case "payout.canceled": + // Payout was canceled before being deposited + await StripeWebhookService.handlePayoutCanceled( + event.data.object, + event.account + ); + break; + case "account.application.deauthorized": // Owner disconnected their Stripe account from our platform await StripeWebhookService.handleAccountDeauthorized(event.account); diff --git a/backend/services/stripeWebhookService.js b/backend/services/stripeWebhookService.js index 99e24a9..9c88b99 100644 --- a/backend/services/stripeWebhookService.js +++ b/backend/services/stripeWebhookService.js @@ -338,6 +338,81 @@ class StripeWebhookService { } } + /** + * Handle payout.canceled webhook event. + * Stripe can cancel payouts if: + * - They are manually canceled via Dashboard/API before processing + * - The connected account is deactivated + * - Risk review cancels the payout + * @param {Object} payout - The Stripe payout object + * @param {string} connectedAccountId - The connected account ID + * @returns {Object} - { processed, rentalsUpdated } + */ + static async handlePayoutCanceled(payout, connectedAccountId) { + logger.info("Processing payout.canceled webhook", { + payoutId: payout.id, + connectedAccountId, + }); + + if (!connectedAccountId) { + logger.warn("payout.canceled webhook missing connected account ID", { + payoutId: payout.id, + }); + return { processed: false, reason: "missing_account_id" }; + } + + try { + // Retrieve balance transactions to find associated transfers + const balanceTransactions = await stripe.balanceTransactions.list( + { + payout: payout.id, + type: "transfer", + limit: 100, + }, + { stripeAccount: connectedAccountId } + ); + + const transferIds = balanceTransactions.data + .map((bt) => bt.source) + .filter(Boolean); + + if (transferIds.length === 0) { + logger.info("No transfers found for canceled payout", { + payoutId: payout.id, + }); + return { processed: true, rentalsUpdated: 0 }; + } + + // Update all rentals associated with this payout + const [updatedCount] = await Rental.update( + { + bankDepositStatus: "canceled", + stripePayoutId: payout.id, + }, + { + where: { + stripeTransferId: { [Op.in]: transferIds }, + }, + } + ); + + logger.info("Updated rentals for canceled payout", { + payoutId: payout.id, + rentalsUpdated: updatedCount, + }); + + return { processed: true, rentalsUpdated: updatedCount }; + } catch (error) { + logger.error("Error processing payout.canceled webhook", { + payoutId: payout.id, + connectedAccountId, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + /** * Handle account.application.deauthorized webhook event. * Triggered when an owner disconnects their Stripe account from our platform. @@ -427,9 +502,9 @@ class StripeWebhookService { /** * Reconcile payout statuses for an owner by checking Stripe for actual status. - * This handles cases where payout.paid or payout.failed webhooks were missed. + * This handles cases where payout.paid, payout.failed, or payout.canceled webhooks were missed. * - * Checks both paid and failed payouts to ensure accurate status tracking. + * Checks paid, failed, and canceled payouts to ensure accurate status tracking. * * @param {string} ownerId - The owner's user ID * @returns {Object} - { reconciled, updated, failed, notificationsSent, errors } @@ -477,8 +552,8 @@ class StripeWebhookService { return results; } - // Fetch recent paid and failed payouts - const [paidPayouts, failedPayouts] = await Promise.all([ + // Fetch recent paid, failed, and canceled payouts + const [paidPayouts, failedPayouts, canceledPayouts] = await Promise.all([ stripe.payouts.list( { status: "paid", limit: 20 }, { stripeAccount: connectedAccountId } @@ -487,6 +562,10 @@ class StripeWebhookService { { status: "failed", limit: 20 }, { stripeAccount: connectedAccountId } ), + stripe.payouts.list( + { status: "canceled", limit: 20 }, + { stripeAccount: connectedAccountId } + ), ]); // Build a map of transfer IDs to failed payouts for quick lookup @@ -510,6 +589,27 @@ class StripeWebhookService { } } + // Build a map of transfer IDs to canceled payouts for quick lookup + const canceledPayoutTransferMap = new Map(); + for (const payout of canceledPayouts.data) { + try { + const balanceTransactions = await stripe.balanceTransactions.list( + { payout: payout.id, type: "transfer", limit: 100 }, + { stripeAccount: connectedAccountId } + ); + for (const bt of balanceTransactions.data) { + if (bt.source) { + canceledPayoutTransferMap.set(bt.source, payout); + } + } + } catch (btError) { + logger.warn("Error fetching balance transactions for canceled payout", { + payoutId: payout.id, + error: btError.message, + }); + } + } + const owner = rentalsToReconcile[0].owner; for (const rental of rentalsToReconcile) { @@ -566,6 +666,25 @@ class StripeWebhookService { continue; // Move to next rental } + // Check if this transfer is in a canceled payout + const canceledPayout = canceledPayoutTransferMap.get(rental.stripeTransferId); + + if (canceledPayout) { + await rental.update({ + bankDepositStatus: "canceled", + stripePayoutId: canceledPayout.id, + }); + + results.canceled = (results.canceled || 0) + 1; + + logger.info("Reconciled rental with canceled payout", { + rentalId: rental.id, + payoutId: canceledPayout.id, + }); + + continue; // Move to next rental + } + // Check for paid payout const transfer = await stripe.transfers.retrieve(rental.stripeTransferId); const matchingPaidPayout = paidPayouts.data.find( @@ -605,6 +724,7 @@ class StripeWebhookService { reconciled: results.reconciled, updated: results.updated, failed: results.failed, + canceled: results.canceled || 0, notificationsSent: results.notificationsSent, errors: results.errors.length, });