handling when payout is canceled

This commit is contained in:
jackiettran
2026-01-08 18:12:58 -05:00
parent 8585633907
commit 0ea35e9d6f
2 changed files with 132 additions and 4 deletions

View File

@@ -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);

View File

@@ -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,
});