diff --git a/backend/migrations/20260107000001-add-requires-action-payment-status.js b/backend/migrations/20260107000001-add-requires-action-payment-status.js
new file mode 100644
index 0000000..d26f930
--- /dev/null
+++ b/backend/migrations/20260107000001-add-requires-action-payment-status.js
@@ -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."
+ );
+ },
+};
diff --git a/backend/models/Rental.js b/backend/models/Rental.js
index 359e588..5851990 100644
--- a/backend/models/Rental.js
+++ b/backend/models/Rental.js
@@ -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: {
diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js
index e5305c1..a27276a 100644
--- a/backend/routes/rentals.js
+++ b/backend/routes/rentals.js
@@ -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;
diff --git a/backend/services/email/domain/RentalFlowEmailService.js b/backend/services/email/domain/RentalFlowEmailService.js
index ac46b4e..7834df0 100644
--- a/backend/services/email/domain/RentalFlowEmailService.js
+++ b/backend/services/email/domain/RentalFlowEmailService.js
@@ -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;
diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js
index a73f4cc..6fa2263 100644
--- a/backend/services/stripeService.js
+++ b/backend/services/stripeService.js
@@ -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;
}
}
diff --git a/backend/templates/emails/authenticationRequiredToRenter.html b/backend/templates/emails/authenticationRequiredToRenter.html
new file mode 100644
index 0000000..b061c14
--- /dev/null
+++ b/backend/templates/emails/authenticationRequiredToRenter.html
@@ -0,0 +1,296 @@
+
+
+
+
+
+
+ Action Required - Village Share
+
+
+
+
+
+
+
+
Hi {{renterName}},
+
+
+ Great news! {{ownerName}} has approved your rental
+ request for {{itemName}} .
+
+
+
+
Your bank requires additional verification
+
+ 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.
+
+
+
+
Rental Details
+
+
+ Item
+ {{itemName}}
+
+
+ Amount
+ ${{amount}}
+
+
+
+
+
How to Complete Your Payment
+
+
+ Go directly to village-share.com in your browser
+
+ Log in to your account
+ Navigate to My Rentals from the navigation menu
+
+ Find the rental for {{itemName}}
+
+ Click "Complete Payment" and follow your bank's verification steps
+
+
+
+
+ The verification process usually takes less than a minute. Once
+ complete, your rental will be confirmed and you'll receive a
+ confirmation email.
+
+
+
+ If you did not request this rental, please ignore this email.
+
+
+
+
+
+
+
diff --git a/backend/utils/stripeErrors.js b/backend/utils/stripeErrors.js
index 8c23a74..927a895 100644
--- a/backend/utils/stripeErrors.js
+++ b/backend/utils/stripeErrors.js
@@ -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.",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 7dfc91b..fd159f7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -26,6 +26,7 @@ import ForumPostDetail from './pages/ForumPostDetail';
import CreateForumPost from './pages/CreateForumPost';
import MyPosts from './pages/MyPosts';
import EarningsDashboard from './pages/EarningsDashboard';
+import CompletePayment from './pages/CompletePayment';
import FAQ from './pages/FAQ';
import NotFound from './pages/NotFound';
import PrivateRoute from './components/PrivateRoute';
@@ -126,6 +127,14 @@ const AppContent: React.FC = () => {
}
/>
+
+
+
+ }
+ />
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 (
+ {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
+
+
+
+
+ Authentication Required
+
+
+
+
+
+ {renterName} 's bank requires additional
+ authentication to complete the payment for{" "}
+ {itemName} .
+
+
+
+ We've sent an email to {renterEmail} with
+ instructions to complete the authentication.
+
+
+ Once they complete the authentication, the rental will be
+ automatically confirmed and you'll receive a notification.
+
+
+
+
+ Got it
+
+
+
+
+
+ );
+};
+
+export default AuthenticationRequiredModal;
diff --git a/frontend/src/pages/CompletePayment.tsx b/frontend/src/pages/CompletePayment.tsx
new file mode 100644
index 0000000..51e8bc5
--- /dev/null
+++ b/frontend/src/pages/CompletePayment.tsx
@@ -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(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 (
+
+
+
+
+ Loading...
+
+
Loading...
+
+
+
+ );
+ }
+
+ if (status === "loading" || status === "authenticating") {
+ return (
+
+
+
+
+
+
+ Loading...
+
+
+ {status === "loading"
+ ? "Loading payment details..."
+ : "Completing authentication..."}
+
+
+ {status === "authenticating"
+ ? "Please complete the authentication when prompted by your bank."
+ : "Please wait while we retrieve your payment information."}
+
+
+
+
+
+
+ );
+ }
+
+ if (status === "success") {
+ return (
+
+
+
+
+
+
+
+
+
Payment Complete!
+
+ Your rental has been confirmed. You'll receive a confirmation
+ email shortly.
+
+
+ View My Rentals
+
+
+
+
+
+
+ );
+ }
+
+ // Error state
+ return (
+
+
+
+
+
+
+
+
+
Payment Failed
+
{error}
+
+ {
+ hasProcessed.current = false;
+ setStatus("loading");
+ setError(null);
+ handleAuthentication();
+ }}
+ >
+ Try Again
+
+
+ View My Rentals
+
+
+
+
+
+
+
+ );
+};
+
+export default CompletePayment;
diff --git a/frontend/src/pages/Owning.tsx b/frontend/src/pages/Owning.tsx
index 233a3ec..b2a8a39 100644
--- a/frontend/src/pages/Owning.tsx
+++ b/frontend/src/pages/Owning.tsx
@@ -12,6 +12,7 @@ import ConditionCheckModal from "../components/ConditionCheckModal";
import ConditionCheckViewerModal from "../components/ConditionCheckViewerModal";
import ReturnStatusModal from "../components/ReturnStatusModal";
import PaymentFailedModal from "../components/PaymentFailedModal";
+import AuthenticationRequiredModal from "../components/AuthenticationRequiredModal";
const Owning: React.FC = () => {
// Helper function to format time
@@ -77,6 +78,8 @@ const Owning: React.FC = () => {
const [showPaymentFailedModal, setShowPaymentFailedModal] = useState(false);
const [paymentFailedError, setPaymentFailedError] = useState(null);
const [paymentFailedRental, setPaymentFailedRental] = useState(null);
+ const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
+ const [authRequiredRental, setAuthRequiredRental] = useState(null);
useEffect(() => {
fetchListings();
@@ -220,8 +223,17 @@ const Owning: React.FC = () => {
} catch (err: any) {
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)
- if (
+ else if (
err.response?.status === 402 ||
err.response?.data?.error === "payment_failed"
) {
@@ -867,6 +879,18 @@ const Owning: React.FC = () => {
/>
)}
+ {/* Authentication Required Modal (3DS) */}
+ {showAuthRequiredModal && authRequiredRental && (
+ {
+ setShowAuthRequiredModal(false);
+ setAuthRequiredRental(null);
+ fetchOwnerRentals();
+ }}
+ />
+ )}
+
{/* Delete Confirmation Modal */}
{showDeleteModal && (
{
)}
+ {/* 3DS Authentication Required Alert */}
+ {rental.paymentStatus === "requires_action" && (
+
+
+
+
+
Payment authentication required
+
+ Your bank requires additional verification to
+ complete this payment.
+
+
+
+ navigate(`/complete-payment/${rental.id}`)
+ }
+ >
+ Complete Payment
+
+
+
+ )}
+
{((rental.displayStatus || rental.status) === "pending" ||
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 4d640cd..7240b8c 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -246,6 +246,11 @@ export const rentalAPI = {
}) => api.post("/rentals/cost-preview", data),
updatePaymentMethod: (id: string, stripePaymentMethodId: string) =>
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 = {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index e8c6724..e537174 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -137,7 +137,7 @@ export interface Rental {
status: RentalStatus;
// Computed status (includes "active" when confirmed + start time passed)
displayStatus?: RentalStatus;
- paymentStatus: "pending" | "paid" | "refunded";
+ paymentStatus: "pending" | "paid" | "refunded" | "requires_action";
// Refund tracking fields
refundAmount?: number;
refundProcessedAt?: string;