handling when payout is canceled
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user