diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index a27276a..59d5af2 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -1111,7 +1111,18 @@ router.post("/cost-preview", authenticateToken, async (req, res) => {
// Get earnings status for owner's rentals
router.get("/earnings/status", authenticateToken, async (req, res, next) => {
+ const reqLogger = logger.withRequestId(req.id);
+
try {
+ // Trigger payout reconciliation in background (non-blocking)
+ // This catches any missed payout.paid or payout.failed webhooks
+ StripeWebhookService.reconcilePayoutStatuses(req.user.id).catch((err) => {
+ reqLogger.error("Background payout reconciliation failed", {
+ error: err.message,
+ userId: req.user.id,
+ });
+ });
+
const ownerRentals = await Rental.findAll({
where: {
ownerId: req.user.id,
@@ -1125,6 +1136,9 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
"payoutStatus",
"payoutProcessedAt",
"stripeTransferId",
+ "bankDepositStatus",
+ "bankDepositAt",
+ "bankDepositFailureCode",
],
include: [{ model: Item, as: "item", attributes: ["name"] }],
order: [["createdAt", "DESC"]],
@@ -1132,7 +1146,6 @@ router.get("/earnings/status", authenticateToken, async (req, res, next) => {
res.json(ownerRentals);
} catch (error) {
- const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Error getting earnings status", {
error: error.message,
stack: error.stack,
diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js
index b29e902..7f45737 100644
--- a/backend/services/email/domain/PaymentEmailService.js
+++ b/backend/services/email/domain/PaymentEmailService.js
@@ -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;
diff --git a/backend/services/stripeWebhookService.js b/backend/services/stripeWebhookService.js
index 740b537..2a04e96 100644
--- a/backend/services/stripeWebhookService.js
+++ b/backend/services/stripeWebhookService.js
@@ -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,
});
diff --git a/backend/templates/emails/payoutFailedToOwner.html b/backend/templates/emails/payoutFailedToOwner.html
new file mode 100644
index 0000000..7358c44
--- /dev/null
+++ b/backend/templates/emails/payoutFailedToOwner.html
@@ -0,0 +1,366 @@
+
+
+
+
+
+
+ Payout Issue - Village Share
+
+
+
+
+
+
+
+
Hi {{ownerName}},
+
+
+ We encountered an issue depositing your earnings to your bank account.
+ Don't worry - your funds are safe and we'll help you resolve this.
+
+
+
+
Pending Payout
+
${{payoutAmount}}
+
Action required to receive funds
+
+
+
+
What happened:
+
{{failureMessage}}
+
What to do:
+
{{actionRequired}}
+
+
+
Payout Details
+
+
+ | Amount |
+ ${{payoutAmount}} |
+
+
+ | Status |
+
+ Failed - Action Required
+ |
+
+
+ | Failure Reason |
+ {{failureCode}} |
+
+
+
+ {{#if requiresBankUpdate}}
+
+ {{/if}}
+
+
+
What happens next?
+
+ Once you resolve this issue, your payout will be retried
+ automatically. If you need assistance, please contact our support
+ team.
+
+
+
+
+ We apologize for any inconvenience. Your earnings are safe and will be
+ deposited as soon as the issue is resolved.
+
+
+
+
+
+
+
diff --git a/backend/tests/unit/services/stripeWebhookService.test.js b/backend/tests/unit/services/stripeWebhookService.test.js
new file mode 100644
index 0000000..3ce8a44
--- /dev/null
+++ b/backend/tests/unit/services/stripeWebhookService.test.js
@@ -0,0 +1,392 @@
+// Mock dependencies
+jest.mock("stripe", () => {
+ const mockStripe = {
+ payouts: {
+ list: jest.fn(),
+ },
+ transfers: {
+ retrieve: jest.fn(),
+ },
+ balanceTransactions: {
+ list: jest.fn(),
+ },
+ };
+ return jest.fn(() => mockStripe);
+});
+
+jest.mock("../../../models", () => ({
+ Rental: {
+ findAll: jest.fn(),
+ update: jest.fn(),
+ },
+ User: {
+ findOne: jest.fn(),
+ },
+ Item: jest.fn(),
+}));
+
+jest.mock("../../../services/payoutService", () => ({
+ processRentalPayout: jest.fn(),
+}));
+
+jest.mock("../../../utils/logger", () => ({
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+}));
+
+jest.mock("../../../services/email", () => ({
+ payment: {
+ sendPayoutFailedNotification: jest.fn(),
+ },
+}));
+
+jest.mock("sequelize", () => ({
+ Op: {
+ not: "not",
+ is: "is",
+ in: "in",
+ },
+}));
+
+const StripeWebhookService = require("../../../services/stripeWebhookService");
+const { Rental, User } = require("../../../models");
+const emailServices = require("../../../services/email");
+const stripe = require("stripe")();
+
+describe("StripeWebhookService", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("reconcilePayoutStatuses", () => {
+ const mockOwnerId = "owner-123";
+ const mockConnectedAccountId = "acct_test123";
+
+ it("should return early if no rentals need reconciliation", async () => {
+ Rental.findAll.mockResolvedValue([]);
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result).toEqual({
+ reconciled: 0,
+ updated: 0,
+ failed: 0,
+ notificationsSent: 0,
+ errors: [],
+ });
+ expect(stripe.payouts.list).not.toHaveBeenCalled();
+ });
+
+ it("should return early if owner has no connected account", async () => {
+ Rental.findAll.mockResolvedValue([
+ {
+ id: 1,
+ stripeTransferId: "tr_123",
+ owner: { stripeConnectedAccountId: null },
+ },
+ ]);
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result).toEqual({
+ reconciled: 0,
+ updated: 0,
+ failed: 0,
+ notificationsSent: 0,
+ errors: [],
+ });
+ });
+
+ it("should reconcile rental with paid payout", async () => {
+ const mockRental = {
+ id: 1,
+ stripeTransferId: "tr_123",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ };
+
+ Rental.findAll.mockResolvedValue([mockRental]);
+
+ stripe.payouts.list.mockImplementation((params) => {
+ if (params.status === "paid") {
+ return Promise.resolve({
+ data: [
+ {
+ id: "po_paid123",
+ status: "paid",
+ arrival_date: 1700000000,
+ },
+ ],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+
+ stripe.balanceTransactions.list.mockResolvedValue({ data: [] });
+
+ stripe.transfers.retrieve.mockResolvedValue({
+ id: "tr_123",
+ created: 1699900000, // Before arrival_date
+ });
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result.updated).toBe(1);
+ expect(result.failed).toBe(0);
+ expect(mockRental.update).toHaveBeenCalledWith({
+ bankDepositStatus: "paid",
+ bankDepositAt: expect.any(Date),
+ stripePayoutId: "po_paid123",
+ });
+ });
+
+ it("should reconcile rental with failed payout and send notification", async () => {
+ const mockRental = {
+ id: 1,
+ stripeTransferId: "tr_123",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ };
+
+ Rental.findAll.mockResolvedValue([mockRental]);
+
+ stripe.payouts.list.mockImplementation((params) => {
+ if (params.status === "failed") {
+ return Promise.resolve({
+ data: [
+ {
+ id: "po_failed123",
+ status: "failed",
+ failure_code: "account_closed",
+ amount: 5000,
+ },
+ ],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+
+ // Mock balance transactions showing the transfer is in the failed payout
+ stripe.balanceTransactions.list.mockResolvedValue({
+ data: [{ source: "tr_123" }],
+ });
+
+ emailServices.payment.sendPayoutFailedNotification.mockResolvedValue({
+ success: true,
+ });
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result.failed).toBe(1);
+ expect(result.notificationsSent).toBe(1);
+ expect(mockRental.update).toHaveBeenCalledWith({
+ bankDepositStatus: "failed",
+ stripePayoutId: "po_failed123",
+ bankDepositFailureCode: "account_closed",
+ });
+ expect(
+ emailServices.payment.sendPayoutFailedNotification
+ ).toHaveBeenCalledWith("owner@test.com", {
+ ownerName: "Test",
+ payoutAmount: 50, // 5000 cents = $50
+ failureMessage: "Your bank account has been closed.",
+ actionRequired:
+ "Please update your bank account in your payout settings.",
+ failureCode: "account_closed",
+ requiresBankUpdate: true,
+ });
+ });
+
+ it("should handle multiple rentals with mixed statuses", async () => {
+ const mockRentals = [
+ {
+ id: 1,
+ stripeTransferId: "tr_failed",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ },
+ {
+ id: 2,
+ stripeTransferId: "tr_paid",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ },
+ ];
+
+ Rental.findAll.mockResolvedValue(mockRentals);
+
+ stripe.payouts.list.mockImplementation((params) => {
+ if (params.status === "failed") {
+ return Promise.resolve({
+ data: [
+ {
+ id: "po_failed",
+ status: "failed",
+ failure_code: "account_frozen",
+ amount: 3000,
+ },
+ ],
+ });
+ }
+ if (params.status === "paid") {
+ return Promise.resolve({
+ data: [
+ {
+ id: "po_paid",
+ status: "paid",
+ arrival_date: 1700000000,
+ },
+ ],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+
+ // tr_failed is in the failed payout
+ stripe.balanceTransactions.list.mockResolvedValue({
+ data: [{ source: "tr_failed" }],
+ });
+
+ stripe.transfers.retrieve.mockResolvedValue({
+ id: "tr_paid",
+ created: 1699900000,
+ });
+
+ emailServices.payment.sendPayoutFailedNotification.mockResolvedValue({
+ success: true,
+ });
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result.reconciled).toBe(2);
+ expect(result.failed).toBe(1);
+ expect(result.updated).toBe(1);
+ expect(result.notificationsSent).toBe(1);
+ });
+
+ it("should continue processing other rentals when one fails", async () => {
+ const mockRentals = [
+ {
+ id: 1,
+ stripeTransferId: "tr_error",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockRejectedValue(new Error("DB error")),
+ },
+ {
+ id: 2,
+ stripeTransferId: "tr_success",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ },
+ ];
+
+ Rental.findAll.mockResolvedValue(mockRentals);
+
+ stripe.payouts.list.mockImplementation((params) => {
+ if (params.status === "paid") {
+ return Promise.resolve({
+ data: [{ id: "po_paid", status: "paid", arrival_date: 1700000000 }],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+
+ stripe.balanceTransactions.list.mockResolvedValue({ data: [] });
+
+ stripe.transfers.retrieve.mockResolvedValue({
+ created: 1699900000,
+ });
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ expect(result.reconciled).toBe(2);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0].rentalId).toBe(1);
+ expect(result.updated).toBe(1);
+ });
+
+ it("should handle email notification failure gracefully", async () => {
+ const mockRental = {
+ id: 1,
+ stripeTransferId: "tr_123",
+ owner: {
+ id: "owner-123",
+ email: "owner@test.com",
+ firstName: "Test",
+ stripeConnectedAccountId: mockConnectedAccountId,
+ },
+ update: jest.fn().mockResolvedValue(true),
+ };
+
+ Rental.findAll.mockResolvedValue([mockRental]);
+
+ stripe.payouts.list.mockImplementation((params) => {
+ if (params.status === "failed") {
+ return Promise.resolve({
+ data: [
+ {
+ id: "po_failed",
+ failure_code: "declined",
+ amount: 1000,
+ },
+ ],
+ });
+ }
+ return Promise.resolve({ data: [] });
+ });
+
+ stripe.balanceTransactions.list.mockResolvedValue({
+ data: [{ source: "tr_123" }],
+ });
+
+ emailServices.payment.sendPayoutFailedNotification.mockRejectedValue(
+ new Error("Email service down")
+ );
+
+ const result =
+ await StripeWebhookService.reconcilePayoutStatuses(mockOwnerId);
+
+ // Should still mark as failed even if notification fails
+ expect(result.failed).toBe(1);
+ expect(result.notificationsSent).toBe(0);
+ expect(mockRental.update).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/backend/tests/unit/utils/payoutErrors.test.js b/backend/tests/unit/utils/payoutErrors.test.js
new file mode 100644
index 0000000..e9dfaa5
--- /dev/null
+++ b/backend/tests/unit/utils/payoutErrors.test.js
@@ -0,0 +1,162 @@
+const {
+ PAYOUT_FAILURE_MESSAGES,
+ DEFAULT_PAYOUT_FAILURE,
+ getPayoutFailureMessage,
+} = require("../../../utils/payoutErrors");
+
+describe("Payout Errors Utility", () => {
+ describe("PAYOUT_FAILURE_MESSAGES", () => {
+ const requiredProperties = ["message", "action", "requiresBankUpdate"];
+
+ const allFailureCodes = [
+ "account_closed",
+ "account_frozen",
+ "bank_account_restricted",
+ "bank_ownership_changed",
+ "could_not_process",
+ "debit_not_authorized",
+ "declined",
+ "insufficient_funds",
+ "invalid_account_number",
+ "incorrect_account_holder_name",
+ "incorrect_account_holder_type",
+ "invalid_currency",
+ "no_account",
+ "unsupported_card",
+ ];
+
+ test.each(allFailureCodes)("%s exists in PAYOUT_FAILURE_MESSAGES", (code) => {
+ expect(PAYOUT_FAILURE_MESSAGES).toHaveProperty(code);
+ });
+
+ test.each(allFailureCodes)("%s has all required properties", (code) => {
+ const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
+ for (const prop of requiredProperties) {
+ expect(failureInfo).toHaveProperty(prop);
+ }
+ });
+
+ test.each(allFailureCodes)("%s has non-empty message and action", (code) => {
+ const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
+ expect(failureInfo.message.length).toBeGreaterThan(0);
+ expect(failureInfo.action.length).toBeGreaterThan(0);
+ });
+
+ test.each(allFailureCodes)("%s has boolean requiresBankUpdate", (code) => {
+ const failureInfo = PAYOUT_FAILURE_MESSAGES[code];
+ expect(typeof failureInfo.requiresBankUpdate).toBe("boolean");
+ });
+
+ // Codes that require bank account update
+ const codesRequiringBankUpdate = [
+ "account_closed",
+ "bank_account_restricted",
+ "bank_ownership_changed",
+ "insufficient_funds",
+ "invalid_account_number",
+ "incorrect_account_holder_name",
+ "incorrect_account_holder_type",
+ "invalid_currency",
+ "no_account",
+ "unsupported_card",
+ ];
+
+ test.each(codesRequiringBankUpdate)(
+ "%s requires bank update",
+ (code) => {
+ expect(PAYOUT_FAILURE_MESSAGES[code].requiresBankUpdate).toBe(true);
+ }
+ );
+
+ // Codes that don't require bank account update (temporary issues)
+ const temporaryCodes = [
+ "account_frozen",
+ "could_not_process",
+ "debit_not_authorized",
+ "declined",
+ ];
+
+ test.each(temporaryCodes)(
+ "%s does not require bank update (temporary issue)",
+ (code) => {
+ expect(PAYOUT_FAILURE_MESSAGES[code].requiresBankUpdate).toBe(false);
+ }
+ );
+ });
+
+ describe("DEFAULT_PAYOUT_FAILURE", () => {
+ test("has all required properties", () => {
+ expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("message");
+ expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("action");
+ expect(DEFAULT_PAYOUT_FAILURE).toHaveProperty("requiresBankUpdate");
+ });
+
+ test("has non-empty message and action", () => {
+ expect(DEFAULT_PAYOUT_FAILURE.message.length).toBeGreaterThan(0);
+ expect(DEFAULT_PAYOUT_FAILURE.action.length).toBeGreaterThan(0);
+ });
+
+ test("defaults to requiring bank update", () => {
+ expect(DEFAULT_PAYOUT_FAILURE.requiresBankUpdate).toBe(true);
+ });
+ });
+
+ describe("getPayoutFailureMessage", () => {
+ test("returns correct message for known failure code", () => {
+ const result = getPayoutFailureMessage("account_closed");
+
+ expect(result.message).toBe("Your bank account has been closed.");
+ expect(result.action).toBe(
+ "Please update your bank account in your payout settings."
+ );
+ expect(result.requiresBankUpdate).toBe(true);
+ });
+
+ test("returns correct message for account_frozen", () => {
+ const result = getPayoutFailureMessage("account_frozen");
+
+ expect(result.message).toBe("Your bank account is frozen.");
+ expect(result.action).toContain("contact your bank");
+ expect(result.requiresBankUpdate).toBe(false);
+ });
+
+ test("returns correct message for invalid_account_number", () => {
+ const result = getPayoutFailureMessage("invalid_account_number");
+
+ expect(result.message).toContain("invalid");
+ expect(result.requiresBankUpdate).toBe(true);
+ });
+
+ test("returns correct message for could_not_process", () => {
+ const result = getPayoutFailureMessage("could_not_process");
+
+ expect(result.message).toContain("could not process");
+ expect(result.action).toContain("retry");
+ expect(result.requiresBankUpdate).toBe(false);
+ });
+
+ test("returns default message for unknown failure code", () => {
+ const result = getPayoutFailureMessage("unknown_code_xyz");
+
+ expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
+ });
+
+ test("returns default message for null failure code", () => {
+ const result = getPayoutFailureMessage(null);
+
+ expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
+ });
+
+ test("returns default message for undefined failure code", () => {
+ const result = getPayoutFailureMessage(undefined);
+
+ expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
+ });
+
+ test("returns default message for empty string failure code", () => {
+ const result = getPayoutFailureMessage("");
+
+ expect(result).toEqual(DEFAULT_PAYOUT_FAILURE);
+ });
+ });
+});
diff --git a/backend/utils/payoutErrors.js b/backend/utils/payoutErrors.js
new file mode 100644
index 0000000..ee6cd28
--- /dev/null
+++ b/backend/utils/payoutErrors.js
@@ -0,0 +1,102 @@
+/**
+ * Payout Error Handling Utility
+ *
+ * Maps Stripe payout failure codes to user-friendly messages for owners.
+ * These codes indicate why a bank deposit failed.
+ */
+
+const PAYOUT_FAILURE_MESSAGES = {
+ account_closed: {
+ message: "Your bank account has been closed.",
+ action: "Please update your bank account in your payout settings.",
+ requiresBankUpdate: true,
+ },
+ account_frozen: {
+ message: "Your bank account is frozen.",
+ action: "Please contact your bank to resolve this issue.",
+ requiresBankUpdate: false,
+ },
+ bank_account_restricted: {
+ message: "Your bank account has restrictions that prevent deposits.",
+ action: "Please contact your bank or add a different account.",
+ requiresBankUpdate: true,
+ },
+ bank_ownership_changed: {
+ message: "The ownership of your bank account has changed.",
+ action: "Please re-verify your bank account in your payout settings.",
+ requiresBankUpdate: true,
+ },
+ could_not_process: {
+ message: "Your bank could not process the deposit.",
+ action: "This is usually temporary. We'll retry automatically.",
+ requiresBankUpdate: false,
+ },
+ debit_not_authorized: {
+ message: "Your bank account is not authorized to receive deposits.",
+ action: "Please contact your bank to enable incoming transfers.",
+ requiresBankUpdate: false,
+ },
+ declined: {
+ message: "Your bank declined the deposit.",
+ action: "Please contact your bank for more information.",
+ requiresBankUpdate: false,
+ },
+ insufficient_funds: {
+ message: "The deposit was returned.",
+ action:
+ "This can happen with some account types. Please verify your account details.",
+ requiresBankUpdate: true,
+ },
+ invalid_account_number: {
+ message: "Your bank account number appears to be invalid.",
+ action: "Please verify and update your bank account details.",
+ requiresBankUpdate: true,
+ },
+ incorrect_account_holder_name: {
+ message: "The name on your bank account doesn't match your profile.",
+ action: "Please update your bank account or profile information.",
+ requiresBankUpdate: true,
+ },
+ incorrect_account_holder_type: {
+ message: "Your bank account type doesn't match what was expected.",
+ action: "Please verify your account type (individual vs business).",
+ requiresBankUpdate: true,
+ },
+ invalid_currency: {
+ message: "Your bank account doesn't support this currency.",
+ action: "Please add a bank account that supports USD deposits.",
+ requiresBankUpdate: true,
+ },
+ no_account: {
+ message: "The bank account could not be found.",
+ action: "Please verify your account number and routing number.",
+ requiresBankUpdate: true,
+ },
+ unsupported_card: {
+ message: "The debit card used for payouts is not supported.",
+ action: "Please update to a supported debit card or bank account.",
+ requiresBankUpdate: true,
+ },
+};
+
+// Default message for unknown failure codes
+const DEFAULT_PAYOUT_FAILURE = {
+ message: "There was an issue depositing funds to your bank.",
+ action: "Please check your bank account details or contact support.",
+ requiresBankUpdate: true,
+};
+
+/**
+ * Get user-friendly payout failure message
+ * @param {string} failureCode - The Stripe payout failure code
+ * @returns {Object} - { message, action, requiresBankUpdate }
+ */
+function getPayoutFailureMessage(failureCode) {
+ return PAYOUT_FAILURE_MESSAGES[failureCode] || DEFAULT_PAYOUT_FAILURE;
+}
+
+module.exports = {
+ PAYOUT_FAILURE_MESSAGES,
+ DEFAULT_PAYOUT_FAILURE,
+ getPayoutFailureMessage,
+};