3D Secure handling
This commit is contained in:
221
frontend/src/pages/CompletePayment.tsx
Normal file
221
frontend/src/pages/CompletePayment.tsx
Normal 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;
|
||||
@@ -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<any>(null);
|
||||
const [paymentFailedRental, setPaymentFailedRental] = useState<Rental | null>(null);
|
||||
const [showAuthRequiredModal, setShowAuthRequiredModal] = useState(false);
|
||||
const [authRequiredRental, setAuthRequiredRental] = useState<Rental | null>(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 && (
|
||||
<AuthenticationRequiredModal
|
||||
rental={authRequiredRental}
|
||||
onClose={() => {
|
||||
setShowAuthRequiredModal(false);
|
||||
setAuthRequiredRental(null);
|
||||
fetchOwnerRentals();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div
|
||||
|
||||
@@ -401,6 +401,30 @@ const Renting: React.FC = () => {
|
||||
</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 gap-2">
|
||||
{((rental.displayStatus || rental.status) === "pending" ||
|
||||
|
||||
Reference in New Issue
Block a user