failed payment method handling
This commit is contained in:
82
frontend/src/components/PaymentFailedModal.tsx
Normal file
82
frontend/src/components/PaymentFailedModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
|
||||
interface PaymentFailedError {
|
||||
error: string;
|
||||
code: string;
|
||||
ownerMessage: string;
|
||||
renterMessage: string;
|
||||
rentalId: string;
|
||||
}
|
||||
|
||||
interface PaymentFailedModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
paymentError: PaymentFailedError;
|
||||
itemName: string;
|
||||
}
|
||||
|
||||
const PaymentFailedModal: React.FC<PaymentFailedModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
paymentError,
|
||||
itemName,
|
||||
}) => {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header border-bottom-0">
|
||||
<h5 className="modal-title">
|
||||
<span className="me-2">⚠</span>
|
||||
Payment Failed
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onHide}
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{/* Error Message */}
|
||||
<div className="alert alert-warning mb-3">
|
||||
<p className="mb-0">{paymentError.ownerMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Item Info */}
|
||||
<p className="text-muted mb-3">
|
||||
<strong>Item:</strong> {itemName}
|
||||
</p>
|
||||
|
||||
{/* What Happens Next */}
|
||||
<div className="bg-light p-3 rounded mb-3">
|
||||
<h6 className="mb-2">What happens next?</h6>
|
||||
<p className="mb-0 small text-muted">
|
||||
An email has been sent to the renter with instructions to update
|
||||
their payment method. Once they do, you'll be able to approve
|
||||
the rental.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onHide}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentFailedModal;
|
||||
190
frontend/src/components/UpdatePaymentMethod.tsx
Normal file
190
frontend/src/components/UpdatePaymentMethod.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import {
|
||||
EmbeddedCheckoutProvider,
|
||||
EmbeddedCheckout,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { stripeAPI, rentalAPI } from "../services/api";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
|
||||
);
|
||||
|
||||
interface UpdatePaymentMethodProps {
|
||||
rentalId: string;
|
||||
itemName: string;
|
||||
onSuccess: () => void;
|
||||
onError: (error: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const UpdatePaymentMethod: React.FC<UpdatePaymentMethodProps> = ({
|
||||
rentalId,
|
||||
itemName,
|
||||
onSuccess,
|
||||
onError,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [clientSecret, setClientSecret] = useState<string>("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string>("");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const hasCreatedSession = useRef(false);
|
||||
|
||||
// Use refs to avoid recreating handleComplete when props change
|
||||
const rentalIdRef = useRef(rentalId);
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
const onErrorRef = useRef(onError);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
|
||||
// Keep refs up to date
|
||||
rentalIdRef.current = rentalId;
|
||||
onSuccessRef.current = onSuccess;
|
||||
onErrorRef.current = onError;
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
const createCheckoutSession = useCallback(async () => {
|
||||
// Prevent multiple session creations
|
||||
if (hasCreatedSession.current) return;
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
hasCreatedSession.current = true;
|
||||
|
||||
// Create a setup checkout session without rental data
|
||||
// (we're updating an existing rental, not creating a new one)
|
||||
const response = await stripeAPI.createSetupCheckoutSession({});
|
||||
|
||||
setClientSecret(response.data.clientSecret);
|
||||
setSessionId(response.data.sessionId);
|
||||
} catch (error: any) {
|
||||
hasCreatedSession.current = false; // Reset on error so it can be retried
|
||||
onError(
|
||||
error.response?.data?.error || "Failed to create checkout session"
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => {
|
||||
createCheckoutSession();
|
||||
}, [createCheckoutSession]);
|
||||
|
||||
// Use useCallback with empty deps - refs provide access to latest values
|
||||
const handleComplete = useCallback(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
|
||||
if (!sessionIdRef.current) {
|
||||
throw new Error("No session ID available");
|
||||
}
|
||||
|
||||
// Get the completed checkout session
|
||||
const sessionResponse = await stripeAPI.getCheckoutSession(
|
||||
sessionIdRef.current
|
||||
);
|
||||
const { status: sessionStatus, setup_intent } = sessionResponse.data;
|
||||
|
||||
if (sessionStatus !== "complete") {
|
||||
throw new Error("Payment setup was not completed");
|
||||
}
|
||||
|
||||
if (!setup_intent?.payment_method) {
|
||||
throw new Error("No payment method found in setup intent");
|
||||
}
|
||||
|
||||
// Extract payment method ID - handle both string ID and object cases
|
||||
const paymentMethodId =
|
||||
typeof setup_intent.payment_method === "string"
|
||||
? setup_intent.payment_method
|
||||
: setup_intent.payment_method.id;
|
||||
|
||||
if (!paymentMethodId) {
|
||||
throw new Error("No payment method ID found");
|
||||
}
|
||||
|
||||
// Update the rental's payment method
|
||||
await rentalAPI.updatePaymentMethod(rentalIdRef.current, paymentMethodId);
|
||||
|
||||
onSuccessRef.current();
|
||||
} catch (error: any) {
|
||||
onErrorRef.current(
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
"Failed to update payment method"
|
||||
);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (creating) {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p>Preparing secure checkout...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (updating) {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border mb-3" role="status">
|
||||
<span className="visually-hidden">Updating...</span>
|
||||
</div>
|
||||
<p>Updating payment method...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clientSecret) {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-muted">Unable to load checkout</p>
|
||||
<button className="btn btn-secondary mt-2" onClick={onCancel}>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="update-payment-method">
|
||||
<div className="mb-4">
|
||||
<h5>Update Payment Method</h5>
|
||||
<p className="text-muted">
|
||||
Update your payment method for <strong>{itemName}</strong>. Once
|
||||
updated, the owner will be notified and can re-attempt to approve your
|
||||
rental request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="embedded-checkout">
|
||||
<EmbeddedCheckoutProvider
|
||||
key={clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
onComplete: handleComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<button className="btn btn-link text-muted" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePaymentMethod;
|
||||
70
frontend/src/components/UpdatePaymentMethodModal.tsx
Normal file
70
frontend/src/components/UpdatePaymentMethodModal.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import UpdatePaymentMethod from "./UpdatePaymentMethod";
|
||||
|
||||
interface UpdatePaymentMethodModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
rentalId: string;
|
||||
itemName: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UpdatePaymentMethodModal: React.FC<UpdatePaymentMethodModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
rentalId,
|
||||
itemName,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
setError(null);
|
||||
onSuccess();
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleError = (errorMessage: string) => {
|
||||
setError(errorMessage);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal fade show d-block"
|
||||
tabIndex={-1}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header border-bottom-0">
|
||||
<h5 className="modal-title">Update Payment Method</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onHide}
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3">
|
||||
<small>{error}</small>
|
||||
</div>
|
||||
)}
|
||||
<UpdatePaymentMethod
|
||||
rentalId={rentalId}
|
||||
itemName={itemName}
|
||||
onSuccess={handleSuccess}
|
||||
onError={handleError}
|
||||
onCancel={onHide}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePaymentMethodModal;
|
||||
@@ -151,6 +151,9 @@ export interface Rental {
|
||||
bankDepositAt?: string;
|
||||
stripePayoutId?: string;
|
||||
bankDepositFailureCode?: string;
|
||||
// Payment failure tracking
|
||||
paymentFailedNotifiedAt?: string;
|
||||
paymentMethodUpdatedAt?: string;
|
||||
intendedUse?: string;
|
||||
rating?: number;
|
||||
review?: string;
|
||||
|
||||
Reference in New Issue
Block a user