diff --git a/src/assets/billing/bank.svg b/src/assets/billing/bank.svg new file mode 100644 index 0000000000..42a10ad51e --- /dev/null +++ b/src/assets/billing/bank.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/pages/PlanPage/PlanPage.jsx b/src/pages/PlanPage/PlanPage.jsx index 8ae321a08c..2608b9efb9 100644 --- a/src/pages/PlanPage/PlanPage.jsx +++ b/src/pages/PlanPage/PlanPage.jsx @@ -8,6 +8,7 @@ import config from 'config' import { SentryRoute } from 'sentry' +import { Theme, useThemeContext } from 'shared/ThemeContext' import LoadingLogo from 'ui/LoadingLogo' import { PlanProvider } from './context' @@ -15,6 +16,8 @@ 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')) @@ -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 @@ -45,7 +50,14 @@ function PlanPage() { return (
- + }> diff --git a/src/pages/PlanPage/PlanPage.test.jsx b/src/pages/PlanPage/PlanPage.test.jsx index 3cdb714f09..b7bbc12378 100644 --- a/src/pages/PlanPage/PlanPage.test.jsx +++ b/src/pages/PlanPage/PlanPage.test.jsx @@ -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') @@ -44,18 +46,20 @@ const wrapper = ({ children }) => ( - - - {children} - { - testLocation = location - return null - }} - /> - - + + + + {children} + { + testLocation = location + return null + }} + /> + + + ) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx deleted file mode 100644 index 5142eafd5c..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.test.tsx +++ /dev/null @@ -1,392 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { z } from 'zod' - -import { SubscriptionDetailSchema } from 'services/account' -import { ThemeContextProvider } from 'shared/ThemeContext' - -import AddressCard from './AddressCard' - -const mocks = vi.hoisted(() => ({ - useUpdateBillingAddress: vi.fn(), -})) - -vi.mock('services/account/useUpdateBillingAddress', async () => { - const actual = await import('services/account/useUpdateBillingAddress') - return { - ...actual, - useUpdateBillingAddress: mocks.useUpdateBillingAddress, - } -}) - -const subscriptionDetail = { - defaultPaymentMethod: { - card: { - brand: 'visa', - expMonth: 12, - expYear: 2021, - last4: '1234', - }, - billingDetails: { - name: 'Bob Smith', - address: { - line1: '123 Sesame St.', - line2: 'Apt A', - city: 'San Francisco', - country: 'US', - state: 'CA', - postalCode: '12345', - }, - }, - }, - currentPeriodEnd: 1606851492, - cancelAtPeriodEnd: false, -} as z.infer - -const wrapper: React.FC = ({ children }) => ( - {children} -) - -// mocking stripe components -vi.mock('@stripe/react-stripe-js', () => { - function makeFakeComponent(name: string) { - // mocking onReady to be called after a bit of time - return function Component({ _onReady }: { _onReady?: any }) { - return name - } - } - - return { - useElements: () => ({ - getElement: vi.fn().mockReturnValue({ - getValue: vi.fn().mockResolvedValue({ - complete: true, - value: { - address: {}, - }, - }), - }), - update: vi.fn(), - }), - useStripe: () => ({}), - AddressElement: makeFakeComponent('AddressElement'), - } -}) - -describe('AddressCard', () => { - 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( - , - { wrapper } - ) - - expect( - screen.getByText( - /No address has been set. Please contact support if you think it’s an error or set it yourself./ - ) - ).toBeInTheDocument() - }) - }) - - describe(`when the user doesn't have billing details`, () => { - it('renders an error message', () => { - render( - , - { wrapper } - ) - - expect( - screen.getByText( - /No address has been set. Please contact support if you think it’s an error or set it yourself./ - ) - ).toBeInTheDocument() - }) - - describe('when the user clicks on "Set Address"', () => { - it(`doesn't render address info stuff anymore`, async () => { - const { user } = setup() - render( - , - { wrapper } - ) - - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.queryByText(/123 Sesame St. Apt A/) - ).not.toBeInTheDocument() - }) - - it('renders the address form component', async () => { - const { user } = setup() - render( - , - { wrapper } - ) - - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - }) - }) - - describe('when the user has an address', () => { - it('renders the address information', () => { - render( - , - { wrapper } - ) - - expect(screen.getByText(/Billing address/)).toBeInTheDocument() - expect(screen.getByText(/123 Sesame St. Apt A/)).toBeInTheDocument() - expect(screen.getByText(/San Francisco, CA 12345/)).toBeInTheDocument() - }) - - it('can render partial information too', () => { - render( - , - { wrapper } - ) - - expect(screen.getByText('Cardholder name')).toBeInTheDocument() - expect(screen.getByText('N/A')).toBeInTheDocument() - expect(screen.getByText('Billing address')).toBeInTheDocument() - expect(screen.queryByText(/null/)).not.toBeInTheDocument() - expect(screen.getByText('12345')).toBeInTheDocument() - }) - - it('renders the card holder information', () => { - render( - , - { wrapper } - ) - - expect(screen.getByText(/Cardholder name/)).toBeInTheDocument() - expect(screen.getByText(/Bob Smith/)).toBeInTheDocument() - }) - }) - - describe('when the user clicks on Edit', () => { - it(`doesn't render the card anymore`, async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect(screen.queryByText(/Cardholder name/)).not.toBeInTheDocument() - expect(screen.queryByText(/Bob Smith/)).not.toBeInTheDocument() - expect(screen.queryByText(/Billing address/)).not.toBeInTheDocument() - expect(screen.queryByText(/123 Sesame St. Apt A/)).not.toBeInTheDocument() - expect( - screen.queryByText(/San Francisco, CA 12345/) - ).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - - describe('when submitting', () => { - it('calls the service to update the address', async () => { - const { user } = setup() - const updateAddress = vi.fn() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: updateAddress, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - await user.click(screen.queryByRole('button', { name: /update/i })!) - - expect(updateAddress).toHaveBeenCalled() - }) - }) - - describe('when the user clicks on cancel', () => { - it(`doesn't render the form anymore`, async () => { - const { user } = setup() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - isLoading: false, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-address')) - await user.click(screen.getByRole('button', { name: /Cancel/ })) - - expect( - screen.queryByRole('button', { name: /save/i }) - ).not.toBeInTheDocument() - }) - }) - }) - - describe('when there is an error in the form', () => { - it('renders the error', async () => { - const { user } = setup() - const randomError = 'not a valid address' - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - error: randomError, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-address')) - - expect(screen.getByText(randomError)).toBeInTheDocument() - }) - }) - - describe('when the form is loading', () => { - it('has the error and save button disabled', async () => { - const { user } = setup() - mocks.useUpdateBillingAddress.mockReturnValue({ - mutate: vi.fn(), - isLoading: true, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-address')) - - expect(screen.queryByRole('button', { name: /update/i })).toBeDisabled() - expect(screen.queryByRole('button', { name: /cancel/i })).toBeDisabled() - }) - }) -}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx deleted file mode 100644 index 377a7a3c9e..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressCard.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState } from 'react' -import { z } from 'zod' - -import { - BillingDetailsSchema, - SubscriptionDetailSchema, -} from 'services/account' -import A from 'ui/A' -import Button from 'ui/Button' -import Icon from 'ui/Icon' - -import AddressForm from './AddressForm' - -interface AddressCardProps { - subscriptionDetail: z.infer - provider: string - owner: string -} - -function AddressCard({ - subscriptionDetail, - provider, - owner, -}: AddressCardProps) { - const [isFormOpen, setIsFormOpen] = useState(false) - const billingDetails = - subscriptionDetail?.defaultPaymentMethod?.billingDetails - - return ( -
- {isFormOpen && ( - setIsFormOpen(false)} - /> - )} - {!isFormOpen && ( - <> - - - - )} -
- ) -} - -interface BillingInnerProps { - billingDetails?: z.infer - setIsFormOpen: (val: boolean) => void -} - -function BillingInner({ billingDetails, setIsFormOpen }: BillingInnerProps) { - if (billingDetails) { - return ( -
-

{`${billingDetails.name ?? 'N/A'}`}

-
-

Billing address

-

{`${billingDetails.address?.line1 ?? ''} ${ - billingDetails.address?.line2 ?? '' - }`}

-

- {billingDetails.address?.city - ? `${billingDetails.address?.city}, ` - : ''} - {`${billingDetails.address?.state ?? ''} ${ - billingDetails.address?.postalCode ?? '' - }`} -

-
- ) - } - - return ( -
-

- No address has been set. Please contact support if you think it’s an - error or set it yourself. -

-
- -
-
- ) -} - -export default AddressCard diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx index c524ccd390..1877e4e717 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.test.tsx @@ -8,11 +8,15 @@ import { Plans } from 'shared/utils/billing' import BillingDetails from './BillingDetails' -vi.mock('./PaymentCard/PaymentCard', () => ({ default: () => 'Payment Card' })) +vi.mock('./ViewPaymentMethod/PaymentMethod/PaymentMethod', () => ({ + default: () => 'Payment Method', +})) vi.mock('./EmailAddress/EmailAddress', () => ({ default: () => 'Email Address', })) -vi.mock('./Address/AddressCard', () => ({ default: () => 'Address Card' })) +vi.mock('./ViewPaymentMethod/Address/Address', () => ({ + default: () => 'Address Card', +})) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -84,12 +88,12 @@ describe('BillingDetails', () => { } describe('when there is a subscription', () => { - it('renders the payment card', async () => { + it('renders the payment method card', async () => { setup({ hasSubscription: true }) render(, { wrapper }) - const paymentCard = await screen.findByText(/Payment Card/) - expect(paymentCard).toBeInTheDocument() + const paymentCards = await screen.findAllByText(/Payment Method/) + expect(paymentCards.length).toBeGreaterThan(0) }) it('renders the email address component', async () => { @@ -132,7 +136,7 @@ describe('BillingDetails', () => { it('renders the payment card', async () => { render(, { wrapper }) - const paymentCard = screen.queryByText(/Payment Card/) + const paymentCard = screen.queryByText(/Payment Method/) expect(paymentCard).not.toBeInTheDocument() }) }) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx index 5965614f09..bc74d38261 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/BillingDetails.tsx @@ -1,10 +1,16 @@ +import { useState } from 'react' import { useParams } from 'react-router-dom' import { useAccountDetails } from 'services/account' +import A from 'ui/A' +import Button from 'ui/Button' -import AddressCard from './Address/AddressCard' +import EditPaymentMethods from './EditPaymentMethods' import EmailAddress from './EmailAddress' -import PaymentCard from './PaymentCard' +import { ViewPaymentMethod } from './ViewPaymentMethod' + +// Remove this when we build Secondary Payment Method feature +export const SECONDARY_PAYMENT_FEATURE_ENABLED = false interface URLParams { provider: string @@ -18,34 +24,86 @@ function BillingDetails() { owner, }) const subscriptionDetail = accountDetails?.subscriptionDetail + const [isEditMode, setEditMode] = useState(false) + + const secondaryPaymentFeatureEnabled = SECONDARY_PAYMENT_FEATURE_ENABLED if (!subscriptionDetail) { return null } return ( -
-

Billing details

- - - - {subscriptionDetail.taxIds.length > 0 ? ( -
-

Tax ID

- {subscriptionDetail.taxIds.map((val, index) => ( -

{val?.value}

- ))} +
+ {/* Billing Details Section */} +
+
+

Billing details

+

+ You can modify your billing details. To update your tax IDs, please{' '} + {/* @ts-expect-error ignore until we can convert A component to ts */} + + contact support + +

- ) : null} + {!isEditMode ? ( + + ) : ( + + )} +
+ {isEditMode ? ( + + ) : ( + <> + + + {secondaryPaymentFeatureEnabled && ( + + )} + {subscriptionDetail?.taxIds?.length ? ( +
+

Tax ID

+ {subscriptionDetail?.taxIds?.map((val, index) => ( +

{val?.value}

+ ))} +
+ ) : null} + + )}
) } diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx new file mode 100644 index 0000000000..3c547763a3 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.test.tsx @@ -0,0 +1,232 @@ +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 AddressForm from './AddressForm' + +const queryClient = new QueryClient() + +const mockGetElement = vi.fn() +const mockGetValue = vi.fn() + +vi.mock('@stripe/react-stripe-js', async () => { + const actual = await vi.importActual('@stripe/react-stripe-js') + return { + ...actual, + useElements: () => ({ + getElement: mockGetElement.mockReturnValue({ + getValue: mockGetValue.mockResolvedValue({ + complete: true, + value: { + name: 'John Doe', + address: { + line1: '123 Main St', + line2: null, + city: 'San Francisco', + state: 'CA', + postal_code: '94105', + country: 'US', + }, + }, + }), + }), + }), + } +}) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const mockSubscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, +} + +const mocks = { + useUpdateBillingAddress: vi.fn(), +} + +vi.mock('services/account/useUpdateBillingAddress', () => ({ + useUpdateBillingAddress: () => mocks.useUpdateBillingAddress(), +})) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('AddressForm', () => { + const setup = () => { + return { user: userEvent.setup() } + } + + it('renders the form', () => { + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: false, + }) + + render( + {}} + />, + { wrapper } + ) + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + describe('when submitting', () => { + it('calls the service to update the address', async () => { + const user = userEvent.setup() + const updateAddress = vi.fn() + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: updateAddress, + isLoading: false, + }) + + render( + {}} + />, + { wrapper } + ) + + await user.click(screen.getByTestId('submit-address-update')) + expect(updateAddress).toHaveBeenCalledWith( + { + name: 'John Doe', + address: { + line1: '123 Main St', + line2: null, + city: 'San Francisco', + state: 'CA', + postal_code: '94105', + country: 'US', + }, + }, + expect.any(Object) + ) + }) + }) + + describe('when the user clicks on cancel', () => { + it('calls the closeForm prop', async () => { + const { user } = setup() + const closeForm = vi.fn() + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: false, + }) + + render( + , + { wrapper } + ) + + await user.click(screen.getByRole('button', { name: /cancel/i })) + + expect(closeForm).toHaveBeenCalled() + }) + }) + + describe('when the form is loading', () => { + it('has the save and cancel buttons disabled', () => { + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: vi.fn(), + isLoading: true, + error: null, + reset: vi.fn(), + }) + + render( + {}} + />, + { wrapper } + ) + + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled() + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx similarity index 63% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx index f7fd0e7cd0..027ba1c985 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/Address/AddressForm.tsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/Address/AddressForm.tsx @@ -4,7 +4,6 @@ import { z } from 'zod' import { AddressSchema } from 'services/account' import { useUpdateBillingAddress } from 'services/account/useUpdateBillingAddress' -import { Theme, useThemeContext } from 'shared/ThemeContext' import Button from 'ui/Button' interface AddressFormProps { @@ -23,35 +22,8 @@ function AddressForm({ owner, }: AddressFormProps) { const elements = useElements() - const { theme } = useThemeContext() - const isDarkMode = theme === Theme.DARK - // Note: unfortunately seems Stripe doesn't let us reference like `var(--)` so rgbs are hardcoded in below - elements?.update({ - 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. - }, - }, - }, - }) - - const { - mutate: updateAddress, - isLoading, - error, - reset, - } = useUpdateBillingAddress({ + const { mutate: updateAddress, isLoading } = useUpdateBillingAddress({ provider, owner, }) @@ -70,8 +42,6 @@ function AddressForm({ } } - const showError = error && !reset - return (
@@ -95,7 +65,6 @@ function AddressForm({ }, }} /> -

{showError && error}

diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx new file mode 100644 index 0000000000..9cf7e4cba8 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.test.tsx @@ -0,0 +1,81 @@ +import { Elements } from '@stripe/react-stripe-js' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route } from 'react-router-dom' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' + +import EditPaymentMethods from './EditPaymentMethods' + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +vi.mock('./PaymentMethod/PaymentMethodForm', () => ({ + default: () => 'Payment Method Form', +})) + +vi.mock('./Address/AddressForm', () => ({ + default: () => 'Address Form', +})) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const mockSubscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + latestInvoice: null, + taxIds: [], + trialEnd: null, +} + +describe('EditPaymentMethod', () => { + it('renders the expected forms', () => { + render( + {}} + provider="gh" + owner="codecov" + subscriptionDetail={mockSubscriptionDetail} + />, + { wrapper } + ) + + expect(screen.getByText(/Payment Method Form/)).toBeInTheDocument() + expect(screen.getByText(/Address Form/)).toBeInTheDocument() + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx new file mode 100644 index 0000000000..ee029ca825 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/EditPaymentMethods.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account/useAccountDetails' + +import AddressForm from './Address/AddressForm' +import PaymentMethodForm from './PaymentMethod/PaymentMethodForm' + +import { SECONDARY_PAYMENT_FEATURE_ENABLED } from '../BillingDetails' + +interface EditPaymentMethodsProps { + setEditMode: (isEditMode: boolean) => void + provider: string + owner: string + subscriptionDetail: z.infer +} + +const EditPaymentMethods = ({ + setEditMode, + provider, + owner, + subscriptionDetail, +}: EditPaymentMethodsProps) => { + const [activeTab, setActiveTab] = useState<'primary' | 'secondary'>('primary') + + const secondaryPaymentMethodFeatureEnabled = SECONDARY_PAYMENT_FEATURE_ENABLED + + return ( +
+

Edit payment method

+
+ {/* Tabs for Primary and Secondary Payment Methods */} +
+ {[ + 'primary', + ...(secondaryPaymentMethodFeatureEnabled ? ['secondary'] : []), + ].map((tab) => ( + + ))} +
+ + {/* Payment Details for the selected tab */} +
+ {activeTab === 'primary' && ( +
+ setEditMode(false)} + provider={provider} + owner={owner} + subscriptionDetail={subscriptionDetail} + /> + setEditMode(false)} + provider={provider} + owner={owner} + /> +
+ )} + {activeTab === 'secondary' && ( +
+ setEditMode(false)} + provider={provider} + owner={owner} + subscriptionDetail={subscriptionDetail} + /> + setEditMode(false)} + provider={provider} + owner={owner} + /> +
+ )} +
+
+
+ ) +} + +export default EditPaymentMethods diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx new file mode 100644 index 0000000000..e77927c042 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.test.tsx @@ -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 = ({ children }) => ( + + + + {children} + + + +) + +const subscriptionDetail: z.infer = { + defaultPaymentMethod: { + billingDetails: { + address: { + line1: '123 Main St', + city: 'San Francisco', + state: 'CA', + postalCode: '94105', + country: 'US', + line2: null, + }, + phone: '1234567890', + name: 'John Doe', + email: 'test@example.com', + }, + card: { + brand: 'visa', + expMonth: 12, + expYear: 2025, + last4: '4242', + }, + }, + currentPeriodEnd: 1706851492, + cancelAtPeriodEnd: false, + customer: { + id: 'cust_123', + email: 'test@example.com', + }, + 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( + {}} + />, + { 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( + {}} + />, + { 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( + {}} + />, + { 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( + , + { 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( + {}} + />, + { 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( + {}} + />, + { wrapper } + ) + + expect(screen.queryByRole('button', { name: /Save/i })).toBeDisabled() + expect(screen.queryByRole('button', { name: /Cancel/i })).toBeDisabled() + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx new file mode 100644 index 0000000000..2deb95111f --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/PaymentMethod/PaymentMethodForm.tsx @@ -0,0 +1,104 @@ +import { PaymentElement, useElements } from '@stripe/react-stripe-js' +import cs from 'classnames' +import { useState } from 'react' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { useUpdatePaymentMethod } from 'services/account/useUpdatePaymentMethod' +import Button from 'ui/Button' + +interface PaymentMethodFormProps { + closeForm: () => void + provider: string + owner: string + subscriptionDetail: z.infer +} + +const PaymentMethodForm = ({ + closeForm, + provider, + owner, + subscriptionDetail, +}: PaymentMethodFormProps) => { + const [errorState, _] = useState('') + const elements = useElements() + + const { + mutate: updatePaymentMethod, + isLoading, + error, + reset, + } = useUpdatePaymentMethod({ + provider, + owner, + email: + subscriptionDetail?.defaultPaymentMethod?.billingDetails?.email || + undefined, + }) + + async function submit(e: React.FormEvent) { + e.preventDefault() + + if (!elements) { + return null + } + + elements.submit() + + const paymentElement = elements.getElement(PaymentElement) + + updatePaymentMethod(paymentElement, { + onSuccess: async () => { + closeForm() + }, + }) + } + + const showError = (error && !reset) || errorState + + return ( + +
+
+ +

+ {showError && (error?.message || errorState)} +

+
+ + +
+
+
+ + ) +} + +export default PaymentMethodForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts new file mode 100644 index 0000000000..a53f3b83bd --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/EditPaymentMethods/index.ts @@ -0,0 +1 @@ +export { default } from './EditPaymentMethods' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx deleted file mode 100644 index bfe18a3c7c..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CreditCardForm.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { CardElement, useElements } from '@stripe/react-stripe-js' -import cs from 'classnames' -import { useState } from 'react' - -import { useUpdateCard } from 'services/account' -import { Theme, useThemeContext } from 'shared/ThemeContext' -import Button from 'ui/Button' - -interface CreditCardFormProps { - closeForm: () => void - provider: string - owner: string -} - -function CreditCardForm({ closeForm, provider, owner }: CreditCardFormProps) { - const [errorState, setErrorState] = useState('') - const { theme } = useThemeContext() - const isDarkMode = theme === Theme.DARK - - const elements = useElements() - const { - mutate: updateCard, - isLoading, - error, - reset, - } = useUpdateCard({ - provider, - owner, - }) - - function submit(e: React.FormEvent) { - e.preventDefault() - - if (!elements) { - return null - } - - updateCard(elements.getElement(CardElement), { - onSuccess: closeForm, - }) - } - - const showError = (error && !reset) || errorState - - return ( -
-
-
- setErrorState(e.error?.message || '')} - options={{ - disableLink: true, - hidePostalCode: true, - classes: { - empty: - 'rounded border-ds-gray-tertiary border-2 bg-ds-container', - focus: - 'rounded !border-ds-blue-darker border-4 bg-ds-container', - base: 'px-4 py-3 bg-ds-container', - invalid: - 'rounded !border-ds-primary-red border-4 bg-ds-container', - }, - style: { - base: { - color: isDarkMode ? 'rgb(210,212,215)' : 'rgb(14,27,41)', // Same values as --color-app-text-primary. - }, - }, - }} - /> -

- {showError && (error?.message || errorState)} -

-
-
- - -
-
-
- ) -} - -export default CreditCardForm diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx deleted file mode 100644 index 97433deb8c..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types' -import { useState } from 'react' - -import { subscriptionDetailType } from 'services/account' -import A from 'ui/A' -import Button from 'ui/Button' -import Icon from 'ui/Icon' - -import CardInformation from './CardInformation' -import CreditCardForm from './CreditCardForm' -function PaymentCard({ subscriptionDetail, provider, owner }) { - const [isFormOpen, setIsFormOpen] = useState(false) - const card = subscriptionDetail?.defaultPaymentMethod?.card - - return ( -
-
-

Payment method

- {!isFormOpen && ( - setIsFormOpen(true)} - hook="edit-card" - > - Edit - - )} -
- {isFormOpen ? ( - setIsFormOpen(false)} - /> - ) : card ? ( - - ) : ( -
-

- No credit card set. Please contact support if you think it’s an - error or set it yourself. -

-
- -
-
- )} -
- ) -} - -PaymentCard.propTypes = { - subscriptionDetail: PropTypes.oneOf([subscriptionDetailType, null]), - provider: PropTypes.string.isRequired, - owner: PropTypes.string.isRequired, -} - -export default PaymentCard diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.jsx deleted file mode 100644 index b8021a7cb6..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/PaymentCard.test.jsx +++ /dev/null @@ -1,336 +0,0 @@ -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' - -import { ThemeContextProvider } from 'shared/ThemeContext' -import { Plans } from 'shared/utils/billing' - -import PaymentCard from './PaymentCard' - -const mocks = vi.hoisted(() => ({ - useUpdateCard: vi.fn(), -})) - -vi.mock('services/account', async () => { - const actual = await vi.importActual('services/account') - return { - ...actual, - useUpdateCard: mocks.useUpdateCard, - } -}) - -const subscriptionDetail = { - defaultPaymentMethod: { - card: { - brand: 'visa', - expMonth: 12, - expYear: 2021, - last4: '1234', - }, - }, - plan: { - value: Plans.USERS_PR_INAPPY, - }, - currentPeriodEnd: 1606851492, - cancelAtPeriodEnd: false, -} - -const wrapper = ({ children }) => ( - {children} -) - -// mocking all the stripe components; and trusting the library :) -vi.mock('@stripe/react-stripe-js', () => { - function makeFakeComponent(name) { - return function Component() { - return name - } - } - return { - useElements: () => ({ - getElement: vi.fn(), - }), - useStripe: () => ({}), - CardElement: makeFakeComponent('CardElement'), - } -}) - -describe('PaymentCard', () => { - 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 credit card set. Please contact support if you think it’s an error or set it yourself./ - ) - ).toBeInTheDocument() - }) - }) - - describe(`when the user doesn't have any card`, () => { - it('renders an error message', () => { - render( - , - { wrapper } - ) - - expect( - screen.getByText( - /No credit card 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`, async () => { - const { user } = setup() - render( - , - { wrapper } - ) - - mocks.useUpdateCard.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect(screen.queryByText(/Visa/)).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - render( - , - { wrapper } - ) - - mocks.useUpdateCard.mockReturnValue({ - mutate: () => null, - isLoading: false, - }) - await user.click(screen.getByTestId('open-modal')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - }) - }) - - 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() - }) - }) - - describe('when the user clicks on Edit card', () => { - it(`doesn't render the card anymore`, async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect(screen.queryByText(/Visa/)).not.toBeInTheDocument() - }) - - it('renders the form', async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect( - screen.getByRole('button', { name: /update/i }) - ).toBeInTheDocument() - }) - - describe('when submitting', () => { - it('calls the service to update the card', async () => { - const { user } = setup() - const updateCard = vi.fn() - mocks.useUpdateCard.mockReturnValue({ - mutate: updateCard, - isLoading: false, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - await user.click(screen.queryByRole('button', { name: /update/i })) - - expect(updateCard).toHaveBeenCalled() - }) - }) - - describe('when the user clicks on cancel', () => { - it(`doesn't render the form anymore`, async () => { - const { user } = setup() - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - isLoading: false, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-card')) - await user.click(screen.getByRole('button', { name: /Cancel/ })) - - expect( - screen.queryByRole('button', { name: /save/i }) - ).not.toBeInTheDocument() - }) - }) - }) - - describe('when there is an error in the form', () => { - it('renders the error', async () => { - const { user } = setup() - const randomError = 'not rich enough' - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - error: { message: randomError }, - }) - render( - , - { wrapper } - ) - - await user.click(screen.getByTestId('edit-card')) - - expect(screen.getByText(randomError)).toBeInTheDocument() - }) - }) - - describe('when the form is loading', () => { - it('has the error and save button disabled', async () => { - const { user } = setup() - mocks.useUpdateCard.mockReturnValue({ - mutate: vi.fn(), - isLoading: true, - }) - render( - , - { wrapper } - ) - await user.click(screen.getByTestId('edit-card')) - - expect(screen.queryByRole('button', { name: /update/i })).toBeDisabled() - expect(screen.queryByRole('button', { name: /cancel/i })).toBeDisabled() - }) - }) -}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts deleted file mode 100644 index 9102b8ff5f..0000000000 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './PaymentCard' diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.test.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.test.tsx new file mode 100644 index 0000000000..0685b07a0e --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.test.tsx @@ -0,0 +1,215 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { ThemeContextProvider } from 'shared/ThemeContext' + +import Address from './Address' + +const mocks = vi.hoisted(() => ({ + useUpdateBillingAddress: vi.fn(), +})) + +vi.mock('services/account/useUpdateBillingAddress', async () => { + const actual = await import('services/account/useUpdateBillingAddress') + return { + ...actual, + useUpdateBillingAddress: mocks.useUpdateBillingAddress, + } +}) + +const subscriptionDetail = { + defaultPaymentMethod: { + card: { + brand: 'visa', + expMonth: 12, + expYear: 2021, + last4: '1234', + }, + billingDetails: { + name: 'Bob Smith', + address: { + line1: '123 Sesame St.', + line2: 'Apt A', + city: 'San Francisco', + country: 'US', + state: 'CA', + postalCode: '12345', + }, + }, + }, + currentPeriodEnd: 1606851492, + cancelAtPeriodEnd: false, +} as z.infer + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +// mocking stripe components +vi.mock('@stripe/react-stripe-js', () => { + function makeFakeComponent(name: string) { + // mocking onReady to be called after a bit of time + return function Component({ _onReady }: { _onReady?: any }) { + return name + } + } + + return { + useElements: () => ({ + getElement: vi.fn().mockReturnValue({ + getValue: vi.fn().mockResolvedValue({ + complete: true, + value: { + address: {}, + }, + }), + }), + update: vi.fn(), + }), + useStripe: () => ({}), + AddressElement: makeFakeComponent('AddressElement'), + } +}) + +describe('AddressCard', () => { + 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( +
{}} + />, + { wrapper } + ) + + expect( + screen.getByText( + /No address has been set. Please contact support if you think it's an error or set it yourself./ + ) + ).toBeInTheDocument() + }) + }) + + describe(`when the user doesn't have billing details`, () => { + it('renders an error message', () => { + render( +
{}} + />, + { wrapper } + ) + + expect( + screen.getByText( + /No address has been set. Please contact support if you think it's an error or set it yourself./ + ) + ).toBeInTheDocument() + }) + + describe('when the user clicks on "Set Address"', () => { + it(`doesn't render address info stuff anymore`, async () => { + const { user } = setup() + render( +
{}} + />, + { wrapper } + ) + + mocks.useUpdateBillingAddress.mockReturnValue({ + mutate: () => null, + isLoading: false, + }) + await user.click(screen.getByTestId('open-modal')) + + expect( + screen.queryByText(/123 Sesame St. Apt A/) + ).not.toBeInTheDocument() + }) + }) + + describe('when the user has an address', () => { + it('renders the address information', () => { + render( +
{}} + />, + { wrapper } + ) + + expect(screen.getByText(/Billing address/)).toBeInTheDocument() + expect(screen.getByText(/123 Sesame St. Apt A/)).toBeInTheDocument() + expect(screen.getByText(/San Francisco, CA 12345/)).toBeInTheDocument() + }) + + it('can render partial information too', () => { + render( +
{}} + />, + { wrapper } + ) + + expect(screen.getByText('Billing address')).toBeInTheDocument() + expect(screen.queryByText(/null/)).not.toBeInTheDocument() + expect(screen.getByText('12345')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.tsx new file mode 100644 index 0000000000..d503a0b4c4 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/Address/Address.tsx @@ -0,0 +1,85 @@ +import { z } from 'zod' + +import { SubscriptionDetailSchema } from 'services/account' +import { cn } from 'shared/utils/cn' +import Button from 'ui/Button' + +import { SECONDARY_PAYMENT_FEATURE_ENABLED } from '../../BillingDetails' + +interface AddressProps { + setEditMode: (isEditMode: boolean) => void + subscriptionDetail: z.infer + provider: string + owner: string + className?: string +} + +function Address({ setEditMode, subscriptionDetail, className }: AddressProps) { + const billingDetails = + subscriptionDetail?.defaultPaymentMethod?.billingDetails + + // TODO: Implement this when we have secondary payment method feature + const isAddressSameAsPrimary = SECONDARY_PAYMENT_FEATURE_ENABLED + ? true + : undefined + + const isEmptyAddress = + !billingDetails?.address?.line1 && + !billingDetails?.address?.line2 && + !billingDetails?.address?.city && + !billingDetails?.address?.state && + !billingDetails?.address?.postalCode + + if (!billingDetails) { + return ( +
+
+

+ No address has been set. Please contact support if you think + it's an error or set it yourself. +

+
+ +
+
+
+ ) + } + + return ( +
+
+

Billing address

+ {isAddressSameAsPrimary ? ( +

Same as primary address

+ ) : isEmptyAddress ? ( +

-

+ ) : ( + <> +

{`${billingDetails.address?.line1 ?? ''} ${ + billingDetails.address?.line2 ?? '' + }`}

+

+ {billingDetails.address?.city + ? `${billingDetails.address?.city}, ` + : ''} + {`${billingDetails.address?.state ?? ''} ${ + billingDetails.address?.postalCode ?? '' + }`} +

+ + )} +
+
+ ) +} + +export default Address diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx new file mode 100644 index 0000000000..f73afae9f8 --- /dev/null +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/BankInformation.tsx @@ -0,0 +1,26 @@ +import { z } from 'zod' + +import bankLogo from 'assets/billing/bank.svg' +import { SubscriptionDetailSchema } from 'services/account' + +interface BankInformationProps { + subscriptionDetail: z.infer +} +function BankInformation({ subscriptionDetail }: BankInformationProps) { + return ( +
+
+ bank logo +
+ + {subscriptionDetail?.defaultPaymentMethod?.usBankAccount?.bankName} +  ••••  + {subscriptionDetail?.defaultPaymentMethod?.usBankAccount?.last4} + +
+
+
+ ) +} + +export default BankInformation diff --git a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx similarity index 61% rename from src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx rename to src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx index b1830d0174..9193fdc345 100644 --- a/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/PaymentCard/CardInformation.jsx +++ b/src/pages/PlanPage/subRoutes/CurrentOrgPlan/BillingDetails/ViewPaymentMethod/PaymentMethod/CardInformation.tsx @@ -1,16 +1,16 @@ -import PropTypes from 'prop-types' +import { z } from 'zod' import amexLogo from 'assets/billing/amex.svg' import discoverLogo from 'assets/billing/discover.svg' import mastercardLogo from 'assets/billing/mastercard.svg' import visaLogo from 'assets/billing/visa.svg' -import { subscriptionDetailType } from 'services/account' +import { SubscriptionDetailSchema } from 'services/account' import { formatTimestampToCalendarDate, lastTwoDigits, } from 'shared/utils/billing' -const cardBrand = { +const cardBrands = { amex: { logo: amexLogo, name: 'American Express', @@ -33,33 +33,39 @@ const cardBrand = { }, } -function CardInformation({ subscriptionDetail, card }) { - const typeCard = cardBrand[card?.brand] ?? cardBrand?.fallback +type CardBrand = keyof typeof cardBrands + +interface CardInformationProps { + subscriptionDetail: z.infer +} +function CardInformation({ subscriptionDetail }: CardInformationProps) { + const card = subscriptionDetail?.defaultPaymentMethod?.card + const typeCard = cardBrands[card?.brand as CardBrand] ?? cardBrands.fallback let nextBilling = null if (!subscriptionDetail?.cancelAtPeriodEnd) { nextBilling = formatTimestampToCalendarDate( - subscriptionDetail.currentPeriodEnd + subscriptionDetail?.currentPeriodEnd ) } return (
-
+
credit card logo
- •••• {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. +

+
+ +
+
+ )} +
+ ) +} + +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 ? ( + + ) : 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 = (