Skip to content

Commit 969f83f

Browse files
feat: Use Stripe PaymentElement
1 parent 72cd37d commit 969f83f

File tree

9 files changed

+424
-16
lines changed

9 files changed

+424
-16
lines changed

src/assets/billing/bank.svg

Lines changed: 10 additions & 0 deletions
Loading

src/pages/PlanPage/PlanPage.jsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import config from 'config'
88

99
import { SentryRoute } from 'sentry'
1010

11+
import { Theme, useThemeContext } from 'shared/ThemeContext'
1112
import LoadingLogo from 'ui/LoadingLogo'
1213

1314
import { PlanProvider } from './context'
1415
import PlanBreadcrumb from './PlanBreadcrumb'
1516
import { PlanPageDataQueryOpts } from './queries/PlanPageDataQueryOpts'
1617
import Tabs from './Tabs'
1718

19+
import { StripeAppearance } from '../../stripe'
20+
1821
const CancelPlanPage = lazy(() => import('./subRoutes/CancelPlanPage'))
1922
const CurrentOrgPlan = lazy(() => import('./subRoutes/CurrentOrgPlan'))
2023
const InvoicesPage = lazy(() => import('./subRoutes/InvoicesPage'))
@@ -37,6 +40,8 @@ function PlanPage() {
3740
const { data: ownerData } = useSuspenseQueryV5(
3841
PlanPageDataQueryOpts({ owner, provider })
3942
)
43+
const { theme } = useThemeContext()
44+
const isDarkMode = theme !== Theme.LIGHT
4045

4146
if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) {
4247
return <Redirect to={`/${provider}/${owner}`} />
@@ -45,7 +50,14 @@ function PlanPage() {
4550
return (
4651
<div className="flex flex-col gap-4">
4752
<Tabs />
48-
<Elements stripe={stripePromise}>
53+
<Elements
54+
stripe={stripePromise}
55+
options={{
56+
...StripeAppearance(isDarkMode),
57+
mode: 'setup',
58+
currency: 'usd',
59+
}}
60+
>
4961
<PlanProvider>
5062
<PlanBreadcrumb />
5163
<Suspense fallback={<Loader />}>

src/pages/PlanPage/PlanPage.test.jsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { MemoryRouter, Route } from 'react-router-dom'
1111

1212
import config from 'config'
1313

14+
import { ThemeContextProvider } from 'shared/ThemeContext'
15+
1416
import PlanPage from './PlanPage'
1517

1618
vi.mock('config')
@@ -44,18 +46,20 @@ const wrapper =
4446
({ children }) => (
4547
<QueryClientProviderV5 client={queryClientV5}>
4648
<QueryClientProvider client={queryClient}>
47-
<Suspense fallback={null}>
48-
<MemoryRouter initialEntries={[initialEntries]}>
49-
<Route path="/plan/:provider/:owner">{children}</Route>
50-
<Route
51-
path="*"
52-
render={({ location }) => {
53-
testLocation = location
54-
return null
55-
}}
56-
/>
57-
</MemoryRouter>
58-
</Suspense>
49+
<ThemeContextProvider>
50+
<Suspense fallback={null}>
51+
<MemoryRouter initialEntries={[initialEntries]}>
52+
<Route path="/plan/:provider/:owner">{children}</Route>
53+
<Route
54+
path="*"
55+
render={({ location }) => {
56+
testLocation = location
57+
return null
58+
}}
59+
/>
60+
</MemoryRouter>
61+
</Suspense>
62+
</ThemeContextProvider>
5963
</QueryClientProvider>
6064
</QueryClientProviderV5>
6165
)

src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Button from 'ui/Button'
77
import Icon from 'ui/Icon'
88

99
import CardInformation from './CardInformation'
10-
import CreditCardForm from './CreditCardForm'
10+
import PaymentMethodForm from './PaymentMethodForm'
1111
function PaymentCard({ subscriptionDetail, provider, owner }) {
1212
const [isFormOpen, setIsFormOpen] = useState(false)
1313
const card = subscriptionDetail?.defaultPaymentMethod?.card
@@ -27,7 +27,7 @@ function PaymentCard({ subscriptionDetail, provider, owner }) {
2727
)}
2828
</div>
2929
{isFormOpen ? (
30-
<CreditCardForm
30+
<PaymentMethodForm
3131
provider={provider}
3232
owner={owner}
3333
closeForm={() => setIsFormOpen(false)}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { Elements } from '@stripe/react-stripe-js'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
import { render, screen } from '@testing-library/react'
4+
import userEvent from '@testing-library/user-event'
5+
import { MemoryRouter, Route } from 'react-router-dom'
6+
import { vi } from 'vitest'
7+
import { z } from 'zod'
8+
9+
import { SubscriptionDetailSchema } from 'services/account/useAccountDetails'
10+
11+
import PaymentMethodForm from './PaymentMethodForm'
12+
13+
const queryClient = new QueryClient()
14+
15+
const mockElements = {
16+
submit: vi.fn(),
17+
getElement: vi.fn(),
18+
}
19+
20+
vi.mock('@stripe/react-stripe-js', () => ({
21+
Elements: ({ children }: { children: React.ReactNode }) => children,
22+
useElements: () => mockElements,
23+
PaymentElement: 'div',
24+
}))
25+
26+
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
27+
<QueryClientProvider client={queryClient}>
28+
<Elements stripe={null}>
29+
<MemoryRouter initialEntries={['/plan/gh/codecov']}>
30+
<Route path="/plan/:provider/:owner">{children}</Route>
31+
</MemoryRouter>
32+
</Elements>
33+
</QueryClientProvider>
34+
)
35+
36+
const subscriptionDetail: z.infer<typeof SubscriptionDetailSchema> = {
37+
defaultPaymentMethod: {
38+
billingDetails: {
39+
address: {
40+
line1: '123 Main St',
41+
city: 'San Francisco',
42+
state: 'CA',
43+
postalCode: '94105',
44+
country: 'US',
45+
line2: null,
46+
},
47+
phone: '1234567890',
48+
name: 'John Doe',
49+
50+
},
51+
card: {
52+
brand: 'visa',
53+
expMonth: 12,
54+
expYear: 2025,
55+
last4: '4242',
56+
},
57+
},
58+
currentPeriodEnd: 1706851492,
59+
cancelAtPeriodEnd: false,
60+
customer: {
61+
id: 'cust_123',
62+
63+
},
64+
latestInvoice: null,
65+
taxIds: [],
66+
trialEnd: null,
67+
}
68+
69+
const mocks = {
70+
useUpdatePaymentMethod: vi.fn(),
71+
}
72+
73+
vi.mock('services/account/useUpdatePaymentMethod', () => ({
74+
useUpdatePaymentMethod: () => mocks.useUpdatePaymentMethod(),
75+
}))
76+
77+
afterEach(() => {
78+
vi.clearAllMocks()
79+
})
80+
81+
describe('PaymentMethodForm', () => {
82+
describe('when the user clicks on Edit payment method', () => {
83+
it(`doesn't render the payment method anymore`, async () => {
84+
const user = userEvent.setup()
85+
const updatePaymentMethod = vi.fn()
86+
mocks.useUpdatePaymentMethod.mockReturnValue({
87+
mutate: updatePaymentMethod,
88+
isLoading: false,
89+
})
90+
91+
render(
92+
<PaymentMethodForm
93+
subscriptionDetail={subscriptionDetail}
94+
provider="gh"
95+
owner="codecov"
96+
closeForm={() => {}}
97+
/>,
98+
{ wrapper }
99+
)
100+
await user.click(screen.getByTestId('update-payment-method'))
101+
102+
expect(screen.queryByText(/Visa/)).not.toBeInTheDocument()
103+
})
104+
105+
it('renders the form', async () => {
106+
const user = userEvent.setup()
107+
const updatePaymentMethod = vi.fn()
108+
mocks.useUpdatePaymentMethod.mockReturnValue({
109+
mutate: updatePaymentMethod,
110+
isLoading: false,
111+
})
112+
render(
113+
<PaymentMethodForm
114+
subscriptionDetail={subscriptionDetail}
115+
provider="gh"
116+
owner="codecov"
117+
closeForm={() => {}}
118+
/>,
119+
{ wrapper }
120+
)
121+
await user.click(screen.getByTestId('update-payment-method'))
122+
123+
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument()
124+
})
125+
126+
describe('when submitting', () => {
127+
it('calls the service to update the payment method', async () => {
128+
const user = userEvent.setup()
129+
const updatePaymentMethod = vi.fn()
130+
mocks.useUpdatePaymentMethod.mockReturnValue({
131+
mutate: updatePaymentMethod,
132+
isLoading: false,
133+
})
134+
render(
135+
<PaymentMethodForm
136+
subscriptionDetail={subscriptionDetail}
137+
provider="gh"
138+
owner="codecov"
139+
closeForm={() => {}}
140+
/>,
141+
{ wrapper }
142+
)
143+
await user.click(screen.getByTestId('update-payment-method'))
144+
expect(updatePaymentMethod).toHaveBeenCalled()
145+
})
146+
})
147+
148+
describe('when the user clicks on cancel', () => {
149+
it(`doesn't render the form anymore`, async () => {
150+
const user = userEvent.setup()
151+
const closeForm = vi.fn()
152+
mocks.useUpdatePaymentMethod.mockReturnValue({
153+
mutate: vi.fn(),
154+
isLoading: false,
155+
})
156+
render(
157+
<PaymentMethodForm
158+
subscriptionDetail={subscriptionDetail}
159+
provider="gh"
160+
owner="codecov"
161+
closeForm={closeForm}
162+
/>,
163+
{ wrapper }
164+
)
165+
166+
await user.click(screen.getByTestId('update-payment-method'))
167+
await user.click(screen.getByRole('button', { name: /Cancel/ }))
168+
169+
expect(closeForm).toHaveBeenCalled()
170+
})
171+
})
172+
})
173+
174+
describe('when there is an error in the form', () => {
175+
it('renders the error', async () => {
176+
const user = userEvent.setup()
177+
const randomError = 'not rich enough'
178+
mocks.useUpdatePaymentMethod.mockReturnValue({
179+
mutate: vi.fn(),
180+
error: { message: randomError },
181+
})
182+
render(
183+
<PaymentMethodForm
184+
subscriptionDetail={subscriptionDetail}
185+
provider="gh"
186+
owner="codecov"
187+
closeForm={() => {}}
188+
/>,
189+
{ wrapper }
190+
)
191+
192+
await user.click(screen.getByTestId('update-payment-method'))
193+
194+
expect(screen.getByText(randomError)).toBeInTheDocument()
195+
})
196+
})
197+
198+
describe('when the form is loading', () => {
199+
it('has the error and save button disabled', async () => {
200+
mocks.useUpdatePaymentMethod.mockReturnValue({
201+
mutate: vi.fn(),
202+
isLoading: true,
203+
})
204+
render(
205+
<PaymentMethodForm
206+
subscriptionDetail={subscriptionDetail}
207+
provider="gh"
208+
owner="codecov"
209+
closeForm={() => {}}
210+
/>,
211+
{ wrapper }
212+
)
213+
214+
expect(screen.queryByRole('button', { name: /Save/i })).toBeDisabled()
215+
expect(screen.queryByRole('button', { name: /Cancel/i })).toBeDisabled()
216+
})
217+
})
218+
})

0 commit comments

Comments
 (0)