From 9b13a49bf01788ab151609a0ad5787218b4586d8 Mon Sep 17 00:00:00 2001 From: leamotta Date: Wed, 5 Aug 2026 14:01:10 +0200 Subject: [PATCH] fix failed payment new card --- .../Order/Components/CreditCardPicker.tsx | 4 +++ .../Components/__mocks__/CreditCardPicker.tsx | 23 +++++++++++-- src/Apps/Order/Routes/NewPayment/index.tsx | 13 +++++-- .../Routes/__tests__/NewPayment.jest.tsx | 34 ++++++++++++++++++- 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/Apps/Order/Components/CreditCardPicker.tsx b/src/Apps/Order/Components/CreditCardPicker.tsx index 057e80dda52..8a93d69cc4e 100644 --- a/src/Apps/Order/Components/CreditCardPicker.tsx +++ b/src/Apps/Order/Components/CreditCardPicker.tsx @@ -54,6 +54,7 @@ export interface CreditCardPickerProps { commitMutation: CommitMutation innerRef: React.RefObject tracking: TrackingProp + onCreditCardChange?: () => void } interface CreditCardPickerState { @@ -270,6 +271,8 @@ export class CreditCardPicker extends React.Component< { + this.props.onCreditCardChange?.() + if (val === "new") { this.setState({ creditCardSelection: { type: "new" } }) } else { @@ -322,6 +325,7 @@ export class CreditCardPicker extends React.Component< { + this.props.onCreditCardChange?.() this.setState({ stripeError: response.error as StripeError }) }} required diff --git a/src/Apps/Order/Components/__mocks__/CreditCardPicker.tsx b/src/Apps/Order/Components/__mocks__/CreditCardPicker.tsx index 11062605e5d..2121b4d3f78 100644 --- a/src/Apps/Order/Components/__mocks__/CreditCardPicker.tsx +++ b/src/Apps/Order/Components/__mocks__/CreditCardPicker.tsx @@ -45,6 +45,13 @@ export const useThrownError = () => { ) } +export const useAnotherCreditCard = (creditCardId: string) => { + CreditCardPickerMock.getCreditCardId.mockResolvedValue({ + type: "success", + creditCardId, + }) +} + const CreditCardPickerMock = { getCreditCardId: jest.fn( () => Promise.resolve(goodResult) as Promise, @@ -56,7 +63,19 @@ beforeEach(() => { useGoodResult() }) -export const CreditCardPickerFragmentContainer = ({ innerRef }) => { +export const CreditCardPickerFragmentContainer = ({ + innerRef, + onCreditCardChange, +}) => { innerRef.current = CreditCardPickerMock - return createElement("div") + + // Stands in for selecting a different card in the real picker + return createElement( + "div", + { + "data-testid": "select-another-credit-card", + onClick: onCreditCardChange, + }, + null, + ) } diff --git a/src/Apps/Order/Routes/NewPayment/index.tsx b/src/Apps/Order/Routes/NewPayment/index.tsx index c6bf7f2d077..de2860e76ed 100644 --- a/src/Apps/Order/Routes/NewPayment/index.tsx +++ b/src/Apps/Order/Routes/NewPayment/index.tsx @@ -25,7 +25,7 @@ import type { NewPaymentRouteSetOrderPaymentMutation } from "__generated__/NewPa import type { NewPayment_me$data } from "__generated__/NewPayment_me.graphql" import type { NewPayment_order$data } from "__generated__/NewPayment_order.graphql" import type { Router } from "found" -import { type FC, createRef, useState } from "react" +import { type FC, useRef, useState } from "react" import { createFragmentContainer, graphql } from "react-relay" import { useTracking } from "react-tracking" @@ -77,7 +77,7 @@ export const NewPaymentRoute: FC< stripe, } = props const isLoading = isCommittingMutation || isGettingCreditCardId - const CreditCardPicker = createRef() + const CreditCardPicker = useRef(null) const trackErrorMessageEvent = ( title: string, @@ -129,6 +129,14 @@ export const NewPaymentRoute: FC< return null } + // The result of the picker is cached so that retrying after a recoverable + // failure (a declined charge, or an SCA challenge) doesn't create a duplicate + // credit card. That cache is only valid for as long as the user sticks with + // the same payment method, so drop it as soon as they pick a different one. + const handleCreditCardChange = () => { + setCreditCardPickerResult(null) + } + const onContinue = async () => { try { const creditCardId = @@ -326,6 +334,7 @@ export const NewPaymentRoute: FC< commitMutation={commitMutation} innerRef={CreditCardPicker} tracking={tracking} + onCreditCardChange={handleCreditCardChange} /> diff --git a/src/Apps/Order/Routes/__tests__/NewPayment.jest.tsx b/src/Apps/Order/Routes/__tests__/NewPayment.jest.tsx index 05185529d5b..7dc590cb933 100644 --- a/src/Apps/Order/Routes/__tests__/NewPayment.jest.tsx +++ b/src/Apps/Order/Routes/__tests__/NewPayment.jest.tsx @@ -1,3 +1,4 @@ +import { screen } from "@testing-library/react" import { NewPaymentFragmentContainer } from "Apps/Order/Routes/NewPayment" import { fixFailedPaymentFailure, @@ -13,7 +14,6 @@ import { MockBoot } from "DevTools/MockBoot" import { mockLocation } from "DevTools/mockLocation" import { mockStripe } from "DevTools/mockStripe" import { setupTestWrapperTL } from "DevTools/setupTestWrapperTL" -import { screen } from "@testing-library/react" import type { NewPaymentTestQuery$rawResponse } from "__generated__/NewPaymentTestQuery.graphql" import { DateTime } from "luxon" import { graphql } from "react-relay" @@ -255,6 +255,38 @@ describe("Payment", () => { }) }) + it("uses the newly selected credit card when retrying after a failed charge", async () => { + mockCommitMutation.mockResolvedValueOnce(fixFailedPaymentFailure) + const { user } = renderWithRelay({ CommerceOrder: () => testOrder }) + const page = new OrderAppTestPageRTL(screen, user) + await page.clickSubmit() + + expect(mockCommitMutation).toHaveBeenLastCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ creditCardId: "credit-card-id" }), + }), + }), + ) + + CreditCardPickerMock.useAnotherCreditCard("another-credit-card-id") + await user.click(screen.getByTestId("select-another-credit-card")) + + mockCommitMutation.mockResolvedValueOnce(fixFailedPaymentSuccess) + await page.clickSubmit() + + expect(mockCommitMutation).toHaveBeenLastCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + creditCardId: "another-credit-card-id", + }), + }), + }), + ) + expect(pushMock).toHaveBeenCalledWith("/orders/1234/details") + }) + it("shows an error modal when there is a network error", async () => { mockCommitMutation.mockRejectedValue({}) const { user } = renderWithRelay({ CommerceOrder: () => testOrder })