diff --git a/src/pages/PlanPage/subRoutes/CancelPlanPage/CancelPlanPage.tsx b/src/pages/PlanPage/subRoutes/CancelPlanPage/CancelPlanPage.tsx index aa545d4708..f87d6dea4c 100644 --- a/src/pages/PlanPage/subRoutes/CancelPlanPage/CancelPlanPage.tsx +++ b/src/pages/PlanPage/subRoutes/CancelPlanPage/CancelPlanPage.tsx @@ -9,6 +9,7 @@ import { useAvailablePlans, usePlanData, } from 'services/account' +import { Provider } from 'shared/api/helpers' import { BillingRate, shouldDisplayTeamCard } from 'shared/utils/billing' import Spinner from 'ui/Spinner' @@ -25,7 +26,7 @@ const Loader = () => ( function CancelPlanPage() { const { provider, owner } = useParams<{ - provider: string + provider: Provider owner: string }>() const { data: accountDetailsData } = useAccountDetails({ provider, owner }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index 5965614f09..75c66312a5 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -1,13 +1,14 @@ import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import { Provider } from 'shared/api/helpers' import AddressCard from './Address/AddressCard' import EmailAddress from './EmailAddress' import PaymentCard from './PaymentCard' interface URLParams { - provider: string + provider: Provider owner: string } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx index 8a159c2f13..e00f217034 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EmailAddress/EmailAddress.tsx @@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom' import { z } from 'zod' import { useAccountDetails, useUpdateBillingEmail } from 'services/account' +import { Provider } from 'shared/api/helpers' import A from 'ui/A' import Button from 'ui/Button' import Icon from 'ui/Icon' @@ -18,7 +19,7 @@ const emailSchema = z.object({ }) interface URLParams { - provider: string + provider: Provider owner: string } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx index 33a3cf78ba..34cb4fa93e 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentOrgPlan.tsx @@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom' import { usePlanUpdatedNotification } from 'pages/PlanPage/context' import { useAccountDetails, usePlanData } from 'services/account' +import { Provider } from 'shared/api/helpers' import { getScheduleStart } from 'shared/plan/ScheduledPlanDetails/ScheduledPlanDetails' import A from 'ui/A' import { Alert } from 'ui/Alert' @@ -16,7 +17,7 @@ import LatestInvoiceCard from './LatestInvoiceCard' import { EnterpriseAccountDetailsQueryOpts } from './queries/EnterpriseAccountDetailsQueryOpts' interface URLParams { - provider: string + provider: Provider owner: string } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/CurrentPlanCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/CurrentPlanCard.tsx index ce7d59779e..82e13e67e5 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/CurrentPlanCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/CurrentPlanCard.tsx @@ -1,6 +1,7 @@ import { useParams } from 'react-router-dom' import { useAccountDetails, usePlanData } from 'services/account' +import { Provider } from 'shared/api/helpers' import { CollectionMethods } from 'shared/utils/billing' import EnterprisePlanCard from './EnterprisePlanCard' @@ -8,7 +9,7 @@ import FreePlanCard from './FreePlanCard' import PaidPlanCard from './PaidPlanCard' interface URLParams { - provider: string + provider: Provider owner: string } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/PaidPlanCard/PaidPlanCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/PaidPlanCard/PaidPlanCard.tsx index 7da63d9407..95714e2837 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/PaidPlanCard/PaidPlanCard.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/CurrentPlanCard/PaidPlanCard/PaidPlanCard.tsx @@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom' import { PlanPageDataQueryOpts } from 'pages/PlanPage/queries/PlanPageDataQueryOpts' import { useAccountDetails, usePlanData } from 'services/account' +import { Provider } from 'shared/api/helpers' import BenefitList from 'shared/plan/BenefitList' import ScheduledPlanDetails from 'shared/plan/ScheduledPlanDetails' @@ -11,7 +12,7 @@ import ActionsBilling from '../shared/ActionsBilling/ActionsBilling' import PlanPricing from '../shared/PlanPricing' type URLParams = { - provider: string + provider: Provider owner: string } diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/PriceCallout/PriceCallout.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/PriceCallout/PriceCallout.tsx index 34865dcb0e..07b6f8431b 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/PriceCallout/PriceCallout.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/PriceCallout/PriceCallout.tsx @@ -7,6 +7,7 @@ import { useAccountDetails, useAvailablePlans, } from 'services/account' +import { Provider } from 'shared/api/helpers' import { BillingRate, findProPlans, @@ -32,7 +33,7 @@ const PriceCallout: React.FC = ({ seats, setFormValue, }) => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: plans } = useAvailablePlans({ provider, owner }) const { proPlanMonth, proPlanYear } = findProPlans({ plans }) const perMonthPrice = calculatePriceProPlan({ diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/UserCount/UserCount.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/UserCount/UserCount.tsx index 6a9e378141..a57579c8f7 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/UserCount/UserCount.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/ProPlanController/UserCount/UserCount.tsx @@ -2,6 +2,7 @@ import isNumber from 'lodash/isNumber' import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import { Provider } from 'shared/api/helpers' interface StudentTextProps { activatedStudents?: number @@ -41,7 +42,7 @@ const UserText: React.FC = ({ } const UserCount: React.FC = () => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: accountDetails } = useAccountDetails({ provider, owner }) const activatedStudentCount = accountDetails?.activatedStudentCount diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/PriceCallout/PriceCallout.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/PriceCallout/PriceCallout.tsx index 74fc072e65..ca9ba6ccf7 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/PriceCallout/PriceCallout.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/PriceCallout/PriceCallout.tsx @@ -7,6 +7,7 @@ import { useAccountDetails, useAvailablePlans, } from 'services/account' +import { Provider } from 'shared/api/helpers' import { BillingRate, findSentryPlans, @@ -33,7 +34,7 @@ const PriceCallout: React.FC = ({ seats, setFormValue, }) => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: plans } = useAvailablePlans({ provider, owner }) const { sentryPlanMonth, sentryPlanYear } = findSentryPlans({ plans }) const perMonthPrice = calculatePriceSentryPlan({ diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/UserCount/UserCount.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/UserCount/UserCount.tsx index 4ae581a90f..c6b4782bae 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/UserCount/UserCount.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/SentryPlanController/UserCount/UserCount.tsx @@ -2,6 +2,7 @@ import isNumber from 'lodash/isNumber' import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import { Provider } from 'shared/api/helpers' interface StudentTextProps { activatedStudents?: number @@ -30,7 +31,7 @@ const UserText: React.FC = () => { } const UserCount: React.FC = () => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: accountDetails } = useAccountDetails({ provider, owner }) const activatedStudentCount = accountDetails?.activatedStudentCount diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/PriceCallout/PriceCallout.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/PriceCallout/PriceCallout.tsx index 048f5ee013..28c828a139 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/PriceCallout/PriceCallout.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/PriceCallout/PriceCallout.tsx @@ -8,6 +8,7 @@ import { useAccountDetails, useAvailablePlans, } from 'services/account' +import { Provider } from 'shared/api/helpers' import { BillingRate, findTeamPlans, @@ -34,7 +35,7 @@ const PriceCallout: React.FC = ({ seats, setFormValue, }) => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: plans } = useAvailablePlans({ provider, owner }) const { teamPlanMonth, teamPlanYear } = findTeamPlans({ plans }) const perMonthPrice = calculatePriceTeamPlan({ diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/UserCount/UserCount.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/UserCount/UserCount.tsx index 27cdd96214..202d90d174 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/UserCount/UserCount.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/Controllers/TeamPlanController/UserCount/UserCount.tsx @@ -2,6 +2,7 @@ import isNumber from 'lodash/isNumber' import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import { Provider } from 'shared/api/helpers' interface StudentTextProps { activatedStudents?: number @@ -42,7 +43,7 @@ const UserText: React.FC = ({ } const UserCount: React.FC = () => { - const { provider, owner } = useParams<{ provider: string; owner: string }>() + const { provider, owner } = useParams<{ provider: Provider; owner: string }>() const { data: accountDetails } = useAccountDetails({ provider, owner }) const activatedStudentCount = accountDetails?.activatedStudentCount diff --git a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx index 43d3245786..86a8186aec 100644 --- a/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx +++ b/src/pages/PlanPage/subRoutes/UpgradePlanPage/UpgradeForm/UpgradeForm.tsx @@ -9,6 +9,7 @@ import { useAvailablePlans, usePlanData, } from 'services/account' +import { Provider } from 'shared/api/helpers' import { canApplySentryUpgrade, getNextBillingDate } from 'shared/utils/billing' import { getDefaultValuesUpgradeForm, @@ -24,7 +25,7 @@ import UpdateBlurb from './UpdateBlurb/UpdateBlurb' import UpdateButton from './UpdateButton' type URLParams = { - provider: string + provider: Provider owner: string } diff --git a/src/services/account/index.ts b/src/services/account/index.ts index 2c2da2150c..4780cda4a0 100644 --- a/src/services/account/index.ts +++ b/src/services/account/index.ts @@ -9,5 +9,6 @@ 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..d3fb95b85b 100644 --- a/src/services/account/useAccountDetails.ts +++ b/src/services/account/useAccountDetails.ts @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { z } from 'zod' import Api from 'shared/api' -import { NetworkErrorObject } from 'shared/api/helpers' +import { NetworkErrorObject, Provider } from 'shared/api/helpers' const InvoiceSchema = z .object({ @@ -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() @@ -147,7 +153,7 @@ export const AccountDetailsSchema = z.object({ }) export interface UseAccountDetailsArgs { - provider: string + provider: Provider owner: string opts?: { enabled?: boolean @@ -158,7 +164,7 @@ function getPathAccountDetails({ provider, owner, }: { - provider: string + provider: Provider owner: string }) { return `/${provider}/${owner}/account-details/` @@ -169,7 +175,7 @@ function fetchAccountDetails({ owner, signal, }: { - provider: string + provider: Provider owner: string signal?: AbortSignal }) { 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..64016da7f8 --- /dev/null +++ b/src/services/account/useCreateStripeSetupIntent.ts @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' + +import Api from 'shared/api' +import { + NetworkErrorObject, + Provider, + rejectNetworkError, +} 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'), + }), + z.object({ + __typename: z.literal('UnauthorizedError'), + }), + ]) + .nullish(), + }), +}) + +export interface UseCreateStripeSetupIntentArgs { + provider: Provider + owner: string + opts?: { + enabled?: boolean + } +} + +function createStripeSetupIntent({ + provider, + owner, + signal, +}: { + provider: Provider + 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 rejectNetworkError({ + status: 404, + error: parsedRes.error, + data: {}, + dev: 'useStripeSetupIntent - 404 failed to parse', + } satisfies NetworkErrorObject) + } + + const error = parsedRes.data.createStripeSetupIntent.error + if (error?.__typename) { + return Promise.reject({ + error: error.__typename, + message: 'Error creating setup intent', + }) + } + + return parsedRes.data.createStripeSetupIntent + }), + ...opts, + }) +} diff --git a/src/services/account/useUpdateBillingEmail.test.tsx b/src/services/account/useUpdateBillingEmail.test.tsx index 4151927605..a7eb357f85 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', + apply_to_default_payment_method: true, }) ) }) diff --git a/src/services/account/useUpdateBillingEmail.ts b/src/services/account/useUpdateBillingEmail.ts index 27a1875d00..8f26d669c7 100644 --- a/src/services/account/useUpdateBillingEmail.ts +++ b/src/services/account/useUpdateBillingEmail.ts @@ -1,9 +1,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import Api from 'shared/api' +import { Provider } from 'shared/api/helpers' interface UsePlanDataArgs { - provider: string + provider: Provider owner: string } @@ -20,6 +21,7 @@ export function useUpdateBillingEmail({ provider, owner }: UsePlanDataArgs) { const body = { /* eslint-disable camelcase */ new_email: formData?.newEmail, + apply_to_default_payment_method: true, } return Api.patch({ path, provider, body }) }, diff --git a/src/services/account/useUpdatePaymentMethod.test.tsx b/src/services/account/useUpdatePaymentMethod.test.tsx new file mode 100644 index 0000000000..e0077cabca --- /dev/null +++ b/src/services/account/useUpdatePaymentMethod.test.tsx @@ -0,0 +1,175 @@ +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' +import { setupServer } from 'msw/node' +import React from 'react' +import { MemoryRouter, Route } from 'react-router-dom' +import { type Mock } from 'vitest' + +import { Plans } from 'shared/utils/billing' + +import { useUpdatePaymentMethod } from './useUpdatePaymentMethod' + +const mocks = vi.hoisted(() => ({ + useStripe: vi.fn(), + useCreateStripeSetupIntent: vi.fn(), +})) + +vi.mock('@stripe/react-stripe-js', async () => { + const original = await vi.importActual('@stripe/react-stripe-js') + return { + ...original, + useStripe: mocks.useStripe, + } +}) + +vi.mock('./useCreateStripeSetupIntent', () => ({ + useCreateStripeSetupIntent: mocks.useCreateStripeSetupIntent, +})) + +const stripePromise = loadStripe('fake-publishable-key') + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const wrapper = + (initialEntries = '/gh'): React.FC => + ({ children }) => ( + + + + {children} + + + + ) + +const provider = 'gh' +const owner = 'codecov' + +const accountDetails = { + plan: { + marketingName: 'Pro Team', + baseUnitPrice: 12, + benefits: ['Configurable # of users', 'Unlimited repos'], + quantity: 5, + value: Plans.USERS_PR_INAPPM, + }, + activatedUserCount: 2, + inactiveUserCount: 1, +} + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +describe('useUpdatePaymentMethod', () => { + const card = { + last4: '1234', + } + + function setupStripe({ confirmSetup }: { confirmSetup: Mock }) { + mocks.useStripe.mockReturnValue({ + confirmSetup, + }) + mocks.useCreateStripeSetupIntent.mockReturnValue({ + data: { clientSecret: 'test_secret' }, + }) + } + + describe('when called', () => { + describe('when the mutation is successful', () => { + beforeEach(() => { + setupStripe({ + confirmSetup: vi.fn( + () => + new Promise((resolve) => { + resolve({ + setupIntent: { payment_method: 'test_payment_method' }, + }) + }) + ), + }) + + server.use( + http.patch( + `/internal/${provider}/${owner}/account-details/update_payment`, + () => { + return HttpResponse.json(accountDetails) + } + ) + ) + }) + + it('returns the data from the server', async () => { + const { result } = renderHook( + () => + useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }), + { wrapper: wrapper() } + ) + + // @ts-expect-error mutation mock + result.current.mutate(card) + + await waitFor(() => expect(result.current.data).toEqual(accountDetails)) + }) + }) + + describe('when the mutation is not successful', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + setupStripe({ + confirmSetup: vi.fn( + () => + new Promise((resolve) => { + resolve({ error: { message: 'not good' } }) + }) + ), + }) + + server.use( + http.patch( + `/internal/${provider}/${owner}/account-details/update_payment`, + () => { + return HttpResponse.json(accountDetails) + } + ) + ) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it('does something', async () => { + const { result } = renderHook( + () => + useUpdatePaymentMethod({ provider, owner, email: 'test@test.com' }), + { wrapper: wrapper() } + ) + + // @ts-expect-error mutation mock + result.current.mutate(card) + + await waitFor(() => result.current.error) + await waitFor(() => + expect(result.current.error).toEqual({ message: 'not good' }) + ) + }) + }) + }) +}) diff --git a/src/services/account/useUpdatePaymentMethod.ts b/src/services/account/useUpdatePaymentMethod.ts new file mode 100644 index 0000000000..8cdb844901 --- /dev/null +++ b/src/services/account/useUpdatePaymentMethod.ts @@ -0,0 +1,93 @@ +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import config from 'config' + +import Api from 'shared/api' +import { Provider } from 'shared/api/helpers' + +import { useCreateStripeSetupIntent } from './useCreateStripeSetupIntent' + +interface useUpdatePaymentMethodProps { + provider: Provider + owner: string + email?: string +} + +interface useUpdatePaymentMethodReturn { + reset: () => void + error: null | Error + isLoading: boolean + mutate: ( + variables: typeof PaymentElement, + data?: { onSuccess?: () => void } + ) => void + data: undefined | unknown +} + +function getPathAccountDetails({ + provider, + owner, +}: { + provider: Provider + 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) + }, + }) +}