handling case where payout failed and webhook event not received

This commit is contained in:
jackiettran
2026-01-08 15:27:02 -05:00
parent 65b7574be2
commit 5248c3dc39
7 changed files with 1237 additions and 29 deletions

View File

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

View File

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