Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add ACH payment method #3616

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Prev Previous commit
Next Next commit
fix tests
  • Loading branch information
suejung-sentry committed Jan 14, 2025
commit 616eaa800d3369b8e76291b56cb0d07dbafbf64a
9 changes: 8 additions & 1 deletion src/pages/PlanPage/PlanPage.jsx
Original file line number Diff line number Diff line change
@@ -50,7 +50,14 @@ function PlanPage() {
return (
<div className="flex flex-col gap-4">
<Tabs />
<Elements stripe={stripePromise} options={StripeAppearance(isDarkMode)}>
<Elements
stripe={stripePromise}
options={{
...StripeAppearance(isDarkMode),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn't considered adding the appearance on the top level here but I really like this

Do we need the 'mode' and 'currency' defined?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah those are parameters required in order to use this Stripe Elements provider along with the setupIntent for the PaymentElement UI. There are complaints in the console if they don't get passed unfortunately

mode: 'setup',
currency: 'usd',
}}
>
<PlanProvider>
<PlanBreadcrumb />
<Suspense fallback={<Loader />}>
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@
import { z } from 'zod'

import { SubscriptionDetailSchema } from 'services/account'
import { useCreateStripeSetupIntent } from 'services/account/useCreateStripeSetupIntent'
import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod'
import Button from 'ui/Button'

@@ -22,14 +21,8 @@
existingSubscriptionDetail,
}: PaymentMethodFormProps) => {
const [errorState, _] = useState('')
const { data: setupIntent } = useCreateStripeSetupIntent({ owner, provider })
const elements = useElements()

elements?.update({
// @ts-expect-error clientSecret works actually
clientSecret: setupIntent?.clientSecret,
})

const {
mutate: updatePaymentMethod,
isLoading,
@@ -38,7 +31,7 @@
} = useUpdatePaymentMethod({
provider,
owner,
email:

Check failure on line 34 in src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx

GitHub Actions / Run Type Checker

Type 'string | null | undefined' is not assignable to type 'string'.

Check failure on line 34 in src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx

GitHub Actions / Upload Bundle Stats - Staging

Type 'string | null | undefined' is not assignable to type 'string'.

Check failure on line 34 in src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx

GitHub Actions / Upload Bundle Stats - Production

Type 'string | null | undefined' is not assignable to type 'string'.
existingSubscriptionDetail?.defaultPaymentMethod?.billingDetails?.email,
})

@@ -49,6 +42,8 @@
return null
}

elements.submit()

const paymentElement = elements.getElement(PaymentElement)

