3D Secure handling
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
// Add 'requires_action' to the paymentStatus enum
|
||||
// This status is used when 3DS authentication is required for a payment
|
||||
await queryInterface.sequelize.query(`
|
||||
ALTER TYPE "enum_Rentals_paymentStatus" ADD VALUE IF NOT EXISTS 'requires_action';
|
||||
`);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
// Note: PostgreSQL does not support removing values from ENUMs directly.
|
||||
// The 'requires_action' value will remain in the enum but can be unused.
|
||||
// To fully remove it would require recreating the enum and column,
|
||||
// which is complex and risky for production data.
|
||||
console.log(
|
||||
"Note: PostgreSQL does not support removing ENUM values. " +
|
||||
"'requires_action' will remain in the enum but will not be used."
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -67,7 +67,7 @@ const Rental = sequelize.define("Rental", {
|
||||
allowNull: false,
|
||||
},
|
||||
paymentStatus: {
|
||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"),
|
||||
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
|
||||
allowNull: false,
|
||||
},
|
||||
payoutStatus: {
|
||||
|
||||
@@ -493,6 +493,51 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
);
|
||||
|
||||
// Check if 3DS authentication is required
|
||||
if (paymentResult.requiresAction) {
|
||||
// Store payment intent for later completion
|
||||
await rental.update({
|
||||
stripePaymentIntentId: paymentResult.paymentIntentId,
|
||||
paymentStatus: "requires_action",
|
||||
});
|
||||
|
||||
// Send email to renter (without direct link for security)
|
||||
try {
|
||||
await emailServices.rentalFlow.sendAuthenticationRequiredEmail(
|
||||
rental.renter.email,
|
||||
{
|
||||
renterName: rental.renter.firstName,
|
||||
itemName: rental.item.name,
|
||||
ownerName: rental.owner.firstName,
|
||||
amount: rental.totalAmount,
|
||||
}
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Authentication required email sent to renter", {
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send authentication required email",
|
||||
{
|
||||
error: emailError.message,
|
||||
stack: emailError.stack,
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(402).json({
|
||||
error: "authentication_required",
|
||||
requiresAction: true,
|
||||
message: "The renter's card requires additional authentication.",
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Update rental with payment completion
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
@@ -1652,4 +1697,223 @@ router.put("/:id/payment-method", authenticateToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /rentals/:id/payment-client-secret
|
||||
* Returns client secret for 3DS completion (renter only)
|
||||
*/
|
||||
router.get(
|
||||
"/:id/payment-client-secret",
|
||||
authenticateToken,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: User, as: "renter", attributes: ["id", "stripeCustomerId"] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
if (rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (!rental.stripePaymentIntentId) {
|
||||
return res.status(400).json({ error: "No payment intent found" });
|
||||
}
|
||||
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||
rental.stripePaymentIntentId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
status: paymentIntent.status,
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Get client secret error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /rentals/:id/complete-payment
|
||||
* Called after renter completes 3DS authentication
|
||||
*/
|
||||
router.post(
|
||||
"/:id/complete-payment",
|
||||
authenticateToken,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: User, as: "renter", attributes: ["id", "firstName", "lastName", "email", "stripeCustomerId"] },
|
||||
{ model: User, as: "owner", attributes: ["id", "firstName", "lastName", "email", "stripeConnectedAccountId", "stripePayoutsEnabled"] },
|
||||
{ model: Item, as: "item" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
if (rental.renterId !== req.user.id) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (rental.paymentStatus !== "requires_action") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid state",
|
||||
message: "This rental is not awaiting payment authentication",
|
||||
});
|
||||
}
|
||||
|
||||
// Retrieve payment intent to check status (expand latest_charge for payment method details)
|
||||
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(
|
||||
rental.stripePaymentIntentId,
|
||||
{ expand: ['latest_charge.payment_method_details'] }
|
||||
);
|
||||
|
||||
if (paymentIntent.status !== "succeeded") {
|
||||
return res.status(402).json({
|
||||
error: "payment_incomplete",
|
||||
status: paymentIntent.status,
|
||||
message:
|
||||
paymentIntent.status === "requires_action"
|
||||
? "Authentication not yet completed"
|
||||
: "Payment could not be completed",
|
||||
});
|
||||
}
|
||||
|
||||
// Extract payment method details from latest_charge (charges is deprecated)
|
||||
const charge = paymentIntent.latest_charge;
|
||||
const paymentMethodDetails = charge?.payment_method_details;
|
||||
|
||||
let paymentMethodBrand = null;
|
||||
let paymentMethodLast4 = null;
|
||||
if (paymentMethodDetails) {
|
||||
const type = paymentMethodDetails.type;
|
||||
if (type === "card") {
|
||||
paymentMethodBrand = paymentMethodDetails.card?.brand || "card";
|
||||
paymentMethodLast4 = paymentMethodDetails.card?.last4 || null;
|
||||
} else if (type === "us_bank_account") {
|
||||
paymentMethodBrand = "bank_account";
|
||||
paymentMethodLast4 = paymentMethodDetails.us_bank_account?.last4 || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Payment succeeded - complete rental confirmation
|
||||
await rental.update({
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
chargedAt: new Date(),
|
||||
paymentMethodBrand,
|
||||
paymentMethodLast4,
|
||||
});
|
||||
|
||||
// Send confirmation emails
|
||||
try {
|
||||
await emailServices.rentalFlow.sendRentalApprovalConfirmationEmail(
|
||||
rental.owner,
|
||||
rental.renter,
|
||||
rental
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental approval confirmation sent to owner (after 3DS)", {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send rental approval confirmation email after 3DS",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: rental.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const renterNotification = {
|
||||
type: "rental_confirmed",
|
||||
title: "Rental Confirmed",
|
||||
message: `Your rental of "${rental.item.name}" has been confirmed.`,
|
||||
rentalId: rental.id,
|
||||
userId: rental.renterId,
|
||||
metadata: { rentalStart: rental.startDateTime },
|
||||
};
|
||||
await emailServices.rentalFlow.sendRentalConfirmation(
|
||||
rental.renter.email,
|
||||
renterNotification,
|
||||
rental,
|
||||
rental.renter.firstName,
|
||||
true
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental confirmation sent to renter (after 3DS)", {
|
||||
rentalId: rental.id,
|
||||
renterId: rental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error(
|
||||
"Failed to send rental confirmation email after 3DS",
|
||||
{
|
||||
error: emailError.message,
|
||||
rentalId: rental.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger payout if owner has payouts enabled
|
||||
if (rental.owner.stripePayoutsEnabled && rental.owner.stripeConnectedAccountId) {
|
||||
try {
|
||||
await PayoutService.processRentalPayout(rental);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Payout processed after 3DS completion", {
|
||||
rentalId: rental.id,
|
||||
ownerId: rental.ownerId,
|
||||
});
|
||||
} catch (payoutError) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Payout failed after 3DS completion", {
|
||||
error: payoutError.message,
|
||||
rentalId: rental.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
rental: {
|
||||
id: rental.id,
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Complete payment error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1209,6 +1209,50 @@ class RentalFlowEmailService {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send authentication required email to renter when 3DS verification is needed
|
||||
* This is sent when the owner approves a rental but the renter's bank requires
|
||||
* additional verification (3D Secure) to complete the payment.
|
||||
*
|
||||
* @param {string} email - Renter's email address
|
||||
* @param {Object} data - Email data
|
||||
* @param {string} data.renterName - Renter's first name
|
||||
* @param {string} data.itemName - Name of the item being rented
|
||||
* @param {string} data.ownerName - Owner's first name
|
||||
* @param {number} data.amount - Total rental amount
|
||||
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||
*/
|
||||
async sendAuthenticationRequiredEmail(email, data) {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
try {
|
||||
const { renterName, itemName, ownerName, amount } = data;
|
||||
|
||||
const variables = {
|
||||
renterName: renterName || "there",
|
||||
itemName: itemName || "the item",
|
||||
ownerName: ownerName || "The owner",
|
||||
amount: typeof amount === "number" ? amount.toFixed(2) : "0.00",
|
||||
};
|
||||
|
||||
const htmlContent = await this.templateManager.renderTemplate(
|
||||
"authenticationRequiredToRenter",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.emailClient.sendEmail(
|
||||
email,
|
||||
`Action Required: Complete payment for ${itemName}`,
|
||||
htmlContent
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send authentication required email:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RentalFlowEmailService;
|
||||
|
||||
@@ -3,14 +3,16 @@ const logger = require("../utils/logger");
|
||||
const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
|
||||
|
||||
class StripeService {
|
||||
|
||||
static async getCheckoutSession(sessionId) {
|
||||
try {
|
||||
return await stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ['setup_intent', 'setup_intent.payment_method']
|
||||
expand: ["setup_intent", "setup_intent.payment_method"],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving checkout session", { error: error.message, stack: error.stack });
|
||||
logger.error("Error retrieving checkout session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +30,10 @@ class StripeService {
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
logger.error("Error creating connected account", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating connected account", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +49,10 @@ class StripeService {
|
||||
|
||||
return accountLink;
|
||||
} catch (error) {
|
||||
logger.error("Error creating account link", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating account link", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -60,7 +68,10 @@ class StripeService {
|
||||
requirements: account.requirements,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving account status", { error: error.message, stack: error.stack });
|
||||
logger.error("Error retrieving account status", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +87,10 @@ class StripeService {
|
||||
|
||||
return accountSession;
|
||||
} catch (error) {
|
||||
logger.error("Error creating account session", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating account session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -97,7 +111,10 @@ class StripeService {
|
||||
|
||||
return transfer;
|
||||
} catch (error) {
|
||||
logger.error("Error creating transfer", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating transfer", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -118,7 +135,10 @@ class StripeService {
|
||||
|
||||
return refund;
|
||||
} catch (error) {
|
||||
logger.error("Error creating refund", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating refund", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -127,12 +147,20 @@ class StripeService {
|
||||
try {
|
||||
return await stripe.refunds.retrieve(refundId);
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving refund", { error: error.message, stack: error.stack });
|
||||
logger.error("Error retrieving refund", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) {
|
||||
static async chargePaymentMethod(
|
||||
paymentMethodId,
|
||||
amount,
|
||||
customerId,
|
||||
metadata = {}
|
||||
) {
|
||||
try {
|
||||
// Create a payment intent with the stored payment method
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
@@ -142,49 +170,71 @@ class StripeService {
|
||||
customer: customerId, // Include customer ID
|
||||
confirm: true, // Automatically confirm the payment
|
||||
off_session: true, // Indicate this is an off-session payment
|
||||
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
|
||||
return_url: `${
|
||||
process.env.FRONTEND_URL || "http://localhost:3000"
|
||||
}/complete-payment`,
|
||||
metadata,
|
||||
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
|
||||
expand: ["latest_charge.payment_method_details"], // Expand to get payment method details
|
||||
});
|
||||
|
||||
// Extract payment method details from charges
|
||||
const charge = paymentIntent.charges?.data?.[0];
|
||||
// Check if additional authentication is required
|
||||
if (paymentIntent.status === "requires_action") {
|
||||
return {
|
||||
status: "requires_action",
|
||||
requiresAction: true,
|
||||
paymentIntentId: paymentIntent.id,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract payment method details from latest_charge
|
||||
const charge = paymentIntent.latest_charge;
|
||||
const paymentMethodDetails = charge?.payment_method_details;
|
||||
|
||||
// Build payment method info object
|
||||
let paymentMethod = null;
|
||||
if (paymentMethodDetails) {
|
||||
const type = paymentMethodDetails.type;
|
||||
if (type === 'card') {
|
||||
if (type === "card") {
|
||||
paymentMethod = {
|
||||
type: 'card',
|
||||
brand: paymentMethodDetails.card?.brand || 'card',
|
||||
last4: paymentMethodDetails.card?.last4 || '****',
|
||||
type: "card",
|
||||
brand: paymentMethodDetails.card?.brand || "card",
|
||||
last4: paymentMethodDetails.card?.last4 || "****",
|
||||
};
|
||||
} else if (type === 'us_bank_account') {
|
||||
} else if (type === "us_bank_account") {
|
||||
paymentMethod = {
|
||||
type: 'bank',
|
||||
brand: 'bank_account',
|
||||
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
|
||||
type: "bank",
|
||||
brand: "bank_account",
|
||||
last4: paymentMethodDetails.us_bank_account?.last4 || "****",
|
||||
};
|
||||
} else {
|
||||
paymentMethod = {
|
||||
type: type || 'unknown',
|
||||
brand: type || 'payment',
|
||||
type: type || "unknown",
|
||||
brand: type || "payment",
|
||||
last4: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
paymentIntentId: paymentIntent.id,
|
||||
status: paymentIntent.status,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
paymentMethod: paymentMethod,
|
||||
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
|
||||
amountCharged: amount, // Original amount in dollars
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle authentication_required error (thrown for off-session 3DS)
|
||||
if (error.code === "authentication_required") {
|
||||
return {
|
||||
status: "requires_action",
|
||||
requiresAction: true,
|
||||
paymentIntentId: error.payment_intent?.id,
|
||||
clientSecret: error.payment_intent?.client_secret,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse Stripe error into structured format
|
||||
const parsedError = parseStripeError(error);
|
||||
|
||||
@@ -213,17 +263,22 @@ class StripeService {
|
||||
|
||||
return customer;
|
||||
} catch (error) {
|
||||
logger.error("Error creating customer", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating customer", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async getPaymentMethod(paymentMethodId) {
|
||||
try {
|
||||
return await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
} catch (error) {
|
||||
logger.error("Error retrieving payment method", { error: error.message, paymentMethodId });
|
||||
logger.error("Error retrieving payment method", {
|
||||
error: error.message,
|
||||
paymentMethodId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -232,19 +287,28 @@ class StripeService {
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ['card', 'us_bank_account', 'link'],
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
payment_method_types: ["card", "us_bank_account", "link"],
|
||||
mode: "setup",
|
||||
ui_mode: "embedded",
|
||||
redirect_on_completion: "never",
|
||||
// Configure for off-session usage - triggers 3DS during setup
|
||||
payment_method_options: {
|
||||
card: {
|
||||
request_three_d_secure: "any",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
...metadata
|
||||
}
|
||||
type: "payment_method_setup",
|
||||
...metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.error("Error creating setup checkout session", { error: error.message, stack: error.stack });
|
||||
logger.error("Error creating setup checkout session", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal file
296
backend/templates/emails/authenticationRequiredToRenter.html
Normal file
@@ -0,0 +1,296 @@
|
||||
<!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, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Warning box */
|
||||
.warning-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">Village Share</div>
|
||||
<div class="tagline">Action Required: Complete Your Payment</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{renterName}},</p>
|
||||
|
||||
<p>
|
||||
Great news! <strong>{{ownerName}}</strong> has approved your rental
|
||||
request for <strong>{{itemName}}</strong>.
|
||||
</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<p><strong>Your bank requires additional verification</strong></p>
|
||||
<p>
|
||||
To complete the payment and confirm your rental, your bank needs you
|
||||
to verify your identity. This is a security measure to protect your
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Rental Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Amount</th>
|
||||
<td>${{amount}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>How to Complete Your Payment</strong></p>
|
||||
<ol style="margin: 10px 0; padding-left: 20px; color: #004085">
|
||||
<li>
|
||||
Go directly to <strong>village-share.com</strong> in your browser
|
||||
</li>
|
||||
<li>Log in to your account</li>
|
||||
<li>Navigate to <strong>My Rentals</strong> from the navigation menu</li>
|
||||
<li>
|
||||
Find the rental for <strong>{{itemName}}</strong>
|
||||
</li>
|
||||
<li>Click <strong>"Complete Payment"</strong> and follow your bank's verification steps</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The verification process usually takes less than a minute. Once
|
||||
complete, your rental will be confirmed and you'll receive a
|
||||
confirmation email.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you did not request this rental, please ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Village Share</strong></p>
|
||||
<p>
|
||||
This is a notification about your rental request. You received this
|
||||
message because the owner approved your rental and your bank requires
|
||||
additional 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>
|
||||
@@ -6,6 +6,13 @@
|
||||
*/
|
||||
|
||||
const DECLINE_MESSAGES = {
|
||||
authentication_required: {
|
||||
ownerMessage: "The renter's card requires additional authentication.",
|
||||
renterMessage: "Your card requires authentication to complete this payment.",
|
||||
canOwnerRetry: false,
|
||||
requiresNewPaymentMethod: false,
|
||||
requires3DS: true,
|
||||
},
|
||||
insufficient_funds: {
|
||||
ownerMessage: "The renter's card has insufficient funds.",
|
||||
renterMessage: "Your card has insufficient funds.",
|
||||
|
||||
Reference in New Issue
Block a user