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 + + + +
+
+ +
Action Required: Complete Your Payment
+
+ +
+

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

+
    +
  1. + Go directly to village-share.com in your browser +
  2. +
  3. Log in to your account
  4. +
  5. Navigate to My Rentals from the navigation menu
  6. +
  7. + Find the rental for {{itemName}} +
  8. +
  9. Click "Complete Payment" and follow your bank's verification steps
  10. +
+
+ +

+ 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. +

+
+
+ +
+
+
+
+ ); +}; + +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}

+
+ + + 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. +

+
+ +
+
+ )} +
{((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;