3D Secure handling

This commit is contained in:
jackiettran
2026-01-08 12:44:57 -05:00
parent 8b9b92d848
commit bcb917c959
14 changed files with 1093 additions and 40 deletions

View File

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

View File

@@ -67,7 +67,7 @@ const Rental = sequelize.define("Rental", {
allowNull: false, allowNull: false,
}, },
paymentStatus: { paymentStatus: {
type: DataTypes.ENUM("pending", "paid", "refunded", "not_required"), type: DataTypes.ENUM("pending", "paid", "refunded", "not_required", "requires_action"),
allowNull: false, allowNull: false,
}, },
payoutStatus: { payoutStatus: {

View File

@@ -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 // Update rental with payment completion
await rental.update({ await rental.update({
status: "confirmed", 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; module.exports = router;

View File

@@ -1209,6 +1209,50 @@ class RentalFlowEmailService {
return { success: false, error: error.message }; 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; module.exports = RentalFlowEmailService;

View File

@@ -3,14 +3,16 @@ const logger = require("../utils/logger");
const { parseStripeError, PaymentError } = require("../utils/stripeErrors"); const { parseStripeError, PaymentError } = require("../utils/stripeErrors");
class StripeService { class StripeService {
static async getCheckoutSession(sessionId) { static async getCheckoutSession(sessionId) {
try { try {
return await stripe.checkout.sessions.retrieve(sessionId, { return await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['setup_intent', 'setup_intent.payment_method'] expand: ["setup_intent", "setup_intent.payment_method"],
}); });
} catch (error) { } 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; throw error;
} }
} }
@@ -28,7 +30,10 @@ class StripeService {
return account; return account;
} catch (error) { } 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; throw error;
} }
} }
@@ -44,7 +49,10 @@ class StripeService {
return accountLink; return accountLink;
} catch (error) { } 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; throw error;
} }
} }
@@ -60,7 +68,10 @@ class StripeService {
requirements: account.requirements, requirements: account.requirements,
}; };
} catch (error) { } 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; throw error;
} }
} }
@@ -76,7 +87,10 @@ class StripeService {
return accountSession; return accountSession;
} catch (error) { } 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; throw error;
} }
} }
@@ -97,7 +111,10 @@ class StripeService {
return transfer; return transfer;
} catch (error) { } 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; throw error;
} }
} }
@@ -118,7 +135,10 @@ class StripeService {
return refund; return refund;
} catch (error) { } 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; throw error;
} }
} }
@@ -127,12 +147,20 @@ class StripeService {
try { try {
return await stripe.refunds.retrieve(refundId); return await stripe.refunds.retrieve(refundId);
} catch (error) { } 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; throw error;
} }
} }
static async chargePaymentMethod(paymentMethodId, amount, customerId, metadata = {}) { static async chargePaymentMethod(
paymentMethodId,
amount,
customerId,
metadata = {}
) {
try { try {
// Create a payment intent with the stored payment method // Create a payment intent with the stored payment method
const paymentIntent = await stripe.paymentIntents.create({ const paymentIntent = await stripe.paymentIntents.create({
@@ -142,49 +170,71 @@ class StripeService {
customer: customerId, // Include customer ID customer: customerId, // Include customer ID
confirm: true, // Automatically confirm the payment confirm: true, // Automatically confirm the payment
off_session: true, // Indicate this is an off-session 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, 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 // Check if additional authentication is required
const charge = paymentIntent.charges?.data?.[0]; 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; const paymentMethodDetails = charge?.payment_method_details;
// Build payment method info object // Build payment method info object
let paymentMethod = null; let paymentMethod = null;
if (paymentMethodDetails) { if (paymentMethodDetails) {
const type = paymentMethodDetails.type; const type = paymentMethodDetails.type;
if (type === 'card') { if (type === "card") {
paymentMethod = { paymentMethod = {
type: 'card', type: "card",
brand: paymentMethodDetails.card?.brand || 'card', brand: paymentMethodDetails.card?.brand || "card",
last4: paymentMethodDetails.card?.last4 || '****', last4: paymentMethodDetails.card?.last4 || "****",
}; };
} else if (type === 'us_bank_account') { } else if (type === "us_bank_account") {
paymentMethod = { paymentMethod = {
type: 'bank', type: "bank",
brand: 'bank_account', brand: "bank_account",
last4: paymentMethodDetails.us_bank_account?.last4 || '****', last4: paymentMethodDetails.us_bank_account?.last4 || "****",
}; };
} else { } else {
paymentMethod = { paymentMethod = {
type: type || 'unknown', type: type || "unknown",
brand: type || 'payment', brand: type || "payment",
last4: null, last4: null,
}; };
} }
} }
return { return {
status: "succeeded",
paymentIntentId: paymentIntent.id, paymentIntentId: paymentIntent.id,
status: paymentIntent.status,
clientSecret: paymentIntent.client_secret, clientSecret: paymentIntent.client_secret,
paymentMethod: paymentMethod, paymentMethod: paymentMethod,
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
amountCharged: amount, // Original amount in dollars amountCharged: amount, // Original amount in dollars
}; };
} catch (error) { } 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 // Parse Stripe error into structured format
const parsedError = parseStripeError(error); const parsedError = parseStripeError(error);
@@ -213,17 +263,22 @@ class StripeService {
return customer; return customer;
} catch (error) { } 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; throw error;
} }
} }
static async getPaymentMethod(paymentMethodId) { static async getPaymentMethod(paymentMethodId) {
try { try {
return await stripe.paymentMethods.retrieve(paymentMethodId); return await stripe.paymentMethods.retrieve(paymentMethodId);
} catch (error) { } catch (error) {
logger.error("Error retrieving payment method", { error: error.message, paymentMethodId }); logger.error("Error retrieving payment method", {
error: error.message,
paymentMethodId,
});
throw error; throw error;
} }
} }
@@ -232,19 +287,28 @@ class StripeService {
try { try {
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
customer: customerId, customer: customerId,
payment_method_types: ['card', 'us_bank_account', 'link'], payment_method_types: ["card", "us_bank_account", "link"],
mode: 'setup', mode: "setup",
ui_mode: 'embedded', ui_mode: "embedded",
redirect_on_completion: 'never', redirect_on_completion: "never",
// Configure for off-session usage - triggers 3DS during setup
payment_method_options: {
card: {
request_three_d_secure: "any",
},
},
metadata: { metadata: {
type: 'payment_method_setup', type: "payment_method_setup",
...metadata ...metadata,
} },
}); });
return session; return session;
} catch (error) { } 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; throw error;
} }
} }

