Skip to content

Commit

Permalink
feat: Use Stripe PaymentElement
Browse files Browse the repository at this point in the history
  • Loading branch information
suejung-sentry committed Jan 16, 2025
1 parent 72cd37d commit 969f83f
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 16 deletions.
10 changes: 10 additions & 0 deletions src/assets/billing/bank.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 13 additions & 1 deletion src/pages/PlanPage/PlanPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import config from 'config'

import { SentryRoute } from 'sentry'

import { Theme, useThemeContext } from 'shared/ThemeContext'
import LoadingLogo from 'ui/LoadingLogo'

import { PlanProvider } from './context'
import PlanBreadcrumb from './PlanBreadcrumb'
import { PlanPageDataQueryOpts } from './queries/PlanPageDataQueryOpts'
import Tabs from './Tabs'

import { StripeAppearance } from '../../stripe'

const CancelPlanPage = lazy(() => import('./subRoutes/CancelPlanPage'))
const CurrentOrgPlan = lazy(() => import('./subRoutes/CurrentOrgPlan'))
const InvoicesPage = lazy(() => import('./subRoutes/InvoicesPage'))
Expand All @@ -37,6 +40,8 @@ function PlanPage() {
const { data: ownerData } = useSuspenseQueryV5(
PlanPageDataQueryOpts({ owner, provider })
)
const { theme } = useThemeContext()
const isDarkMode = theme !== Theme.LIGHT

if (config.IS_SELF_HOSTED || !ownerData?.isCurrentUserPartOfOrg) {
return <Redirect to={`/${provider}/${owner}`} />
Expand All @@ -45,7 +50,14 @@ function PlanPage() {
return (
<div className="flex flex-col gap-4">
<Tabs />
<Elements stripe={stripePromise}>
<Elements
stripe={stripePromise}
options={{
...StripeAppearance(isDarkMode),
mode: 'setup',
currency: 'usd',
}}
>
<PlanProvider>
<PlanBreadcrumb />
<Suspense fallback={<Loader />}>
Expand Down
28 changes: 16 additions & 12 deletions src/pages/PlanPage/PlanPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { MemoryRouter, Route } from 'react-router-dom'

import config from 'config'

import { ThemeContextProvider } from 'shared/ThemeContext'

import PlanPage from './PlanPage'

vi.mock('config')
Expand Down Expand Up @@ -44,18 +46,20 @@ const wrapper =
({ children }) => (
<QueryClientProviderV5 client={queryClientV5}>
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>
<MemoryRouter initialEntries={[initialEntries]}>
<Route path="/plan/:provider/:owner">{children}</Route>
<Route
path="*"
render={({ location }) => {
testLocation = location
return null
}}
/>
</MemoryRouter>
</Suspense>
<ThemeContextProvider>
<Suspense fallback={null}>
<MemoryRouter initialEntries={[initialEntries]}>
<Route path="/plan/:provider/:owner">{children}</Route>
<Route
path="*"
render={({ location }) => {
testLocation = location
return null
}}
/>
</MemoryRouter>
</Suspense>
</ThemeContextProvider>
</QueryClientProvider>
</QueryClientProviderV5>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Button from 'ui/Button'
import Icon from 'ui/Icon'

import CardInformation from './CardInformation'
import CreditCardForm from './CreditCardForm'
import PaymentMethodForm from './PaymentMethodForm'
function PaymentCard({ subscriptionDetail, provider, owner }) {
const [isFormOpen, setIsFormOpen] = useState(false)
const card = subscriptionDetail?.defaultPaymentMethod?.card
Expand All @@ -27,7 +27,7 @@ function PaymentCard({ subscriptionDetail, provider, owner }) {
)}
</div>
{isFormOpen ? (
<CreditCardForm
<PaymentMethodForm
provider={provider}
owner={owner}
closeForm={() => setIsFormOpen(false)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { Elements } from '@stripe/react-stripe-js'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route } from 'react-router-dom'
import { vi } from 'vitest'
import { z } from 'zod'

import { SubscriptionDetailSchema } from 'services/account/useAccountDetails'

import PaymentMethodForm from './PaymentMethodForm'

const queryClient = new QueryClient()

const mockElements = {
submit: vi.fn(),
getElement: vi.fn(),
}

vi.mock('@stripe/react-stripe-js', () => ({
Elements: ({ children }: { children: React.ReactNode }) => children,
useElements: () => mockElements,
PaymentElement: 'div',
}))

const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<Elements stripe={null}>
<MemoryRouter initialEntries={['/plan/gh/codecov']}>
<Route path="/plan/:provider/:owner">{children}</Route>
</MemoryRouter>
</Elements>
</QueryClientProvider>
)

const subscriptionDetail: z.infer<typeof SubscriptionDetailSchema> = {
defaultPaymentMethod: {
billingDetails: {
address: {
line1: '123 Main St',
city: 'San Francisco',
state: 'CA',
postalCode: '94105',
country: 'US',
line2: null,
},
phone: '1234567890',
name: 'John Doe',
email: '[email protected]',
},
card: {
brand: 'visa',
expMonth: 12,
expYear: 2025,
last4: '4242',
},
},
currentPeriodEnd: 1706851492,
cancelAtPeriodEnd: false,
customer: {
id: 'cust_123',
email: '[email protected]',
},
latestInvoice: null,
taxIds: [],
trialEnd: null,
}

const mocks = {
useUpdatePaymentMethod: vi.fn(),
}

vi.mock('services/account/useUpdatePaymentMethod', () => ({
useUpdatePaymentMethod: () => mocks.useUpdatePaymentMethod(),
}))

afterEach(() => {
vi.clearAllMocks()
})

describe('PaymentMethodForm', () => {
describe('when the user clicks on Edit payment method', () => {
it(`doesn't render the payment method anymore`, async () => {
const user = userEvent.setup()
const updatePaymentMethod = vi.fn()
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: updatePaymentMethod,
isLoading: false,
})

render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={() => {}}
/>,
{ wrapper }
)
await user.click(screen.getByTestId('update-payment-method'))

expect(screen.queryByText(/Visa/)).not.toBeInTheDocument()
})

it('renders the form', async () => {
const user = userEvent.setup()
const updatePaymentMethod = vi.fn()
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: updatePaymentMethod,
isLoading: false,
})
render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={() => {}}
/>,
{ wrapper }
)
await user.click(screen.getByTestId('update-payment-method'))

expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument()
})

describe('when submitting', () => {
it('calls the service to update the payment method', async () => {
const user = userEvent.setup()
const updatePaymentMethod = vi.fn()
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: updatePaymentMethod,
isLoading: false,
})
render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={() => {}}
/>,
{ wrapper }
)
await user.click(screen.getByTestId('update-payment-method'))
expect(updatePaymentMethod).toHaveBeenCalled()
})
})

describe('when the user clicks on cancel', () => {
it(`doesn't render the form anymore`, async () => {
const user = userEvent.setup()
const closeForm = vi.fn()
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: vi.fn(),
isLoading: false,
})
render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={closeForm}
/>,
{ wrapper }
)

await user.click(screen.getByTestId('update-payment-method'))
await user.click(screen.getByRole('button', { name: /Cancel/ }))

expect(closeForm).toHaveBeenCalled()
})
})
})

describe('when there is an error in the form', () => {
it('renders the error', async () => {
const user = userEvent.setup()
const randomError = 'not rich enough'
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: vi.fn(),
error: { message: randomError },
})
render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={() => {}}
/>,
{ wrapper }
)

await user.click(screen.getByTestId('update-payment-method'))

expect(screen.getByText(randomError)).toBeInTheDocument()
})
})

describe('when the form is loading', () => {
it('has the error and save button disabled', async () => {
mocks.useUpdatePaymentMethod.mockReturnValue({
mutate: vi.fn(),
isLoading: true,
})
render(
<PaymentMethodForm
subscriptionDetail={subscriptionDetail}
provider="gh"
owner="codecov"
closeForm={() => {}}
/>,
{ wrapper }
)

expect(screen.queryByRole('button', { name: /Save/i })).toBeDisabled()
expect(screen.queryByRole('button', { name: /Cancel/i })).toBeDisabled()
})
})
})
Loading

0 comments on commit 969f83f

Please sign in to comment.