From e2e32f7632cd3c83ced75e162ce5212b8384162a Mon Sep 17 00:00:00 2001
From: jackiettran <41605212+jackiettran@users.noreply.github.com>
Date: Thu, 8 Jan 2026 19:08:14 -0500
Subject: [PATCH] handling changes to stripe account where owner needs to
provide information
---
...000003-add-stripe-requirements-to-users.js | 34 ++
backend/models/User.js | 18 +
backend/routes/stripe.js | 62 +++-
.../email/domain/PaymentEmailService.js | 40 +++
backend/services/stripeWebhookService.js | 101 +++++-
.../emails/payoutsDisabledToOwner.html | 311 ++++++++++++++++++
frontend/src/components/EarningsStatus.tsx | 26 +-
frontend/src/pages/EarningsDashboard.tsx | 10 +-
8 files changed, 586 insertions(+), 16 deletions(-)
create mode 100644 backend/migrations/20260108000003-add-stripe-requirements-to-users.js
create mode 100644 backend/templates/emails/payoutsDisabledToOwner.html
diff --git a/backend/migrations/20260108000003-add-stripe-requirements-to-users.js b/backend/migrations/20260108000003-add-stripe-requirements-to-users.js
new file mode 100644
index 0000000..03d7959
--- /dev/null
+++ b/backend/migrations/20260108000003-add-stripe-requirements-to-users.js
@@ -0,0 +1,34 @@
+"use strict";
+
+module.exports = {
+ up: async (queryInterface, Sequelize) => {
+ await queryInterface.addColumn("Users", "stripeRequirementsCurrentlyDue", {
+ type: Sequelize.JSON,
+ defaultValue: [],
+ allowNull: true,
+ });
+
+ await queryInterface.addColumn("Users", "stripeRequirementsPastDue", {
+ type: Sequelize.JSON,
+ defaultValue: [],
+ allowNull: true,
+ });
+
+ await queryInterface.addColumn("Users", "stripeDisabledReason", {
+ type: Sequelize.STRING,
+ allowNull: true,
+ });
+
+ await queryInterface.addColumn("Users", "stripeRequirementsLastUpdated", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ },
+
+ down: async (queryInterface, Sequelize) => {
+ await queryInterface.removeColumn("Users", "stripeRequirementsCurrentlyDue");
+ await queryInterface.removeColumn("Users", "stripeRequirementsPastDue");
+ await queryInterface.removeColumn("Users", "stripeDisabledReason");
+ await queryInterface.removeColumn("Users", "stripeRequirementsLastUpdated");
+ },
+};
diff --git a/backend/models/User.js b/backend/models/User.js
index d5bd7c7..c9fdb4c 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -124,6 +124,24 @@ const User = sequelize.define(
type: DataTypes.STRING,
allowNull: true,
},
+ stripeRequirementsCurrentlyDue: {
+ type: DataTypes.JSON,
+ defaultValue: [],
+ allowNull: true,
+ },
+ stripeRequirementsPastDue: {
+ type: DataTypes.JSON,
+ defaultValue: [],
+ allowNull: true,
+ },
+ stripeDisabledReason: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ },
+ stripeRequirementsLastUpdated: {
+ type: DataTypes.DATE,
+ allowNull: true,
+ },
loginAttempts: {
type: DataTypes.INTEGER,
defaultValue: 0,
diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js
index a039750..a3e5979 100644
--- a/backend/routes/stripe.js
+++ b/backend/routes/stripe.js
@@ -2,6 +2,8 @@ const express = require("express");
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models");
const StripeService = require("../services/stripeService");
+const StripeWebhookService = require("../services/stripeWebhookService");
+const emailServices = require("../services/email");
const logger = require("../utils/logger");
const router = express.Router();
@@ -168,7 +170,7 @@ router.post("/account-sessions", authenticateToken, requireVerifiedEmail, async
}
});
-// Get account status
+// Get account status with reconciliation
router.get("/account-status", authenticateToken, async (req, res, next) => {
let user = null;
try {
@@ -190,6 +192,64 @@ router.get("/account-status", authenticateToken, async (req, res, next) => {
payoutsEnabled: accountStatus.payouts_enabled,
});
+ // Reconciliation: Compare fetched status with stored User fields
+ const previousPayoutsEnabled = user.stripePayoutsEnabled;
+ const currentPayoutsEnabled = accountStatus.payouts_enabled;
+ const requirements = accountStatus.requirements || {};
+
+ // Check if status has changed and needs updating
+ const statusChanged =
+ previousPayoutsEnabled !== currentPayoutsEnabled ||
+ JSON.stringify(user.stripeRequirementsCurrentlyDue || []) !==
+ JSON.stringify(requirements.currently_due || []);
+
+ if (statusChanged) {
+ reqLogger.info("Reconciling account status from API call", {
+ userId: req.user.id,
+ previousPayoutsEnabled,
+ currentPayoutsEnabled,
+ previousCurrentlyDue: user.stripeRequirementsCurrentlyDue?.length || 0,
+ newCurrentlyDue: requirements.currently_due?.length || 0,
+ });
+
+ // Update user with current status
+ await user.update({
+ stripePayoutsEnabled: currentPayoutsEnabled,
+ stripeRequirementsCurrentlyDue: requirements.currently_due || [],
+ stripeRequirementsPastDue: requirements.past_due || [],
+ stripeDisabledReason: requirements.disabled_reason || null,
+ stripeRequirementsLastUpdated: new Date(),
+ });
+
+ // If payouts just became disabled (true -> false), send notification
+ if (!currentPayoutsEnabled && previousPayoutsEnabled) {
+ reqLogger.warn("Payouts disabled detected during reconciliation", {
+ userId: req.user.id,
+ disabledReason: requirements.disabled_reason,
+ });
+
+ try {
+ const disabledReason = StripeWebhookService.formatDisabledReason(
+ requirements.disabled_reason
+ );
+
+ await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
+ ownerName: user.firstName || user.name,
+ disabledReason,
+ });
+
+ reqLogger.info("Sent payouts disabled email during reconciliation", {
+ userId: req.user.id,
+ });
+ } catch (emailError) {
+ reqLogger.error("Failed to send payouts disabled email", {
+ userId: req.user.id,
+ error: emailError.message,
+ });
+ }
+ }
+ }
+
res.json({
accountId: accountStatus.id,
detailsSubmitted: accountStatus.details_submitted,
diff --git a/backend/services/email/domain/PaymentEmailService.js b/backend/services/email/domain/PaymentEmailService.js
index 2246fc3..416a05c 100644
--- a/backend/services/email/domain/PaymentEmailService.js
+++ b/backend/services/email/domain/PaymentEmailService.js
@@ -213,6 +213,46 @@ class PaymentEmailService {
}
}
+ /**
+ * Send notification when owner's payouts are disabled due to requirements
+ * @param {string} ownerEmail - Owner's email address
+ * @param {Object} params - Email parameters
+ * @param {string} params.ownerName - Owner's name
+ * @param {string} params.disabledReason - Human-readable reason for disabling
+ * @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
+ */
+ async sendPayoutsDisabledEmail(ownerEmail, params) {
+ if (!this.initialized) {
+ await this.initialize();
+ }
+
+ try {
+ const { ownerName, disabledReason } = params;
+
+ const variables = {
+ ownerName: ownerName || "there",
+ disabledReason:
+ disabledReason ||
+ "Additional verification is required for your account.",
+ earningsUrl: `${process.env.FRONTEND_URL}/earnings`,
+ };
+
+ const htmlContent = await this.templateManager.renderTemplate(
+ "payoutsDisabledToOwner",
+ variables
+ );
+
+ return await this.emailClient.sendEmail(
+ ownerEmail,
+ "Action Required: Your payouts have been paused - Village Share",
+ htmlContent
+ );
+ } catch (error) {
+ console.error("Failed to send payouts disabled 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/stripeWebhookService.js b/backend/services/stripeWebhookService.js
index 9c88b99..4639fea 100644
--- a/backend/services/stripeWebhookService.js
+++ b/backend/services/stripeWebhookService.js
@@ -16,19 +16,23 @@ class StripeWebhookService {
/**
* Handle account.updated webhook event.
- * Triggers payouts for owner when payouts_enabled becomes true.
+ * Tracks requirements, triggers payouts when enabled, and notifies when disabled.
* @param {Object} account - The Stripe account object from the webhook
- * @returns {Object} - { processed, payoutsTriggered, payoutResults }
+ * @returns {Object} - { processed, payoutsTriggered, payoutResults, notificationSent }
*/
static async handleAccountUpdated(account) {
const accountId = account.id;
const payoutsEnabled = account.payouts_enabled;
+ const requirements = account.requirements || {};
logger.info("Processing account.updated webhook", {
accountId,
payoutsEnabled,
chargesEnabled: account.charges_enabled,
detailsSubmitted: account.details_submitted,
+ currentlyDue: requirements.currently_due?.length || 0,
+ pastDue: requirements.past_due?.length || 0,
+ disabledReason: requirements.disabled_reason,
});
// Find user with this Stripe account
@@ -41,18 +45,33 @@ class StripeWebhookService {
return { processed: false, reason: "user_not_found" };
}
+ // Store previous state before update
const previousPayoutsEnabled = user.stripePayoutsEnabled;
- // Update user's payouts_enabled status
- await user.update({ stripePayoutsEnabled: payoutsEnabled });
+ // Update user with all account status fields
+ await user.update({
+ stripePayoutsEnabled: payoutsEnabled,
+ stripeRequirementsCurrentlyDue: requirements.currently_due || [],
+ stripeRequirementsPastDue: requirements.past_due || [],
+ stripeDisabledReason: requirements.disabled_reason || null,
+ stripeRequirementsLastUpdated: new Date(),
+ });
- logger.info("Updated user stripePayoutsEnabled", {
+ logger.info("Updated user Stripe account status", {
userId: user.id,
accountId,
previousPayoutsEnabled,
newPayoutsEnabled: payoutsEnabled,
+ currentlyDue: requirements.currently_due?.length || 0,
+ pastDue: requirements.past_due?.length || 0,
});
+ const result = {
+ processed: true,
+ payoutsTriggered: false,
+ notificationSent: false,
+ };
+
// If payouts just became enabled (false -> true), process pending payouts
if (payoutsEnabled && !previousPayoutsEnabled) {
logger.info("Payouts enabled for user, processing pending payouts", {
@@ -60,15 +79,69 @@ class StripeWebhookService {
accountId,
});
- const result = await this.processPayoutsForOwner(user.id);
- return {
- processed: true,
- payoutsTriggered: true,
- payoutResults: result,
- };
+ result.payoutsTriggered = true;
+ result.payoutResults = await this.processPayoutsForOwner(user.id);
}
- return { processed: true, payoutsTriggered: false };
+ // If payouts just became disabled (true -> false), notify the owner
+ if (!payoutsEnabled && previousPayoutsEnabled) {
+ logger.warn("Payouts disabled for user", {
+ userId: user.id,
+ accountId,
+ disabledReason: requirements.disabled_reason,
+ currentlyDue: requirements.currently_due,
+ });
+
+ try {
+ const disabledReason = this.formatDisabledReason(requirements.disabled_reason);
+
+ await emailServices.payment.sendPayoutsDisabledEmail(user.email, {
+ ownerName: user.firstName || user.name,
+ disabledReason,
+ });
+
+ result.notificationSent = true;
+
+ logger.info("Sent payouts disabled notification to owner", {
+ userId: user.id,
+ accountId,
+ disabledReason: requirements.disabled_reason,
+ });
+ } catch (emailError) {
+ logger.error("Failed to send payouts disabled notification", {
+ userId: user.id,
+ accountId,
+ error: emailError.message,
+ });
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Format Stripe disabled_reason code to user-friendly message.
+ * @param {string} reason - Stripe disabled_reason code
+ * @returns {string} User-friendly message
+ */
+ static formatDisabledReason(reason) {
+ const reasonMap = {
+ "requirements.past_due":
+ "Some required information is past due and must be provided to continue receiving payouts.",
+ "requirements.pending_verification":
+ "Your submitted information is being verified. This usually takes a few minutes.",
+ listed: "Your account has been listed for review due to potential policy concerns.",
+ platform_paused:
+ "Payouts have been temporarily paused by the platform.",
+ rejected_fraud: "Your account was flagged for potential fraudulent activity.",
+ rejected_listed: "Your account has been rejected due to policy concerns.",
+ rejected_terms_of_service:
+ "Your account was rejected due to a terms of service violation.",
+ rejected_other: "Your account was rejected. Please contact support for more information.",
+ under_review: "Your account is under review. We'll notify you when the review is complete.",
+ };
+
+ return reasonMap[reason] || "Additional verification is required for your account.";
}
/**
@@ -444,6 +517,10 @@ class StripeWebhookService {
await user.update({
stripeConnectedAccountId: null,
stripePayoutsEnabled: false,
+ stripeRequirementsCurrentlyDue: [],
+ stripeRequirementsPastDue: [],
+ stripeDisabledReason: null,
+ stripeRequirementsLastUpdated: null,
});
logger.info("Cleared Stripe connection for deauthorized account", {
diff --git a/backend/templates/emails/payoutsDisabledToOwner.html b/backend/templates/emails/payoutsDisabledToOwner.html
new file mode 100644
index 0000000..3d6849a
--- /dev/null
+++ b/backend/templates/emails/payoutsDisabledToOwner.html
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+ Action Required - Village Share
+
+
+
+
+
+
+
+
Hi {{ownerName}},
+
+
+ Your payouts have been temporarily paused because additional
+ verification is needed for your account.
+
+
+
+
Account Status
+
Payouts Paused
+
Complete verification to resume
+
+
+
+
What happened:
+
+ {{disabledReason}}
+
+
What to do:
+
+ Visit your Earnings page to complete the required verification
+ steps. This usually only takes a few minutes.
+
+
+
+
+
+
+
What happens next?
+
+ Once you complete the verification, your payouts will resume
+ automatically. Any pending earnings will be deposited to your bank
+ account on the normal schedule.
+
+
+
+
+ We apologize for any inconvenience. Your earnings are safe and will be
+ deposited as soon as verification is complete.
+
+
+
+
+
+
+
diff --git a/frontend/src/components/EarningsStatus.tsx b/frontend/src/components/EarningsStatus.tsx
index 17eee6b..6eecf25 100644
--- a/frontend/src/components/EarningsStatus.tsx
+++ b/frontend/src/components/EarningsStatus.tsx
@@ -3,12 +3,14 @@ import React from "react";
interface EarningsStatusProps {
hasStripeAccount: boolean;
isOnboardingComplete?: boolean;
+ payoutsEnabled?: boolean;
onSetupClick: () => void;
}
const EarningsStatus: React.FC = ({
hasStripeAccount,
isOnboardingComplete = false,
+ payoutsEnabled = true,
onSetupClick,
}) => {
// No Stripe account exists
@@ -55,7 +57,29 @@ const EarningsStatus: React.FC = ({
);
}
- // Account exists and is fully set up
+ // Account exists, onboarding complete, but payouts disabled
+ if (!payoutsEnabled) {
+ return (
+
+
+
+
+
Action Required
+
+ Additional verification is needed to continue receiving payouts.
+ Please complete the required steps to resume your earnings.
+
+
+ Complete Verification
+
+
+ );
+ }
+
+ // Account exists and is fully set up with payouts enabled
return (
diff --git a/frontend/src/pages/EarningsDashboard.tsx b/frontend/src/pages/EarningsDashboard.tsx
index 5ac0fe5..a734535 100644
--- a/frontend/src/pages/EarningsDashboard.tsx
+++ b/frontend/src/pages/EarningsDashboard.tsx
@@ -125,6 +125,11 @@ const EarningsDashboard: React.FC = () => {
const hasStripeAccount = !!userProfile?.stripeConnectedAccountId;
const isOnboardingComplete = accountStatus?.detailsSubmitted ?? false;
+ const payoutsEnabled = accountStatus?.payoutsEnabled ?? true;
+
+ // Show setup card if: no account, onboarding incomplete, or payouts disabled
+ const showSetupCard =
+ !hasStripeAccount || !isOnboardingComplete || !payoutsEnabled;
return (
@@ -147,8 +152,8 @@ const EarningsDashboard: React.FC = () => {
)}
- {/* Earnings Setup - only show if not fully set up */}
- {(!hasStripeAccount || !isOnboardingComplete) && (
+ {/* Earnings Setup - show if not fully set up or payouts disabled */}
+ {showSetupCard && (
Earnings Setup
@@ -157,6 +162,7 @@ const EarningsDashboard: React.FC = () => {
setShowOnboarding(true)}
/>