A declined payment that locks the checkout form, redirects to a dead-end error page, or crashes the component turns a recoverable event into an abandoned cart. Customers whose first card triggers a fraud hold or hits an insufficient-funds block cannot switch to a second card without restarting the entire flow, which inflates checkout abandonment on an experience already covered by user-experience and error-resilience taxons. For high-ticket orders this converts one soft decline into lost revenue and a support ticket, and repeated decline-and-reload loops can trip Stripe Radar rules that escalate future attempts to hard blocks.
Low because the control has no security or data-integrity impact; the damage is conversion loss and friction, not exposure or fraud.
Handle Stripe errors by category instead of treating every failure as terminal. Keep isProcessing scoped so the form re-enables after the promise resolves, show a generic recovery message for card_error (not the raw decline code), and leave inputs mounted so the customer can swap cards and retry without a page reload. Wire this through the component that calls stripe.confirmPayment, for example src/components/checkout/PaymentForm.tsx:
const { error, paymentIntent } = await stripe.confirmPayment({ /* ... */ })
setIsProcessing(false)
if (error?.type === 'card_error') {
setError('Your card was declined. Try a different card or contact your bank.')
return
}
ID: ecommerce-payment-security.payment-errors.graceful-decline-handling
Severity: low
What to look for: Enumerate all error handling paths in the client-side payment flow after a decline. For each path, verify at least 3 elements: (1) the checkout form remains usable after a decline, (2) the user receives a helpful message suggesting a next step, (3) the user can retry without refreshing the page. Count the total decline handling paths and classify each as graceful (form stays usable) or broken (form locks, redirects, or crashes).
Pass criteria: After a payment decline, the checkout form remains usable (not locked or redirected away). The user receives a helpful message that suggests a next step (try a different card, contact their bank) without exposing the specific decline code. The user can retry without refreshing the page.
Fail criteria: A declined payment either locks the checkout form permanently, redirects to a generic error page with no recovery path, or crashes the component. Or the user receives no guidance on what to do next.
Skip (N/A) when: The project has no client-side payment form (uses fully hosted payment page).
Detail on fail: Describe the problematic behavior. Example: "On card decline, checkout form is set to isComplete=true and redirects to /checkout/success without checking paymentIntent.status — declined payments appear successful to the UI"
Remediation: Handle decline errors gracefully and keep the form in a retriable state:
const handlePayment = async () => {
setIsProcessing(true)
setError(null)
const { error, paymentIntent } = await stripe.confirmPayment({ /* ... */ })
setIsProcessing(false)
if (error) {
// Show a helpful, generic message based on error category
if (error.type === 'card_error') {
setError('Your payment was declined. Please try a different card or contact your bank.')
} else {
setError('Something went wrong. Please try again.')
}
// Form remains usable — user can correct and retry
return
}
if (paymentIntent.status === 'succeeded') {
router.push('/order/complete')
}
}