handling changes to stripe account where owner needs to provide information

This commit is contained in:
jackiettran
2026-01-08 19:08:14 -05:00
parent 0ea35e9d6f
commit e2e32f7632
8 changed files with 586 additions and 16 deletions

View File

@@ -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");
},
};

View File

@@ -124,6 +124,24 @@ const User = sequelize.define(
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, 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: { loginAttempts: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
defaultValue: 0, defaultValue: 0,

View File

@@ -2,6 +2,8 @@ const express = require("express");
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth"); const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
const { User, Item } = require("../models"); const { User, Item } = require("../models");
const StripeService = require("../services/stripeService"); const StripeService = require("../services/stripeService");
const StripeWebhookService = require("../services/stripeWebhookService");
const emailServices = require("../services/email");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const router = express.Router(); 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) => { router.get("/account-status", authenticateToken, async (req, res, next) => {
let user = null; let user = null;
try { try {
@@ -190,6 +192,64 @@ router.get("/account-status", authenticateToken, async (req, res, next) => {
payoutsEnabled: accountStatus.payouts_enabled, 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({ res.json({
accountId: accountStatus.id, accountId: accountStatus.id,
detailsSubmitted: accountStatus.details_submitted, detailsSubmitted: accountStatus.details_submitted,

View File

@@ -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 * Send dispute alert to platform admin
* Called when a new dispute is opened * Called when a new dispute is opened

View File

@@ -16,19 +16,23 @@ class StripeWebhookService {
/** /**
* Handle account.updated webhook event. * 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 * @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) { static async handleAccountUpdated(account) {
const accountId = account.id; const accountId = account.id;
const payoutsEnabled = account.payouts_enabled; const payoutsEnabled = account.payouts_enabled;
const requirements = account.requirements || {};
logger.info("Processing account.updated webhook", { logger.info("Processing account.updated webhook", {
accountId, accountId,
payoutsEnabled, payoutsEnabled,
chargesEnabled: account.charges_enabled, chargesEnabled: account.charges_enabled,
detailsSubmitted: account.details_submitted, 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 // Find user with this Stripe account
@@ -41,18 +45,33 @@ class StripeWebhookService {
return { processed: false, reason: "user_not_found" }; return { processed: false, reason: "user_not_found" };
} }
// Store previous state before update
const previousPayoutsEnabled = user.stripePayoutsEnabled; const previousPayoutsEnabled = user.stripePayoutsEnabled;
// Update user's payouts_enabled status // Update user with all account status fields
await user.update({ stripePayoutsEnabled: payoutsEnabled }); 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, userId: user.id,
accountId, accountId,
previousPayoutsEnabled, previousPayoutsEnabled,
newPayoutsEnabled: payoutsEnabled, 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 payouts just became enabled (false -> true), process pending payouts
if (payoutsEnabled && !previousPayoutsEnabled) { if (payoutsEnabled && !previousPayoutsEnabled) {
logger.info("Payouts enabled for user, processing pending payouts", { logger.info("Payouts enabled for user, processing pending payouts", {
@@ -60,15 +79,69 @@ class StripeWebhookService {
accountId, accountId,
}); });
const result = await this.processPayoutsForOwner(user.id); result.payoutsTriggered = true;
return { result.payoutResults = await this.processPayoutsForOwner(user.id);
processed: true,
payoutsTriggered: true,
payoutResults: result,
};
} }
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({ await user.update({
stripeConnectedAccountId: null, stripeConnectedAccountId: null,
stripePayoutsEnabled: false, stripePayoutsEnabled: false,
stripeRequirementsCurrentlyDue: [],
stripeRequirementsPastDue: [],
stripeDisabledReason: null,
stripeRequirementsLastUpdated: null,
}); });
logger.info("Cleared Stripe connection for deauthorized account", { logger.info("Cleared Stripe connection for deauthorized account", {

View File

@@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Action Required - Village Share</title>
<style>
/* Reset styles */
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #212529;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #495057;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #856404;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Warning display */
.warning-display {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%);
padding: 30px;
border-radius: 8px;
text-align: center;
margin: 30px 0;
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.3);
}
.warning-label {
color: #212529;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 10px;
}
.warning-title {
color: #212529;
font-size: 28px;
font-weight: 700;
margin: 0;
line-height: 1.2;
}
.warning-subtitle {
color: #212529;
font-size: 14px;
margin-top: 10px;
opacity: 0.8;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
}
.info-box p:last-child {
margin-bottom: 0;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.warning-title {
font-size: 24px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Action Required</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<p>
Your payouts have been temporarily paused because additional
verification is needed for your account.
</p>
<div class="warning-display">
<div class="warning-label">Account Status</div>
<div class="warning-title">Payouts Paused</div>
<div class="warning-subtitle">Complete verification to resume</div>
</div>
<div class="alert-box">
<p><strong>What happened:</strong></p>
<p>
{{disabledReason}}
</p>
<p><strong>What to do:</strong></p>
<p>
Visit your Earnings page to complete the required verification
steps. This usually only takes a few minutes.
</p>
</div>
<div style="text-align: center">
<a href="{{earningsUrl}}" class="button">Go to Earnings</a>
</div>
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>
Once you complete the verification, your payouts will resume
automatically. Any pending earnings will be deposited to your bank
account on the normal schedule.
</p>
</div>
<p>
We apologize for any inconvenience. Your earnings are safe and will be
deposited as soon as verification is complete.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is an important notification about your account. You received
this message because your payouts have been paused pending
verification.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -3,12 +3,14 @@ import React from "react";
interface EarningsStatusProps { interface EarningsStatusProps {
hasStripeAccount: boolean; hasStripeAccount: boolean;
isOnboardingComplete?: boolean; isOnboardingComplete?: boolean;
payoutsEnabled?: boolean;
onSetupClick: () => void; onSetupClick: () => void;
} }
const EarningsStatus: React.FC<EarningsStatusProps> = ({ const EarningsStatus: React.FC<EarningsStatusProps> = ({
hasStripeAccount, hasStripeAccount,
isOnboardingComplete = false, isOnboardingComplete = false,
payoutsEnabled = true,
onSetupClick, onSetupClick,
}) => { }) => {
// No Stripe account exists // No Stripe account exists
@@ -55,7 +57,29 @@ const EarningsStatus: React.FC<EarningsStatusProps> = ({
); );
} }
// Account exists and is fully set up // Account exists, onboarding complete, but payouts disabled
if (!payoutsEnabled) {
return (
<div className="text-center">
<div className="mb-3">
<i
className="bi bi-exclamation-triangle text-danger"
style={{ fontSize: "2.5rem" }}
></i>
</div>
<h6 className="text-danger">Action Required</h6>
<p className="text-muted small mb-3">
Additional verification is needed to continue receiving payouts.
Please complete the required steps to resume your earnings.
</p>
<button className="btn btn-danger" onClick={onSetupClick}>
Complete Verification
</button>
</div>
);
}
// Account exists and is fully set up with payouts enabled
return ( return (
<div className="text-center"> <div className="text-center">
<div className="mb-3"> <div className="mb-3">

View File

@@ -125,6 +125,11 @@ const EarningsDashboard: React.FC = () => {
const hasStripeAccount = !!userProfile?.stripeConnectedAccountId; const hasStripeAccount = !!userProfile?.stripeConnectedAccountId;
const isOnboardingComplete = accountStatus?.detailsSubmitted ?? false; 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 ( return (
<div className="container mt-4"> <div className="container mt-4">
@@ -147,8 +152,8 @@ const EarningsDashboard: React.FC = () => {
</div> </div>
)} )}
{/* Earnings Setup - only show if not fully set up */} {/* Earnings Setup - show if not fully set up or payouts disabled */}
{(!hasStripeAccount || !isOnboardingComplete) && ( {showSetupCard && (
<div className="card mb-4"> <div className="card mb-4">
<div className="card-header"> <div className="card-header">
<h5 className="mb-0">Earnings Setup</h5> <h5 className="mb-0">Earnings Setup</h5>
@@ -157,6 +162,7 @@ const EarningsDashboard: React.FC = () => {
<EarningsStatus <EarningsStatus
hasStripeAccount={hasStripeAccount} hasStripeAccount={hasStripeAccount}
isOnboardingComplete={isOnboardingComplete} isOnboardingComplete={isOnboardingComplete}
payoutsEnabled={payoutsEnabled}
onSetupClick={() => setShowOnboarding(true)} onSetupClick={() => setShowOnboarding(true)}
/> />
</div> </div>