handling when payout is canceled
This commit is contained in:
@@ -71,6 +71,14 @@ router.post("/", async (req, res) => {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "payout.canceled":
|
||||||
|
// Payout was canceled before being deposited
|
||||||
|
await StripeWebhookService.handlePayoutCanceled(
|
||||||
|
event.data.object,
|
||||||
|
event.account
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case "account.application.deauthorized":
|
case "account.application.deauthorized":
|
||||||
// Owner disconnected their Stripe account from our platform
|
// Owner disconnected their Stripe account from our platform
|
||||||
await StripeWebhookService.handleAccountDeauthorized(event.account);
|
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.
|
* Handle account.application.deauthorized webhook event.
|
||||||
* Triggered when an owner disconnects their Stripe account from our platform.
|
* 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.
|
* 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
|
* @param {string} ownerId - The owner's user ID
|
||||||
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
|
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
|
||||||
@@ -477,8 +552,8 @@ class StripeWebhookService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent paid and failed payouts
|
// Fetch recent paid, failed, and canceled payouts
|
||||||
const [paidPayouts, failedPayouts] = await Promise.all([
|
const [paidPayouts, failedPayouts, canceledPayouts] = await Promise.all([
|
||||||
stripe.payouts.list(
|
stripe.payouts.list(
|
||||||
{ status: "paid", limit: 20 },
|
{ status: "paid", limit: 20 },
|
||||||
{ stripeAccount: connectedAccountId }
|
{ stripeAccount: connectedAccountId }
|
||||||
@@ -487,6 +562,10 @@ class StripeWebhookService {
|
|||||||
{ status: "failed", limit: 20 },
|
{ status: "failed", limit: 20 },
|
||||||
{ stripeAccount: connectedAccountId }
|
{ stripeAccount: connectedAccountId }
|
||||||
),
|
),
|
||||||
|
stripe.payouts.list(
|
||||||
|
{ status: "canceled", limit: 20 },
|
||||||
|
{ stripeAccount: connectedAccountId }
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build a map of transfer IDs to failed payouts for quick lookup
|
// 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;
|
const owner = rentalsToReconcile[0].owner;
|
||||||
|
|
||||||
for (const rental of rentalsToReconcile) {
|
for (const rental of rentalsToReconcile) {
|
||||||
@@ -566,6 +666,25 @@ class StripeWebhookService {
|
|||||||
continue; // Move to next rental
|
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
|
// Check for paid payout
|
||||||
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
|
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
|
||||||
const matchingPaidPayout = paidPayouts.data.find(
|
const matchingPaidPayout = paidPayouts.data.find(
|
||||||
@@ -605,6 +724,7 @@ class StripeWebhookService {
|
|||||||
reconciled: results.reconciled,
|
reconciled: results.reconciled,
|
||||||
updated: results.updated,
|
updated: results.updated,
|
||||||
failed: results.failed,
|
failed: results.failed,
|
||||||
|
canceled: results.canceled || 0,
|
||||||
notificationsSent: results.notificationsSent,
|
notificationsSent: results.notificationsSent,
|
||||||
errors: results.errors.length,
|
errors: results.errors.length,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user