+
- •••• {card?.last4}
+ •••• {card?.last4}
Expires {card?.expMonth}/{lastTwoDigits(card?.expYear)}
{nextBilling && (
-
+
Your next billing date is{' '}
{nextBilling} .
@@ -68,15 +74,4 @@ function CardInformation({ subscriptionDetail, card }) {
)
}
-CardInformation.propTypes = {
- subscriptionDetail: subscriptionDetailType,
- card: PropTypes.shape({
- brand: PropTypes.string.isRequired,
- last4: PropTypes.string.isRequired,
- expMonth: PropTypes.number.isRequired,
- expYear: PropTypes.number.isRequired,
- }).isRequired,
- openForm: PropTypes.func.isRequired,
-}
-
export default CardInformation
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx
new file mode 100644
index 0000000000..bf8cb4a92e
--- /dev/null
+++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.test.tsx
@@ -0,0 +1,211 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+import { ThemeContextProvider } from 'shared/ThemeContext'
+
+import PaymentMethod from './PaymentMethod'
+
+const mocks = vi.hoisted(() => ({
+ useUpdatePaymentMethod: vi.fn(),
+}))
+
+vi.mock('services/account', async () => {
+ const actual = await vi.importActual('services/account')
+ return {
+ ...actual,
+ useUpdatePaymentMethod: mocks.useUpdatePaymentMethod,
+ }
+})
+
+const subscriptionDetail = {
+ defaultPaymentMethod: {
+ billingDetails: {
+ name: 'John Doe',
+ email: 'john.doe@example.com',
+ address: {
+ city: 'New York',
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ postalCode: '10001',
+ state: 'NY',
+ },
+ phone: '1234567890',
+ },
+ card: {
+ brand: 'visa',
+ expMonth: 12,
+ expYear: 2021,
+ last4: '1234',
+ },
+ },
+ currentPeriodEnd: 1606851492,
+ cancelAtPeriodEnd: false,
+ customer: {
+ id: 'cust_123',
+ email: 'test@test.com',
+ },
+ latestInvoice: null,
+ taxIds: [],
+ trialEnd: null,
+}
+
+const wrapper: React.FC
= ({ children }) => (
+ {children}
+)
+
+// mocking all the stripe components; and trusting the library :)
+vi.mock('@stripe/react-stripe-js', () => {
+ function makeFakeComponent(name: string) {
+ return function Component() {
+ return name
+ }
+ }
+ return {
+ useElements: () => ({
+ getElement: vi.fn(),
+ }),
+ useStripe: () => ({}),
+ CardElement: makeFakeComponent('CardElement'),
+ }
+})
+
+describe('PaymentMethodCard', () => {
+ function setup() {
+ const user = userEvent.setup()
+
+ return { user }
+ }
+
+ describe(`when the user doesn't have any subscriptionDetail`, () => {
+ // NOTE: This test is misleading because we hide this component from a higher level in
+ // BillingDetails.tsx if there is no subscriptionDetail
+ it('renders the set card message', () => {
+ render(
+ {}}
+ />
+ )
+
+ expect(
+ screen.getByText(
+ /No payment method set. Please contact support if you think it’s an error or set it yourself./
+ )
+ ).toBeInTheDocument()
+ })
+ })
+
+ describe(`when the user doesn't have any payment method`, () => {
+ it('renders an error message', () => {
+ const subscriptionDetailMissingPaymentMethod = {
+ ...subscriptionDetail,
+ defaultPaymentMethod: {
+ ...subscriptionDetail.defaultPaymentMethod,
+ card: null,
+ usBankAccount: null,
+ },
+ }
+ render(
+ {}}
+ />,
+ { wrapper }
+ )
+
+ expect(
+ screen.getByText(
+ /No payment method set. Please contact support if you think it’s an error or set it yourself./
+ )
+ ).toBeInTheDocument()
+ })
+
+ describe('when the user clicks on Set card', () => {
+ it(`doesn't render the card anymore and opens the form`, async () => {
+ const { user } = setup()
+ const subscriptionDetailMissingPaymentMethod = {
+ ...subscriptionDetail,
+ defaultPaymentMethod: {
+ ...subscriptionDetail.defaultPaymentMethod,
+ card: null,
+ usBankAccount: null,
+ },
+ }
+ const setEditMode = vi.fn()
+ render(
+ ,
+ { wrapper }
+ )
+
+ mocks.useUpdatePaymentMethod.mockReturnValue({
+ mutate: () => null,
+ isLoading: false,
+ })
+ await user.click(screen.getByTestId('open-edit-mode'))
+
+ expect(screen.queryByText(/Visa/)).not.toBeInTheDocument()
+ expect(setEditMode).toHaveBeenCalledWith(true)
+ })
+ })
+ })
+
+ describe('when the user have a card', () => {
+ it('renders the card', () => {
+ render(
+ {}}
+ />,
+ { wrapper }
+ )
+
+ expect(screen.getByText(/•••• 1234/)).toBeInTheDocument()
+ expect(screen.getByText(/Expires 12\/21/)).toBeInTheDocument()
+ })
+
+ it('renders the next billing', () => {
+ render(
+ {}}
+ />,
+ { wrapper }
+ )
+
+ expect(screen.getByText(/December 1, 2020/)).toBeInTheDocument()
+ })
+ })
+
+ describe('when the subscription is set to expire', () => {
+ it(`doesn't render the next billing`, () => {
+ render(
+ {}}
+ />,
+ { wrapper }
+ )
+
+ expect(screen.queryByText(/1st December, 2020/)).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx
new file mode 100644
index 0000000000..812c144055
--- /dev/null
+++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/PaymentMethod.tsx
@@ -0,0 +1,55 @@
+import { z } from 'zod'
+
+import { SubscriptionDetailSchema } from 'services/account'
+import { cn } from 'shared/utils/cn'
+import Button from 'ui/Button'
+
+import BankInformation from './BankInformation'
+import CardInformation from './CardInformation'
+
+function PaymentMethod({
+ setEditMode,
+ subscriptionDetail,
+ className,
+}: {
+ setEditMode: (isEditMode: boolean) => void
+ subscriptionDetail: z.infer
+ provider: string
+ owner: string
+ className?: string
+}) {
+ const isCardSet = !!subscriptionDetail?.defaultPaymentMethod?.card
+ const isBankSet = !!subscriptionDetail?.defaultPaymentMethod?.usBankAccount
+
+ return (
+
+
+
Payment method
+
+ {isCardSet ? (
+
+ ) : isBankSet ? (
+
+ ) : (
+
+
+ No payment method set. Please contact support if you think it’s an
+ error or set it yourself.
+
+
+ setEditMode(true)}
+ >
+ Set payment method
+
+
+
+ )}
+
+ )
+}
+
+export default PaymentMethod
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx
new file mode 100644
index 0000000000..b104102086
--- /dev/null
+++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.test.tsx
@@ -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( )
+
+ expect(screen.getByText('Payment Method')).toBeInTheDocument()
+ })
+
+ it('does not show secondary payment method message', () => {
+ render( )
+
+ 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( )
+
+ expect(screen.queryByText('Set as primary')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('when payment method is credit card', () => {
+ it('shows Cardholder name label', () => {
+ render( )
+
+ 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( )
+
+ expect(screen.getByText('Full name')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx
new file mode 100644
index 0000000000..7d814f7e77
--- /dev/null
+++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/ViewPaymentMethod.tsx
@@ -0,0 +1,90 @@
+import { z } from 'zod'
+
+import { SubscriptionDetailSchema } from 'services/account'
+import Button from 'ui/Button'
+import { ExpandableSection } from 'ui/ExpandableSection'
+
+import Address from './Address/Address'
+import PaymentMethod from './PaymentMethod/PaymentMethod'
+
+function ViewPaymentMethod({
+ heading,
+ isPrimaryPaymentMethod,
+ setEditMode,
+ subscriptionDetail,
+ provider,
+ owner,
+}: {
+ heading: string
+ isPrimaryPaymentMethod?: boolean
+ setEditMode: (isEditMode: boolean) => void
+ subscriptionDetail: z.infer
+ provider: string
+ owner: string
+}) {
+ const isCreditCard = subscriptionDetail?.defaultPaymentMethod?.card
+
+ return (
+
+
+
+ {heading}
+
+
+
+ {!isPrimaryPaymentMethod ? (
+
+ By default, if the primary payment fails, the secondary will be
+ charged automatically.
+
+ ) : null}
+
+ {/* Payment method summary */}
+
+ {/* Cardholder name */}
+
+
+ {isCreditCard ? 'Cardholder name' : 'Full name'}
+
+
+ {
+ subscriptionDetail?.defaultPaymentMethod?.billingDetails
+ ?.name
+ }
+
+
+ {/* Address */}
+
+
+ {!isPrimaryPaymentMethod ? (
+
setEditMode(true)}
+ className="mt-4"
+ >
+ Set as primary
+
+ ) : null}
+
+
+
+
+ )
+}
+
+export default ViewPaymentMethod
diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/index.ts
new file mode 100644
index 0000000000..d5f13665c3
--- /dev/null
+++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/index.ts
@@ -0,0 +1 @@
+export { default as ViewPaymentMethod } from './ViewPaymentMethod'
diff --git a/src/services/account/index.ts b/src/services/account/index.ts
index 2c2da2150c..0336564b71 100644
--- a/src/services/account/index.ts
+++ b/src/services/account/index.ts
@@ -8,6 +8,6 @@ export * from './useInvoice'
export * from './usePlanData'
export * from './useAvailablePlans'
export * from './useSentryToken'
-export * from './useUpdateCard'
+export * from './useUpdatePaymentMethod'
export * from './useUpgradePlan'
export * from './useUpdateBillingEmail'
diff --git a/src/services/account/useAccountDetails.test.tsx b/src/services/account/useAccountDetails.test.tsx
index 18081b27a1..c31b422b71 100644
--- a/src/services/account/useAccountDetails.test.tsx
+++ b/src/services/account/useAccountDetails.test.tsx
@@ -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) {
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',
+ },
+ },
+ },
+ })
+ )
+ })
})
})
diff --git a/src/services/account/useAccountDetails.ts b/src/services/account/useAccountDetails.ts
index c576dfcfb3..7f9b8a318e 100644
--- a/src/services/account/useAccountDetails.ts
+++ b/src/services/account/useAccountDetails.ts
@@ -75,6 +75,12 @@ export const PaymentMethodSchema = z
last4: z.string(),
})
.nullish(),
+ usBankAccount: z
+ .object({
+ bankName: z.string(),
+ last4: z.string(),
+ })
+ .nullish(),
billingDetails: BillingDetailsSchema.nullable(),
})
.nullable()
diff --git a/src/services/account/useCreateStripeSetupIntent.test.tsx b/src/services/account/useCreateStripeSetupIntent.test.tsx
new file mode 100644
index 0000000000..6c72a1fc60
--- /dev/null
+++ b/src/services/account/useCreateStripeSetupIntent.test.tsx
@@ -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 =>
+ ({ children }) => (
+
+
+ {children}
+
+
+ )
+
+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())
+ })
+ })
+ })
+})
diff --git a/src/services/account/useCreateStripeSetupIntent.ts b/src/services/account/useCreateStripeSetupIntent.ts
new file mode 100644
index 0000000000..3c157e3002
--- /dev/null
+++ b/src/services/account/useCreateStripeSetupIntent.ts
@@ -0,0 +1,81 @@
+import { useQuery } from '@tanstack/react-query'
+import { z } from 'zod'
+
+import Api from 'shared/api'
+import { NetworkErrorObject } from 'shared/api/helpers'
+
+export const CreateStripeSetupIntentSchema = z.object({
+ createStripeSetupIntent: z.object({
+ clientSecret: z.string().nullish(),
+ error: z
+ .discriminatedUnion('__typename', [
+ z.object({
+ __typename: z.literal('ValidationError'),
+ }),
+ z.object({
+ __typename: z.literal('UnauthenticatedError'),
+ }),
+ ])
+ .nullish(),
+ }),
+})
+
+export interface UseCreateStripeSetupIntentArgs {
+ provider: string
+ owner: string
+ opts?: {
+ enabled?: boolean
+ }
+}
+
+function createStripeSetupIntent({
+ provider,
+ owner,
+ signal,
+}: {
+ provider: string
+ owner: string
+ signal?: AbortSignal
+}) {
+ return Api.graphql({
+ provider,
+ signal,
+ query: `
+ mutation CreateStripeSetupIntent($owner: String!) {
+ createStripeSetupIntent(input: { owner: $owner }) {
+ clientSecret
+ error {
+ __typename
+ }
+ }
+ }
+ `,
+ variables: {
+ owner,
+ },
+ })
+}
+
+export function useCreateStripeSetupIntent({
+ provider,
+ owner,
+ opts = {},
+}: UseCreateStripeSetupIntentArgs) {
+ return useQuery({
+ queryKey: ['setupIntent', provider, owner],
+ queryFn: ({ signal }) =>
+ createStripeSetupIntent({ provider, owner, signal }).then((res) => {
+ const parsedRes = CreateStripeSetupIntentSchema.safeParse(res.data)
+ if (!parsedRes.success) {
+ return Promise.reject({
+ status: 404,
+ data: {},
+ dev: 'useStripeSetupIntent - 404 failed to parse',
+ } satisfies NetworkErrorObject)
+ }
+
+ return parsedRes.data.createStripeSetupIntent
+ }),
+ ...opts,
+ })
+}
diff --git a/src/services/account/useUpdateBillingEmail.test.tsx b/src/services/account/useUpdateBillingEmail.test.tsx
index 4151927605..2049ca4024 100644
--- a/src/services/account/useUpdateBillingEmail.test.tsx
+++ b/src/services/account/useUpdateBillingEmail.test.tsx
@@ -95,6 +95,7 @@ describe('useUpdateBillingEmail', () => {
await waitFor(() =>
expect(mockBody).toHaveBeenCalledWith({
new_email: 'test@gmail.com',
+ should_propagate_to_payment_methods: true,
})
)
})
diff --git a/src/services/account/useUpdateBillingEmail.ts b/src/services/account/useUpdateBillingEmail.ts
index 27a1875d00..0cf009e7ab 100644
--- a/src/services/account/useUpdateBillingEmail.ts
+++ b/src/services/account/useUpdateBillingEmail.ts
@@ -20,6 +20,7 @@ export function useUpdateBillingEmail({ provider, owner }: UsePlanDataArgs) {
const body = {
/* eslint-disable camelcase */
new_email: formData?.newEmail,
+ should_propagate_to_payment_methods: true,
}
return Api.patch({ path, provider, body })
},
diff --git a/src/services/account/useUpdateCard.ts b/src/services/account/useUpdateCard.ts
deleted file mode 100644
index e28a7b1f30..0000000000
--- a/src/services/account/useUpdateCard.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useStripe } from '@stripe/react-stripe-js'
-import { StripeCardElement } from '@stripe/stripe-js'
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-
-import Api from 'shared/api'
-
-interface useUpdateCardParams {
- provider: string
- owner: string
-}
-
-interface useUpdateCardReturn {
- reset: () => void
- error: null | Error
- isLoading: boolean
- mutate: (variables: any, data: any) => void
- data: undefined | unknown
-}
-
-function getPathAccountDetails({ provider, owner }: useUpdateCardParams) {
- return `/${provider}/${owner}/account-details/`
-}
-
-export function useUpdateCard({
- provider,
- owner,
-}: useUpdateCardParams): useUpdateCardReturn {
- const stripe = useStripe()
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: (card: StripeCardElement) => {
- return stripe!
- .createPaymentMethod({
- type: 'card',
- card,
- })
- .then((result) => {
- if (result.error) return Promise.reject(result.error)
-
- const accountPath = getPathAccountDetails({ provider, owner })
- const path = `${accountPath}update_payment`
-
- return Api.patch({
- provider,
- path,
- body: {
- /* eslint-disable-next-line camelcase */
- payment_method: result.paymentMethod.id,
- },
- })
- })
- },
- onSuccess: (data) => {
- // update the local cache of account details from what the server returns
- queryClient.setQueryData(['accountDetails', provider, owner], data)
- },
- })
-}
diff --git a/src/services/account/useUpdateCard.test.tsx b/src/services/account/useUpdatePaymentMethod.test.tsx
similarity index 75%
rename from src/services/account/useUpdateCard.test.tsx
rename to src/services/account/useUpdatePaymentMethod.test.tsx
index 74c27764b6..e0077cabca 100644
--- a/src/services/account/useUpdateCard.test.tsx
+++ b/src/services/account/useUpdatePaymentMethod.test.tsx
@@ -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'
@@ -8,10 +10,11 @@ import { type Mock } from 'vitest'
import { Plans } from 'shared/utils/billing'
-import { useUpdateCard } from './useUpdateCard'
+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 }) => (
- {children}
+
+ {children}
+
)
@@ -65,14 +76,17 @@ afterAll(() => {
server.close()
})
-describe('useUpdateCard', () => {
+describe('useUpdatePaymentMethod', () => {
const card = {
last4: '1234',
}
- function setupStripe({ createPaymentMethod }: { createPaymentMethod: Mock }) {
+ function setupStripe({ confirmSetup }: { confirmSetup: Mock }) {
mocks.useStripe.mockReturnValue({
- createPaymentMethod,
+ confirmSetup,
+ })
+ mocks.useCreateStripeSetupIntent.mockReturnValue({
+ data: { clientSecret: 'test_secret' },
})
}
@@ -80,10 +94,12 @@ describe('useUpdateCard', () => {
describe('when the mutation is successful', () => {
beforeEach(() => {
setupStripe({
- createPaymentMethod: vi.fn(
+ confirmSetup: vi.fn(
() =>
new Promise((resolve) => {
- resolve({ paymentMethod: { id: 1 } })
+ resolve({
+ setupIntent: { payment_method: 'test_payment_method' },
+ })
})
),
})
@@ -100,7 +116,8 @@ describe('useUpdateCard', () => {
it('returns the data from the server', async () => {
const { result } = renderHook(
- () => useUpdateCard({ provider, owner }),
+ () =>
+ useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }),
{ wrapper: wrapper() }
)
@@ -116,7 +133,7 @@ describe('useUpdateCard', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
setupStripe({
- createPaymentMethod: vi.fn(
+ confirmSetup: vi.fn(
() =>
new Promise((resolve) => {
resolve({ error: { message: 'not good' } })
@@ -140,7 +157,8 @@ describe('useUpdateCard', () => {
it('does something', async () => {
const { result } = renderHook(
- () => useUpdateCard({ provider, owner }),
+ () =>
+ useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }),
{ wrapper: wrapper() }
)
diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts
new file mode 100644
index 0000000000..b6546dec1c
--- /dev/null
+++ b/src/services/account/useUpdatePaymentMethod.ts
@@ -0,0 +1,88 @@
+import { useElements, useStripe } from '@stripe/react-stripe-js'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+
+import config from 'config'
+
+import Api from 'shared/api'
+
+import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent'
+
+interface useUpdatePaymentMethodProps {
+ provider: string
+ owner: string
+ email?: string
+}
+
+interface useUpdatePaymentMethodReturn {
+ reset: () => void
+ error: null | Error
+ isLoading: boolean
+ mutate: (variables: any, data: any) => void
+ data: undefined | unknown
+}
+function getPathAccountDetails({
+ provider,
+ owner,
+}: {
+ provider: string
+ owner: string
+}) {
+ return `/${provider}/${owner}/account-details/`
+}
+
+export function useUpdatePaymentMethod({
+ provider,
+ owner,
+ email,
+}: useUpdatePaymentMethodProps): useUpdatePaymentMethodReturn {
+ const stripe = useStripe()
+ const elements = useElements()
+ const queryClient = useQueryClient()
+ const { data: setupIntent } = useCreateStripeSetupIntent({ owner, provider })
+
+ return useMutation({
+ mutationFn: () => {
+ const clientSecret = setupIntent?.clientSecret
+ if (!clientSecret) {
+ throw new Error('Client secret not found')
+ }
+
+ return stripe!
+ .confirmSetup({
+ clientSecret,
+ elements: elements!,
+ redirect: 'if_required',
+ confirmParams: {
+ // eslint-disable-next-line camelcase
+ payment_method_data: {
+ // eslint-disable-next-line camelcase
+ billing_details: {
+ email: email,
+ },
+ },
+ // eslint-disable-next-line camelcase
+ return_url: `${config.BASE_URL}/plan/${provider}/${owner}`,
+ },
+ })
+ .then((result) => {
+ if (result.error) return Promise.reject(result.error)
+
+ const accountPath = getPathAccountDetails({ provider, owner })
+ const path = `${accountPath}update_payment`
+
+ return Api.patch({
+ provider,
+ path,
+ body: {
+ /* eslint-disable-next-line camelcase */
+ payment_method: result.setupIntent.payment_method,
+ },
+ })
+ })
+ },
+ onSuccess: (data) => {
+ // update the local cache of account details from what the server returns
+ queryClient.setQueryData(['accountDetails', provider, owner], data)
+ },
+ })
+}
diff --git a/src/shared/ThemeContext/ThemeContext.tsx b/src/shared/ThemeContext/ThemeContext.tsx
index eb2d134876..59e9222bc6 100644
--- a/src/shared/ThemeContext/ThemeContext.tsx
+++ b/src/shared/ThemeContext/ThemeContext.tsx
@@ -29,7 +29,9 @@ interface ThemeContextProviderProps {
export const ThemeContextProvider: FC = ({
children,
}) => {
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ const prefersDark =
+ typeof window !== 'undefined' &&
+ (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false)
let systemTheme = Theme.LIGHT
if (prefersDark) {
diff --git a/src/shared/utils/billing.ts b/src/shared/utils/billing.ts
index f6b0140306..b5e6749738 100644
--- a/src/shared/utils/billing.ts
+++ b/src/shared/utils/billing.ts
@@ -143,7 +143,7 @@ export function formatTimestampToCalendarDate(
return new Intl.DateTimeFormat('en-US', options).format(date)
}
-export function lastTwoDigits(value: number | string) {
+export function lastTwoDigits(value: number | string | undefined) {
if (typeof value === 'number' || typeof value === 'string') {
return value.toString().slice(-2)
}
diff --git a/src/stripe.ts b/src/stripe.ts
new file mode 100644
index 0000000000..4df192e892
--- /dev/null
+++ b/src/stripe.ts
@@ -0,0 +1,51 @@
+export const StripeAppearance = (isDarkMode: boolean) => {
+ return {
+ appearance: {
+ variables: {
+ fontFamily: 'Poppins, ui-sans-serif, system-ui, sans-serif',
+ },
+ rules: {
+ '.Label': {
+ fontWeight: '600',
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.Input': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.Input:focus': {
+ borderColor: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.Tab': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.Tab:hover': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.Tab--selected': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.PickerItem': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.PickerItem:hover': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary.
+ },
+ '.PickerItem--selected': {
+ backgroundColor: isDarkMode ? 'rgb(22,24,29)' : 'rgb(255,255,255)', // Same values as --color-app-container.
+ borderColor: isDarkMode ? 'rgb(47,51,60)' : 'rgb(216,220,226)', // Same values as --color-ds-gray-tertiary.
+ },
+ },
+ },
+ }
+}
diff --git a/src/ui/Button/Button.tsx b/src/ui/Button/Button.tsx
index 846fcc52a8..38138b8b0e 100644
--- a/src/ui/Button/Button.tsx
+++ b/src/ui/Button/Button.tsx
@@ -134,7 +134,8 @@ function Button({
const className = cs(
baseClass,
{ [baseDisabledClasses]: !isLoading },
- pickVariant(variant, isLoading)
+ pickVariant(variant, isLoading),
+ props.className
)
const content = (