failed payment method handling

This commit is contained in:
jackiettran
2026-01-06 16:13:58 -05:00
parent ec84b8354e
commit 28c0b4976d
14 changed files with 1639 additions and 17 deletions

View 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">&#9888;</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;

View 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;

View 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;

View File

@@ -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;