updatePaymentMethod(paymentElement, {
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import { z } from 'zod'

import { SubscriptionDetailSchema } from 'services/account'
import { accountDetailsParsedObj } from 'services/account/mocks'

import ViewPaymentMethod from './ViewPaymentMethod'

describe('ViewPaymentMethod', () => {
const mockSetEditMode = vi.fn()
const defaultProps = {
heading: 'Payment Method',
setEditMode: mockSetEditMode,
subscriptionDetail: accountDetailsParsedObj.subscriptionDetail as z.infer<
typeof SubscriptionDetailSchema
>,
provider: 'gh',
owner: 'codecov',
}

beforeEach(() => {
mockSetEditMode.mockClear()
})

describe('when rendered as primary payment method', () => {
it('renders heading', () => {
render(<ViewPaymentMethod {...defaultProps} isPrimaryPaymentMethod />)

expect(screen.getByText('Payment Method')).toBeInTheDocument()
})

it('does not show secondary payment method message', () => {
render(<ViewPaymentMethod {...defaultProps} isPrimaryPaymentMethod />)

expect(
screen.queryByText(
'By default, if the primary payment fails, the secondary will be charged automatically.'
)
).not.toBeInTheDocument()
})

it('does not show set as primary button', () => {
render(<ViewPaymentMethod {...defaultProps} isPrimaryPaymentMethod />)

expect(screen.queryByText('Set as primary')).not.toBeInTheDocument()
})
})

describe('when payment method is credit card', () => {
it('shows Cardholder name label', () => {
render(<ViewPaymentMethod {...defaultProps} isPrimaryPaymentMethod />)

expect(screen.getByText('Cardholder name')).toBeInTheDocument()
})
})

describe('when payment method is bank account', () => {
beforeEach(() => {
defaultProps.subscriptionDetail = {
...accountDetailsParsedObj.subscriptionDetail,
defaultPaymentMethod: {
billingDetails:
accountDetailsParsedObj.subscriptionDetail?.defaultPaymentMethod
?.billingDetails,
usBankAccount: {
bankName: 'Test Bank',
last4: '1234',
},
},
}
})

it('shows Full name label', () => {
render(<ViewPaymentMethod {...defaultProps} isPrimaryPaymentMethod />)

expect(screen.getByText('Full name')).toBeInTheDocument()
})
})
})
49 changes: 44 additions & 5 deletions src/services/account/useAccountDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -4,9 +4,10 @@ import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { MemoryRouter, Route } from 'react-router-dom'
import { z } from 'zod'

import { accountDetailsObject, accountDetailsParsedObj } from './mocks'
import { useAccountDetails } from './useAccountDetails'
import { accountDetailsParsedObj } from './mocks'
import { AccountDetailsSchema, useAccountDetails } from './useAccountDetails'

vi.mock('js-cookie')

@@ -45,17 +46,17 @@ afterAll(() => {
})

describe('useAccountDetails', () => {
function setup() {
function setup(accountDetails: z.infer<typeof AccountDetailsSchema>) {
server.use(
http.get(`/internal/${provider}/${owner}/account-details/`, () => {
return HttpResponse.json(accountDetailsObject)
return HttpResponse.json(accountDetails)
})
)
}

describe('when called', () => {
it('returns the data', async () => {
setup()
setup(accountDetailsParsedObj)
const { result } = renderHook(
() => useAccountDetails({ provider, owner }),
{ wrapper: wrapper() }
@@ -65,5 +66,43 @@ describe('useAccountDetails', () => {
expect(result.current.data).toEqual(accountDetailsParsedObj)
)
})

it('returns data with usBankAccount when enabled', async () => {
const withUSBankAccount = {
...accountDetailsParsedObj,
subscriptionDetail: {
...accountDetailsParsedObj.subscriptionDetail,
defaultPaymentMethod: {
billingDetails: null,
usBankAccount: {
bankName: 'Bank of America',
last4: '1234',
},
},
},
}
setup(withUSBankAccount)

const { result } = renderHook(
() => useAccountDetails({ provider, owner }),
{ wrapper: wrapper() }
)

await waitFor(() =>
expect(result.current.data).toEqual({
...accountDetailsParsedObj,
subscriptionDetail: {
...accountDetailsParsedObj.subscriptionDetail,
defaultPaymentMethod: {
billingDetails: null,
usBankAccount: {
bankName: 'Bank of America',
last4: '1234',
},
},
},
})
)
})
})
})
90 changes: 90 additions & 0 deletions src/services/account/useCreateStripeSetupIntent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { graphql, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import React from 'react'
import { MemoryRouter, Route } from 'react-router-dom'

import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent'

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const wrapper =
(initialEntries = '/gh'): React.FC<React.PropsWithChildren> =>
({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntries]}>
<Route path="/:provider">{children}</Route>
</MemoryRouter>
</QueryClientProvider>
)

const provider = 'gh'
const owner = 'codecov'

const server = setupServer()
beforeAll(() => {
server.listen()
})

afterEach(() => {
queryClient.clear()
server.resetHandlers()
})

afterAll(() => {
server.close()
})

describe('useCreateStripeSetupIntent', () => {
function setup(hasError = false) {
server.use(
graphql.mutation('CreateStripeSetupIntent', () => {
if (hasError) {
return HttpResponse.json({ data: {} })
}

return HttpResponse.json({
data: { createStripeSetupIntent: { clientSecret: 'test_secret' } },
})
})
)
}

describe('when called', () => {
describe('on success', () => {
it('returns the data', async () => {
setup()
const { result } = renderHook(
() => useCreateStripeSetupIntent({ provider, owner }),
{ wrapper: wrapper() }
)

await waitFor(() =>
expect(result.current.data).toEqual({ clientSecret: 'test_secret' })
)
})
})

describe('on fail', () => {
beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterAll(() => {
vi.restoreAllMocks()
})

it('fails to parse if bad data', async () => {
setup(true)
const { result } = renderHook(
() => useCreateStripeSetupIntent({ provider, owner }),
{ wrapper: wrapper() }
)

await waitFor(() => expect(result.current.error).toBeTruthy())
})
})
})
})
26 changes: 22 additions & 4 deletions src/services/account/useUpdatePaymentMethod.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
@@ -12,6 +14,7 @@ import { useUpdatePaymentMethod } from './useUpdatePaymentMethod'

const mocks = vi.hoisted(() => ({
useStripe: vi.fn(),
useCreateStripeSetupIntent: vi.fn(),
}))

vi.mock('@stripe/react-stripe-js', async () => {
@@ -22,6 +25,12 @@ vi.mock('@stripe/react-stripe-js', async () => {
}
})

vi.mock('./useCreateStripeSetupIntent', () => ({
useCreateStripeSetupIntent: mocks.useCreateStripeSetupIntent,
}))

const stripePromise = loadStripe('fake-publishable-key')

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
@@ -30,7 +39,9 @@ const wrapper =
({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntries]}>
<Route path="/:provider">{children}</Route>
<Route path="/:provider">
<Elements stripe={stripePromise}>{children}</Elements>
</Route>
</MemoryRouter>
</QueryClientProvider>
)
@@ -74,6 +85,9 @@ describe('useUpdatePaymentMethod', () => {
mocks.useStripe.mockReturnValue({
confirmSetup,
})
mocks.useCreateStripeSetupIntent.mockReturnValue({
data: { clientSecret: 'test_secret' },
})
}

describe('when called', () => {
@@ -83,7 +97,9 @@ describe('useUpdatePaymentMethod', () => {
confirmSetup: vi.fn(
() =>
new Promise((resolve) => {
resolve({ paymentMethod: { id: 1 } })
resolve({
setupIntent: { payment_method: 'test_payment_method' },
})
})
),
})
@@ -100,7 +116,8 @@ describe('useUpdatePaymentMethod', () => {

it('returns the data from the server', async () => {
const { result } = renderHook(
() => useUpdatePaymentMethod({ provider, owner }),
() =>
useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }),
{ wrapper: wrapper() }
)

@@ -140,7 +157,8 @@ describe('useUpdatePaymentMethod', () => {

it('does something', async () => {
const { result } = renderHook(
() => useUpdatePaymentMethod({ provider, owner }),
() =>
useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }),
{ wrapper: wrapper() }
)

Loading
Oops, something went wrong.
Loading
Oops, something went wrong.