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 + + + +
+
+ +
Action Required
+
+ +
+

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. +

+
+ +
+ Go to Earnings +
+ +
+

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. +

+ +
+ ); + } + + // 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)} />