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 + + + +
+
+ +
Payout Issue
+
+ +
+

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}} +
+ Update Bank Account +
+ {{/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, +};