From 858563390700816bbcfed1f355e44fe586ae5b69 Mon Sep 17 00:00:00 2001
From: jackiettran <41605212+jackiettran@users.noreply.github.com>
Date: Thu, 8 Jan 2026 17:49:02 -0500
Subject: [PATCH] handling if owner disconnects their stripe account
---
backend/routes/stripeWebhooks.js | 5 +
.../email/domain/PaymentEmailService.js | 40 +++
backend/services/stripeService.js | 90 +++++
backend/services/stripeWebhookService.js | 87 +++++
.../emails/accountDisconnectedToOwner.html | 321 ++++++++++++++++++
.../services/stripeWebhookService.test.js | 198 +++++++++++
6 files changed, 741 insertions(+)
create mode 100644 backend/templates/emails/accountDisconnectedToOwner.html
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
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
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");
+ });
+ });
});