handling changes to stripe account where owner needs to provide information
This commit is contained in:
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
311
backend/templates/emails/payoutsDisabledToOwner.html
Normal file
311
backend/templates/emails/payoutsDisabledToOwner.html
Normal 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>© 2025 Village Share. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user