diff --git a/backend/routes/stripeWebhooks.js b/backend/routes/stripeWebhooks.js index b6b75b7..4644a00 100644 --- a/backend/routes/stripeWebhooks.js +++ b/backend/routes/stripeWebhooks.js @@ -71,6 +71,11 @@ router.post("/", async (req, res) => { ); break; + case "account.application.deauthorized": + // Owner disconnected their Stripe account from our platform + await StripeWebhookService.handleAccountDeauthorized(event.account); + break; + case "charge.dispute.created": // Renter disputed a charge with their bank await DisputeService.handleDisputeCreated(event.data.object); diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js index 9a1682c..2246fc3 100644 --- a/backend/services/email/domain/PaymentEmailService.js +++ b/backend/services/email/domain/PaymentEmailService.js @@ -173,6 +173,46 @@ class PaymentEmailService { } } + /** + * Send notification when owner disconnects their Stripe account + * @param {string} ownerEmail - Owner's email address + * @param {Object} params - Email parameters + * @param {string} params.ownerName - Owner's name + * @param {boolean} params.hasPendingPayouts - Whether there are pending payouts + * @param {number} params.pendingPayoutCount - Number of pending payouts + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendAccountDisconnectedEmail(ownerEmail, params) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const { ownerName, hasPendingPayouts, pendingPayoutCount } = params; + + const variables = { + ownerName: ownerName || "there", + hasPendingPayouts: hasPendingPayouts || false, + pendingPayoutCount: pendingPayoutCount || 0, + reconnectUrl: `${process.env.FRONTEND_URL}/settings/payouts`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "accountDisconnectedToOwner", + variables + ); + + return await this.emailClient.sendEmail( + ownerEmail, + "Your payout account has been disconnected - Village Share", + htmlContent + ); + } catch (error) { + console.error("Failed to send account disconnected email:", error); + return { success: false, error: error.message }; + } + } + /** * Send dispute alert to platform admin * Called when a new dispute is opened diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js index 6fa2263..ec14260 100644 --- a/backend/services/stripeService.js +++ b/backend/services/stripeService.js @@ -1,6 +1,8 @@ const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); const logger = require("../utils/logger"); const { parseStripeError, PaymentError } = require("../utils/stripeErrors"); +const { User } = require("../models"); +const emailServices = require("./email"); class StripeService { static async getCheckoutSession(sessionId) { @@ -111,6 +113,23 @@ class StripeService { return transfer; } catch (error) { + // Check if this is a disconnected account error (fallback for missed webhooks) + if (this.isAccountDisconnectedError(error)) { + logger.warn("Transfer failed - account appears disconnected", { + destination, + errorCode: error.code, + errorType: error.type, + }); + + // Clean up stale connection data asynchronously (don't block the error) + this.handleDisconnectedAccount(destination).catch((cleanupError) => { + logger.error("Failed to clean up disconnected account", { + destination, + error: cleanupError.message, + }); + }); + } + logger.error("Error creating transfer", { error: error.message, stack: error.stack, @@ -119,6 +138,77 @@ class StripeService { } } + /** + * Check if error indicates the connected account is disconnected. + * Used as fallback detection when webhook was missed. + * @param {Error} error - Stripe error object + * @returns {boolean} - True if error indicates disconnected account + */ + static isAccountDisconnectedError(error) { + // Stripe returns these error codes when account is disconnected or invalid + const disconnectedCodes = ["account_invalid", "platform_api_key_expired"]; + + // Error messages that indicate disconnection + const disconnectedMessages = [ + "cannot transfer", + "not connected", + "no longer connected", + "account has been deauthorized", + ]; + + if (disconnectedCodes.includes(error.code)) { + return true; + } + + const message = (error.message || "").toLowerCase(); + return disconnectedMessages.some((msg) => message.includes(msg)); + } + + /** + * Handle disconnected account - cleanup and notify. + * Called as fallback when webhook was missed. + * @param {string} accountId - The disconnected Stripe account ID + */ + static async handleDisconnectedAccount(accountId) { + try { + const user = await User.findOne({ + where: { stripeConnectedAccountId: accountId }, + }); + + if (!user) { + return; + } + + logger.warn("Cleaning up disconnected account (webhook likely missed)", { + userId: user.id, + accountId, + }); + + // Clear connection + await user.update({ + stripeConnectedAccountId: null, + stripePayoutsEnabled: false, + }); + + // Send notification + await emailServices.payment.sendAccountDisconnectedEmail(user.email, { + ownerName: user.firstName || user.name, + hasPendingPayouts: true, // We're in a transfer, so there's at least one + pendingPayoutCount: 1, + }); + + logger.info("Sent account disconnected notification (fallback)", { + userId: user.id, + }); + } catch (cleanupError) { + logger.error("Failed to clean up disconnected account", { + accountId, + error: cleanupError.message, + }); + // Don't throw - let original error propagate + } + } + static async createRefund({ paymentIntentId, amount, diff --git a/backend/services/stripeWebhookService.js b/backend/services/stripeWebhookService.js index 2a04e96..99e24a9 100644 --- a/backend/services/stripeWebhookService.js +++ b/backend/services/stripeWebhookService.js @@ -338,6 +338,93 @@ class StripeWebhookService { } } + /** + * Handle account.application.deauthorized webhook event. + * Triggered when an owner disconnects their Stripe account from our platform. + * @param {string} accountId - The connected account ID that was deauthorized + * @returns {Object} - { processed, userId, pendingPayoutsCount, notificationSent } + */ + static async handleAccountDeauthorized(accountId) { + logger.warn("Processing account.application.deauthorized webhook", { + accountId, + }); + + if (!accountId) { + logger.warn("account.application.deauthorized webhook missing account ID"); + return { processed: false, reason: "missing_account_id" }; + } + + try { + // Find the user by their connected account ID + const user = await User.findOne({ + where: { stripeConnectedAccountId: accountId }, + }); + + if (!user) { + logger.warn("No user found for deauthorized Stripe account", { accountId }); + return { processed: false, reason: "user_not_found" }; + } + + // Clear Stripe connection fields + await user.update({ + stripeConnectedAccountId: null, + stripePayoutsEnabled: false, + }); + + logger.info("Cleared Stripe connection for deauthorized account", { + userId: user.id, + accountId, + }); + + // Check for pending payouts that will now fail + const pendingRentals = await Rental.findAll({ + where: { + ownerId: user.id, + payoutStatus: "pending", + }, + }); + + if (pendingRentals.length > 0) { + logger.warn("Owner disconnected account with pending payouts", { + userId: user.id, + pendingCount: pendingRentals.length, + pendingRentalIds: pendingRentals.map((r) => r.id), + }); + } + + // Send notification email + let notificationSent = false; + try { + await emailServices.payment.sendAccountDisconnectedEmail(user.email, { + ownerName: user.firstName || user.name, + hasPendingPayouts: pendingRentals.length > 0, + pendingPayoutCount: pendingRentals.length, + }); + notificationSent = true; + logger.info("Sent account disconnected notification", { userId: user.id }); + } catch (emailError) { + logger.error("Failed to send account disconnected notification", { + userId: user.id, + error: emailError.message, + }); + } + + return { + processed: true, + userId: user.id, + pendingPayoutsCount: pendingRentals.length, + notificationSent, + }; + } catch (error) { + logger.error("Error processing account.application.deauthorized webhook", { + accountId, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + /** * Reconcile payout statuses for an owner by checking Stripe for actual status. * This handles cases where payout.paid or payout.failed webhooks were missed. diff --git a/backend/templates/emails/accountDisconnectedToOwner.html b/backend/templates/emails/accountDisconnectedToOwner.html new file mode 100644 index 0000000..8b7b9d7 --- /dev/null +++ b/backend/templates/emails/accountDisconnectedToOwner.html @@ -0,0 +1,321 @@ + + + + + + + Payout Account Disconnected - Village Share + + + +
+
+ +
Payout Account Disconnected
+
+ +
+

Hi {{ownerName}},

+ +

+ Your Stripe payout account has been disconnected from Village Share. + This means we are no longer able to send your earnings to your bank + account. +

+ +
+
Account Status
+
+
Payout account disconnected
+
+ + {{#if hasPendingPayouts}} +
+

Important:

+

+ You have {{pendingPayoutCount}} pending + payout{{#if (gt pendingPayoutCount 1)}}s{{/if}} that cannot be + processed until you reconnect your payout account. +

+
+ {{/if}} + +

What This Means

+

+ Without a connected payout account, you will not be able to receive + earnings from rentals. Your listings will remain active, but payments + cannot be deposited to your bank account. +

+ +

What To Do

+

+ If you disconnected your account by mistake, or would like to continue + receiving payouts, please reconnect your Stripe account. +

+ +
+ Reconnect Payout Account +
+ +
+

Need help?

+

+ If you're having trouble reconnecting your account, or didn't + disconnect it yourself, please contact our support team right away. +

+
+ +

+ We're here to help you continue earning on Village Share. Don't + hesitate to reach out if you have any questions. +

+
+ + +
+ + diff --git a/backend/tests/unit/services/stripeWebhookService.test.js b/backend/tests/unit/services/stripeWebhookService.test.js index 3ce8a44..041edfc 100644 --- a/backend/tests/unit/services/stripeWebhookService.test.js +++ b/backend/tests/unit/services/stripeWebhookService.test.js @@ -38,6 +38,7 @@ jest.mock("../../../utils/logger", () => ({ jest.mock("../../../services/email", () => ({ payment: { sendPayoutFailedNotification: jest.fn(), + sendAccountDisconnectedEmail: jest.fn(), }, })); @@ -389,4 +390,201 @@ describe("StripeWebhookService", () => { expect(mockRental.update).toHaveBeenCalled(); }); }); + + describe("handleAccountDeauthorized", () => { + it("should return missing_account_id when accountId is not provided", async () => { + const result = await StripeWebhookService.handleAccountDeauthorized(null); + + expect(result.processed).toBe(false); + expect(result.reason).toBe("missing_account_id"); + }); + + it("should return missing_account_id for undefined accountId", async () => { + const result = await StripeWebhookService.handleAccountDeauthorized(undefined); + + expect(result.processed).toBe(false); + expect(result.reason).toBe("missing_account_id"); + }); + + it("should return user_not_found when account does not match any user", async () => { + User.findOne.mockResolvedValue(null); + + const result = await StripeWebhookService.handleAccountDeauthorized("acct_unknown"); + + expect(result.processed).toBe(false); + expect(result.reason).toBe("user_not_found"); + expect(User.findOne).toHaveBeenCalledWith({ + where: { stripeConnectedAccountId: "acct_unknown" }, + }); + }); + + it("should clear Stripe connection fields when account is deauthorized", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Test", + name: "Test Owner", + stripeConnectedAccountId: "acct_123", + stripePayoutsEnabled: true, + update: jest.fn().mockResolvedValue(true), + }; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue([]); + emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ + success: true, + }); + + const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + expect(result.processed).toBe(true); + expect(result.userId).toBe(1); + expect(mockUser.update).toHaveBeenCalledWith({ + stripeConnectedAccountId: null, + stripePayoutsEnabled: false, + }); + }); + + it("should log warning when there are pending payouts", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Test", + update: jest.fn().mockResolvedValue(true), + }; + const mockRentals = [{ id: 100 }, { id: 101 }]; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue(mockRentals); + emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ + success: true, + }); + + const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + expect(result.pendingPayoutsCount).toBe(2); + expect(Rental.findAll).toHaveBeenCalledWith({ + where: { + ownerId: 1, + payoutStatus: "pending", + }, + }); + }); + + it("should send notification email to owner", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Test", + update: jest.fn().mockResolvedValue(true), + }; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue([]); + emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ + success: true, + }); + + const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + expect(result.notificationSent).toBe(true); + expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( + "owner@test.com", + { + ownerName: "Test", + hasPendingPayouts: false, + pendingPayoutCount: 0, + } + ); + }); + + it("should send notification with pending payout info when there are pending payouts", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Owner", + update: jest.fn().mockResolvedValue(true), + }; + const mockRentals = [{ id: 100 }, { id: 101 }, { id: 102 }]; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue(mockRentals); + emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ + success: true, + }); + + await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( + "owner@test.com", + { + ownerName: "Owner", + hasPendingPayouts: true, + pendingPayoutCount: 3, + } + ); + }); + + it("should use name fallback when firstName is not available", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: null, + name: "Full Name", + update: jest.fn().mockResolvedValue(true), + }; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue([]); + emailServices.payment.sendAccountDisconnectedEmail.mockResolvedValue({ + success: true, + }); + + await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + expect(emailServices.payment.sendAccountDisconnectedEmail).toHaveBeenCalledWith( + "owner@test.com", + expect.objectContaining({ + ownerName: "Full Name", + }) + ); + }); + + it("should handle email sending failure gracefully", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Test", + update: jest.fn().mockResolvedValue(true), + }; + + User.findOne.mockResolvedValue(mockUser); + Rental.findAll.mockResolvedValue([]); + emailServices.payment.sendAccountDisconnectedEmail.mockRejectedValue( + new Error("Email service down") + ); + + const result = await StripeWebhookService.handleAccountDeauthorized("acct_123"); + + // Should still mark as processed even if notification fails + expect(result.processed).toBe(true); + expect(result.notificationSent).toBe(false); + expect(mockUser.update).toHaveBeenCalled(); + }); + + it("should throw error when database update fails", async () => { + const mockUser = { + id: 1, + email: "owner@test.com", + firstName: "Test", + update: jest.fn().mockRejectedValue(new Error("DB error")), + }; + + User.findOne.mockResolvedValue(mockUser); + + await expect( + StripeWebhookService.handleAccountDeauthorized("acct_123") + ).rejects.toThrow("DB error"); + }); + }); });