View 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>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -6,6 +6,13 @@
*/ */
const DECLINE_MESSAGES = { 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: { insufficient_funds: {
ownerMessage: "The renter's card has insufficient funds.", ownerMessage: "The renter's card has insufficient funds.",
renterMessage: "Your card has insufficient funds.", renterMessage: "Your card has insufficient funds.",

View File

@@ -26,6 +26,7 @@ import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost'; import CreateForumPost from './pages/CreateForumPost';
import MyPosts from './pages/MyPosts'; import MyPosts from './pages/MyPosts';
import EarningsDashboard from './pages/EarningsDashboard'; import EarningsDashboard from './pages/EarningsDashboard';
import CompletePayment from './pages/CompletePayment';
import FAQ from './pages/FAQ'; import FAQ from './pages/FAQ';
import NotFound from './pages/NotFound'; import NotFound from './pages/NotFound';
import PrivateRoute from './components/PrivateRoute'; import PrivateRoute from './components/PrivateRoute';
@@ -126,6 +127,14 @@ const AppContent: React.FC = () => {
</PrivateRoute> </PrivateRoute>
} }
/> />
<Route
path="/complete-payment/:rentalId"
element={
<PrivateRoute>
<CompletePayment />
</PrivateRoute>
}
/>
<Route <Route
path="/owning" path="/owning"
element={ element={

View File

@@ -0,0 +1,73 @@
import React from "react";
interface AuthenticationRequiredModalProps {
rental: {
renter?: { firstName?: string; lastName?: string; email?: string };
item?: { name?: string };
};
onClose: () => void;
}
const AuthenticationRequiredModal: React.FC<
AuthenticationRequiredModalProps
> = ({ rental, onClose }) => {
const renterName =
`${rental.renter?.firstName || ""} ${rental.renter?.lastName || ""}`.trim() ||
"The renter";
const renterEmail = rental.renter?.email || "their email";
const itemName = rental.item?.name || "the item";
return (
<div
className="modal fade show d-block"
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
tabIndex={-1}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-shield-lock text-warning me-2"></i>
Authentication Required
</h5>
<button
type="button"
className="btn-close"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<p>
<strong>{renterName}</strong>'s bank requires additional
authentication to complete the payment for{" "}
<strong>{itemName}</strong>.
</p>
<div className="alert alert-info mb-3">
<i className="bi bi-envelope me-2"></i>
We've sent an email to <strong>{renterEmail}</strong> with
instructions to complete the authentication.
</div>
<p className="text-muted mb-0">
Once they complete the authentication, the rental will be
automatically confirmed and you'll receive a notification.
</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
onClick={onClose}
>
Got it
</button>
</div>
</div>
</div>
</div>
);
};
export default AuthenticationRequiredModal;

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useState, useCallback, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { loadStripe } from "@stripe/stripe-js";
import { rentalAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
);
const CompletePayment: React.FC = () => {
const { rentalId } = useParams<{ rentalId: string }>();
const navigate = useNavigate();
const { user, loading: authLoading } = useAuth();
const [status, setStatus] = useState<
"loading" | "authenticating" | "success" | "error"
>("loading");
const [error, setError] = useState<string | null>(null);
const hasProcessed = useRef(false);
const handleAuthentication = useCallback(async () => {
if (!rentalId) {
setError("Invalid rental ID");
setStatus("error");
return;
}
try {
// Get client secret
const secretResponse = await rentalAPI.getPaymentClientSecret(rentalId);
const { clientSecret, status: piStatus } = secretResponse.data;
if (piStatus === "succeeded") {
// Already succeeded, just complete on backend
await rentalAPI.completePayment(rentalId);
setStatus("success");
return;
}
setStatus("authenticating");
// Initialize Stripe and confirm payment
const stripe = await stripePromise;
if (!stripe) {
throw new Error("Stripe failed to load");
}
const { error: stripeError, paymentIntent } =
await stripe.confirmCardPayment(clientSecret);
if (stripeError) {
setError(stripeError.message || "Authentication failed");
setStatus("error");
return;
}
if (paymentIntent?.status === "succeeded") {
await rentalAPI.completePayment(rentalId);
setStatus("success");
} else {
setError("Payment could not be completed. Please try again.");
setStatus("error");
}
} catch (err: any) {
const errorMessage =
err.response?.data?.message ||
err.response?.data?.error ||
err.message ||
"An error occurred";
// Handle specific error cases
if (err.response?.status === 400) {
if (
err.response?.data?.message?.includes("not awaiting payment")
) {
// Rental is not in requires_action state - redirect to rentals
navigate("/renting", { replace: true });
return;
}
}
setError(errorMessage);
setStatus("error");
}
}, [rentalId, navigate]);
useEffect(() => {
// Wait for auth to finish loading
if (authLoading) return;
// If not logged in, redirect to login
if (!user) {
const returnUrl = `/complete-payment/${rentalId}`;
navigate(`/?login=true&redirect=${encodeURIComponent(returnUrl)}`, {
replace: true,
});
return;
}
// Prevent double execution in React StrictMode
if (hasProcessed.current) return;
hasProcessed.current = true;
if (rentalId) {
handleAuthentication();
}
}, [rentalId, handleAuthentication, user, authLoading, navigate]);
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6 text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h5>Loading...</h5>
</div>
</div>
</div>
);
}
if (status === "loading" || status === "authenticating") {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h4>
{status === "loading"
? "Loading payment details..."
: "Completing authentication..."}
</h4>
<p className="text-muted">
{status === "authenticating"
? "Please complete the authentication when prompted by your bank."
: "Please wait while we retrieve your payment information."}
</p>
</div>
</div>
</div>
</div>
</div>
);
}
if (status === "success") {
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-4">
<i
className="bi bi-check-circle-fill text-success"
style={{ fontSize: "4rem" }}
></i>
</div>
<h3>Payment Complete!</h3>
<p className="text-muted mb-4">
Your rental has been confirmed. You'll receive a confirmation
email shortly.
</p>
<Link to="/renting" className="btn btn-primary">
View My Rentals
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
// Error state
return (
<div className="container py-5">
<div className="row justify-content-center">
<div className="col-md-6">
<div className="card">
<div className="card-body text-center py-5">
<div className="mb-4">
<i
className="bi bi-x-circle-fill text-danger"
style={{ fontSize: "4rem" }}
></i>
</div>
<h3>Payment Failed</h3>
<p className="text-muted mb-4">{error}</p>
<div className="d-flex gap-2 justify-content-center">
<button
className="btn btn-primary"
onClick={() => {
hasProcessed.current = false;
setStatus("loading");
setError(null);
handleAuthentication();
}}
>
Try Again
</button>
<Link to="/renting" className="btn btn-outline-secondary">
View My Rentals
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompletePayment;

View File

@@ -12,6 +12,7 @@ import ConditionCheckModal from "../components/ConditionCheckModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal"; import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
import ReturnStatusModal from "../components/ReturnStatusModal"; import ReturnStatusModal from "../components/ReturnStatusModal";
import PaymentFailedModal from "../components/PaymentFailedModal"; import PaymentFailedModal from "../components/PaymentFailedModal";
import AuthenticationRequiredModal from "../components/AuthenticationRequiredModal";
const Owning: React.FC = () => { const Owning: React.FC = () => {
// Helper function to format time // Helper function to format time
@@ -77,6 +78,8 @@ const Owning: React.FC = () => {
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false); const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
const [paymentFailedError, setPaymentFailedError] = useState<any>(null); const [paymentFailedError, setPaymentFailedError] = useState<any>(null);
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null); const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null);
const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(null);
useEffect(() => { useEffect(() => {
fetchListings(); fetchListings();
@@ -220,8 +223,17 @@ const Owning: React.FC = () => {
} catch (err: any) { } catch (err: any) {
console.error("Failed to accept rental request:", err); console.error("Failed to accept rental request:", err);
// Check if 3DS authentication is required
if (err.response?.data?.error === "authentication_required") {
// Find the rental to show in the modal
const rental = ownerRentals.find((r) => r.id === rentalId);
setAuthRequiredRental(rental || null);
setShowAuthRequiredModal(true);
// Refresh rentals to update status
fetchOwnerRentals();
}
// Check if it's a payment failure (HTTP 402 or payment_failed error) // Check if it's a payment failure (HTTP 402 or payment_failed error)
if ( else if (
err.response?.status === 402 || err.response?.status === 402 ||
err.response?.data?.error === "payment_failed" err.response?.data?.error === "payment_failed"
) { ) {
@@ -867,6 +879,18 @@ const Owning: React.FC = () => {
/> />
)} )}
{/* Authentication Required Modal (3DS) */}
{showAuthRequiredModal && authRequiredRental && (
<AuthenticationRequiredModal
rental={authRequiredRental}
onClose={() => {
setShowAuthRequiredModal(false);
setAuthRequiredRental(null);
fetchOwnerRentals();
}}
/>
)}
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
{showDeleteModal && ( {showDeleteModal && (
<div <div

View File

@@ -401,6 +401,30 @@ const Renting: React.FC = () => {
</div> </div>
)} )}
{/* 3DS Authentication Required Alert */}
{rental.paymentStatus === "requires_action" && (
<div className="alert alert-warning py-2 mb-3">
<div className="d-flex align-items-center justify-content-between">
<div>
<i className="bi bi-shield-lock me-2"></i>
<strong>Payment authentication required</strong>
<p className="mb-0 small text-muted mt-1">
Your bank requires additional verification to
complete this payment.
</p>
</div>
<button
className="btn btn-warning btn-sm ms-3"
onClick={() =>
navigate(`/complete-payment/${rental.id}`)
}
>
Complete Payment
</button>
</div>
</div>
)}
<div className="d-flex flex-column gap-2 mt-3"> <div className="d-flex flex-column gap-2 mt-3">
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{((rental.displayStatus || rental.status) === "pending" || {((rental.displayStatus || rental.status) === "pending" ||

View File

@@ -246,6 +246,11 @@ export const rentalAPI = {
}) => api.post("/rentals/cost-preview", data), }) => api.post("/rentals/cost-preview", data),
updatePaymentMethod: (id: string, stripePaymentMethodId: string) => updatePaymentMethod: (id: string, stripePaymentMethodId: string) =>
api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }), api.put(`/rentals/${id}/payment-method`, { stripePaymentMethodId }),
// 3DS authentication endpoints
getPaymentClientSecret: (rentalId: string) =>
api.get(`/rentals/${rentalId}/payment-client-secret`),
completePayment: (rentalId: string) =>
api.post(`/rentals/${rentalId}/complete-payment`),
}; };
export const messageAPI = { export const messageAPI = {

View File

@@ -137,7 +137,7 @@ export interface Rental {
status: RentalStatus; status: RentalStatus;
// Computed status (includes "active" when confirmed + start time passed) // Computed status (includes "active" when confirmed + start time passed)
displayStatus?: RentalStatus; displayStatus?: RentalStatus;
paymentStatus: "pending" | "paid" | "refunded"; paymentStatus: "pending" | "paid" | "refunded" | "requires_action";
// Refund tracking fields // Refund tracking fields
refundAmount?: number; refundAmount?: number;
refundProcessedAt?: string; refundProcessedAt?: string;