handling case where payout failed and webhook event not received
This commit is contained in:
@@ -116,6 +116,61 @@ class PaymentEmailService {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send payout failed notification to owner
|
||||
* @param {string} ownerEmail - Owner's email address
|
||||
* @param {Object} params - Email parameters
|
||||
* @param {string} params.ownerName - Owner's name
|
||||
* @param {number} params.payoutAmount - Payout amount in dollars
|
||||
* @param {string} params.failureMessage - User-friendly failure message
|
||||
* @param {string} params.actionRequired - Action the owner needs to take
|
||||
* @param {string} params.failureCode - The Stripe failure code
|
||||
* @param {boolean} params.requiresBankUpdate - Whether bank account update is needed
|
||||
* @param {string} params.payoutSettingsUrl - URL to payout settings
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendPayoutFailedNotification(ownerEmail, params) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
ownerName,
|
||||
payoutAmount,
|
||||
failureMessage,
|
||||
actionRequired,
|
||||
failureCode,
|
||||
requiresBankUpdate,
|
||||
payoutSettingsUrl,
|
||||
} = params;
|
||||
|
||||
const variables = {
|
||||
ownerName: ownerName || "there",
|
||||
payoutAmount: payoutAmount?.toFixed(2) || "0.00",
|
||||
failureMessage: failureMessage || "There was an issue with your payout.",
|
||||
actionRequired: actionRequired || "Please check your bank account details.",
|
||||
failureCode: failureCode || "unknown",
|
||||
requiresBankUpdate: requiresBankUpdate || false,
|
||||
payoutSettingsUrl: payoutSettingsUrl || process.env.FRONTEND_URL + "/settings/payouts",
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"payoutFailedToOwner",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
ownerEmail,
|
||||
"Action Required: Payout Issue - Village Share",
|
||||
htmlContent
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send payout failed notification:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PaymentEmailService;
|
||||
|
||||
@@ -3,6 +3,8 @@ const { User, Rental, Item } = require("../models");
|
||||
const PayoutService = require("./payoutService");
|
||||
const logger = require("../utils/logger");
|
||||
const { Op } = require("sequelize");
|
||||
const { getPayoutFailureMessage } = require("../utils/payoutErrors");
|
||||
const emailServices = require("./email");
|
||||
|
||||
class StripeWebhookService {
|
||||
/**
|
||||
@@ -219,10 +221,10 @@ class StripeWebhookService {
|
||||
|
||||
/**
|
||||
* Handle payout.failed webhook event.
|
||||
* Updates rentals when bank deposit fails.
|
||||
* Updates rentals when bank deposit fails and notifies the owner.
|
||||
* @param {Object} payout - The Stripe payout object
|
||||
* @param {string} connectedAccountId - The connected account ID (from event.account)
|
||||
* @returns {Object} - { processed, rentalsUpdated }
|
||||
* @returns {Object} - { processed, rentalsUpdated, notificationSent }
|
||||
*/
|
||||
static async handlePayoutFailed(payout, connectedAccountId) {
|
||||
logger.info("Processing payout.failed webhook", {
|
||||
@@ -259,7 +261,7 @@ class StripeWebhookService {
|
||||
payoutId: payout.id,
|
||||
connectedAccountId,
|
||||
});
|
||||
return { processed: true, rentalsUpdated: 0 };
|
||||
return { processed: true, rentalsUpdated: 0, notificationSent: false };
|
||||
}
|
||||
|
||||
// Update all rentals with matching stripeTransferId
|
||||
@@ -282,7 +284,49 @@ class StripeWebhookService {
|
||||
failureCode: payout.failure_code,
|
||||
});
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount };
|
||||
// Find owner and send notification
|
||||
const user = await User.findOne({
|
||||
where: { stripeConnectedAccountId: connectedAccountId },
|
||||
});
|
||||
|
||||
let notificationSent = false;
|
||||
|
||||
if (user) {
|
||||
// Get user-friendly message
|
||||
const failureInfo = getPayoutFailureMessage(payout.failure_code);
|
||||
|
||||
try {
|
||||
await emailServices.payment.sendPayoutFailedNotification(user.email, {
|
||||
ownerName: user.firstName || user.name,
|
||||
payoutAmount: payout.amount / 100,
|
||||
failureMessage: failureInfo.message,
|
||||
actionRequired: failureInfo.action,
|
||||
failureCode: payout.failure_code || "unknown",
|
||||
requiresBankUpdate: failureInfo.requiresBankUpdate,
|
||||
});
|
||||
|
||||
notificationSent = true;
|
||||
|
||||
logger.info("Sent payout failed notification to owner", {
|
||||
userId: user.id,
|
||||
payoutId: payout.id,
|
||||
failureCode: payout.failure_code,
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send payout failed notification", {
|
||||
userId: user.id,
|
||||
payoutId: payout.id,
|
||||
error: emailError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.warn("No user found for connected account", {
|
||||
connectedAccountId,
|
||||
payoutId: payout.id,
|
||||
});
|
||||
}
|
||||
|
||||
return { processed: true, rentalsUpdated: updatedCount, notificationSent };
|
||||
} catch (error) {
|
||||
logger.error("Error processing payout.failed webhook", {
|
||||
payoutId: payout.id,
|
||||
@@ -296,19 +340,19 @@ class StripeWebhookService {
|
||||
|
||||
/**
|
||||
* Reconcile payout statuses for an owner by checking Stripe for actual status.
|
||||
* This handles cases where the payout.paid webhook was missed or failed.
|
||||
* This handles cases where payout.paid or payout.failed webhooks were missed.
|
||||
*
|
||||
* Simplified approach: Since Stripe automatic payouts sweep the entire available
|
||||
* balance, if there's been a paid payout after our transfer was created, our
|
||||
* funds were included.
|
||||
* Checks both paid and failed payouts to ensure accurate status tracking.
|
||||
*
|
||||
* @param {string} ownerId - The owner's user ID
|
||||
* @returns {Object} - { reconciled, updated, errors }
|
||||
* @returns {Object} - { reconciled, updated, failed, notificationsSent, errors }
|
||||
*/
|
||||
static async reconcilePayoutStatuses(ownerId) {
|
||||
const results = {
|
||||
reconciled: 0,
|
||||
updated: 0,
|
||||
failed: 0,
|
||||
notificationsSent: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
@@ -325,7 +369,7 @@ class StripeWebhookService {
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["stripeConnectedAccountId"],
|
||||
attributes: ["id", "email", "firstName", "name", "stripeConnectedAccountId"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -346,42 +390,114 @@ class StripeWebhookService {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Fetch recent paid payouts once for all rentals
|
||||
const paidPayouts = await stripe.payouts.list(
|
||||
{ status: "paid", limit: 20 },
|
||||
{ stripeAccount: connectedAccountId }
|
||||
);
|
||||
// Fetch recent paid and failed payouts
|
||||
const [paidPayouts, failedPayouts] = await Promise.all([
|
||||
stripe.payouts.list(
|
||||
{ status: "paid", limit: 20 },
|
||||
{ stripeAccount: connectedAccountId }
|
||||
),
|
||||
stripe.payouts.list(
|
||||
{ status: "failed", limit: 20 },
|
||||
{ stripeAccount: connectedAccountId }
|
||||
),
|
||||
]);
|
||||
|
||||
if (paidPayouts.data.length === 0) {
|
||||
logger.info("No paid payouts found for connected account", { connectedAccountId });
|
||||
return results;
|
||||
// Build a map of transfer IDs to failed payouts for quick lookup
|
||||
const failedPayoutTransferMap = new Map();
|
||||
for (const payout of failedPayouts.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) {
|
||||
failedPayoutTransferMap.set(bt.source, payout);
|
||||
}
|
||||
}
|
||||
} catch (btError) {
|
||||
logger.warn("Error fetching balance transactions for failed payout", {
|
||||
payoutId: payout.id,
|
||||
error: btError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const owner = rentalsToReconcile[0].owner;
|
||||
|
||||
for (const rental of rentalsToReconcile) {
|
||||
results.reconciled++;
|
||||
|
||||
try {
|
||||
// Get the transfer to find when it was created
|
||||
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
|
||||
// First check if this transfer is in a failed payout
|
||||
const failedPayout = failedPayoutTransferMap.get(rental.stripeTransferId);
|
||||
|
||||
// Find a payout that arrived after the transfer was created
|
||||
const matchingPayout = paidPayouts.data.find(
|
||||
if (failedPayout) {
|
||||
// Update rental with failed status
|
||||
await rental.update({
|
||||
bankDepositStatus: "failed",
|
||||
stripePayoutId: failedPayout.id,
|
||||
bankDepositFailureCode: failedPayout.failure_code || "unknown",
|
||||
});
|
||||
|
||||
results.failed++;
|
||||
|
||||
logger.warn("Reconciled rental with failed payout", {
|
||||
rentalId: rental.id,
|
||||
payoutId: failedPayout.id,
|
||||
failureCode: failedPayout.failure_code,
|
||||
});
|
||||
|
||||
// Send failure notification
|
||||
if (owner?.email) {
|
||||
try {
|
||||
const failureInfo = getPayoutFailureMessage(failedPayout.failure_code);
|
||||
await emailServices.payment.sendPayoutFailedNotification(owner.email, {
|
||||
ownerName: owner.firstName || owner.name,
|
||||
payoutAmount: failedPayout.amount / 100,
|
||||
failureMessage: failureInfo.message,
|
||||
actionRequired: failureInfo.action,
|
||||
failureCode: failedPayout.failure_code || "unknown",
|
||||
requiresBankUpdate: failureInfo.requiresBankUpdate,
|
||||
});
|
||||
results.notificationsSent++;
|
||||
|
||||
logger.info("Sent reconciled payout failure notification", {
|
||||
userId: owner.id,
|
||||
rentalId: rental.id,
|
||||
payoutId: failedPayout.id,
|
||||
});
|
||||
} catch (emailError) {
|
||||
logger.error("Failed to send reconciled payout failure notification", {
|
||||
userId: owner.id,
|
||||
rentalId: rental.id,
|
||||
error: emailError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
continue; // Move to next rental
|
||||
}
|
||||
|
||||
// Check for paid payout
|
||||
const transfer = await stripe.transfers.retrieve(rental.stripeTransferId);
|
||||
const matchingPaidPayout = paidPayouts.data.find(
|
||||
(payout) => payout.arrival_date >= transfer.created
|
||||
);
|
||||
|
||||
if (matchingPayout) {
|
||||
if (matchingPaidPayout) {
|
||||
await rental.update({
|
||||
bankDepositStatus: "paid",
|
||||
bankDepositAt: new Date(matchingPayout.arrival_date * 1000),
|
||||
stripePayoutId: matchingPayout.id,
|
||||
bankDepositAt: new Date(matchingPaidPayout.arrival_date * 1000),
|
||||
stripePayoutId: matchingPaidPayout.id,
|
||||
});
|
||||
|
||||
results.updated++;
|
||||
|
||||
logger.info("Reconciled rental payout status", {
|
||||
logger.info("Reconciled rental payout status to paid", {
|
||||
rentalId: rental.id,
|
||||
payoutId: matchingPayout.id,
|
||||
arrivalDate: matchingPayout.arrival_date,
|
||||
payoutId: matchingPaidPayout.id,
|
||||
arrivalDate: matchingPaidPayout.arrival_date,
|
||||
});
|
||||
}
|
||||
} catch (rentalError) {
|
||||
@@ -401,6 +517,8 @@ class StripeWebhookService {
|
||||
ownerId,
|
||||
reconciled: results.reconciled,
|
||||
updated: results.updated,
|
||||
failed: results.failed,
|
||||
notificationsSent: results.notificationsSent,
|
||||
errors: results.errors.length,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user