diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.spec.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.spec.tsx deleted file mode 100644 index a1f80e21597..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as organizationsDomain from '@qovery/domains/organizations/feature' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import BillingDetailsFeature from './billing-details-feature' - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ organizationId: '1' }), -})) - -const useBillingInfoMockSpy = jest.spyOn(organizationsDomain, 'useBillingInfo') as jest.Mock -const useEditBillingInfoMockSpy = jest.spyOn(organizationsDomain, 'useEditBillingInfo') as jest.Mock - -describe('BillingDetailsFeature', () => { - beforeEach(() => { - useBillingInfoMockSpy.mockReturnValue({ - data: { - city: 'city', - company: 'company', - address: 'address', - state: '', - zip: 'zip', - email: 'email', - first_name: 'first_name', - vat_number: 'vat_number', - last_name: 'last_name', - country_code: 'FR', - }, - }) - useEditBillingInfoMockSpy.mockReturnValue({ - mutateAsync: jest.fn(), - }) - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should fetch the billing info', () => { - renderWithProviders() - expect(useBillingInfoMockSpy).toHaveBeenCalled() - }) - - it('should dispatch the editBillingInfo', async () => { - const { userEvent } = renderWithProviders() - - const input = screen.getByLabelText('First name') - await userEvent.clear(input) - await userEvent.type(input, 'test') - - const button = screen.getByTestId('submit-button') - - expect(button).toBeEnabled() - await userEvent.click(button) - - expect(useEditBillingInfoMockSpy().mutateAsync).toHaveBeenCalledWith({ - organizationId: '1', - billingInfoRequest: { - city: 'city', - company: 'company', - address: 'address', - state: '', - zip: 'zip', - email: 'email', - first_name: 'test', - vat_number: 'vat_number', - last_name: 'last_name', - country_code: 'FR', - }, - }) - }) -}) diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.tsx deleted file mode 100644 index d278e8be709..00000000000 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/billing-details-feature/billing-details-feature.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { type BillingInfoRequest } from 'qovery-typescript-axios' -import { useEffect, useState } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { useParams } from 'react-router-dom' -import { useBillingInfo, useEditBillingInfo } from '@qovery/domains/organizations/feature' -import { countries } from '@qovery/shared/enums' -import { type Value } from '@qovery/shared/interfaces' -import { IconFlag } from '@qovery/shared/ui' -import BillingDetails from '../../../ui/page-organization-billing/billing-details/billing-details' - -export function BillingDetailsFeature() { - const { organizationId = '' } = useParams() - const [editInProcess, setEditInProcess] = useState(false) - const [countryValues, setCountryValues] = useState([]) - const { data: billingInfo, isLoading: isLoadingBillingInfo } = useBillingInfo({ organizationId }) - const { mutateAsync: editBillingInfo } = useEditBillingInfo() - const methods = useForm({ - mode: 'onChange', - defaultValues: { - city: '', - address: '', - state: '', - company: '', - zip: '', - email: '', - first_name: '', - last_name: '', - vat_number: '', - country_code: '', - }, - }) - - const onSubmit = methods.handleSubmit(async (data) => { - if (organizationId) { - setEditInProcess(true) - - try { - const response = await editBillingInfo({ - organizationId, - billingInfoRequest: data, - }) - methods.reset(response as BillingInfoRequest) - } catch (error) { - console.error(error) - } - - setEditInProcess(false) - } - }) - - useEffect(() => { - setCountryValues( - countries.map((country) => ({ label: country.name, value: country.code, icon: })) - ) - }, [setCountryValues]) - - useEffect(() => { - methods.reset(billingInfo as BillingInfoRequest) - }, [billingInfo, methods]) - - return ( - - - - ) -} - -export default BillingDetailsFeature diff --git a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/page-organization-billing-feature.tsx b/libs/pages/settings/src/lib/feature/page-organization-billing-feature/page-organization-billing-feature.tsx index 6cd6484f488..1ee3aefa089 100644 --- a/libs/pages/settings/src/lib/feature/page-organization-billing-feature/page-organization-billing-feature.tsx +++ b/libs/pages/settings/src/lib/feature/page-organization-billing-feature/page-organization-billing-feature.tsx @@ -1,30 +1,117 @@ -import { type CreditCard } from 'qovery-typescript-axios' +import { type default as FieldContainer } from '@chargebee/chargebee-js-react-wrapper/dist/components/FieldContainer' +import { type default as CbInstance } from '@chargebee/chargebee-js-types/cb-types/models/cb-instance' +import { type BillingInfoRequest, type CreditCard } from 'qovery-typescript-axios' +import { useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { useParams } from 'react-router-dom' -import { useCreditCards, useDeleteCreditCard } from '@qovery/domains/organizations/feature' -import { AddCreditCardModalFeature, type CreditCardFormValues } from '@qovery/shared/console-shared' -import { useModal, useModalConfirmation } from '@qovery/shared/ui' +import { + useAddCreditCard, + useBillingInfo, + useCreditCards, + useDeleteCreditCard, + useEditBillingInfo, +} from '@qovery/domains/organizations/feature' +import { countries } from '@qovery/shared/enums' +import { type Value } from '@qovery/shared/interfaces' +import { IconFlag, toastError, useModalConfirmation } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' +import { loadChargebee } from '@qovery/shared/util-payment' +import { type SerializedError } from '@qovery/shared/utils' import PageOrganizationBilling from '../../ui/page-organization-billing/page-organization-billing' export function PageOrganizationBillingFeature() { useDocumentTitle('Billing details - Organization settings') const { organizationId = '' } = useParams() - const { openModal } = useModal() const { openModalConfirmation } = useModalConfirmation() const { data: creditCards = [], isLoading: isLoadingCreditCards } = useCreditCards({ organizationId }) const { mutateAsync: deleteCreditCard } = useDeleteCreditCard() + const { data: billingInfo, isLoading: isLoadingBillingInfo } = useBillingInfo({ organizationId }) + const { mutateAsync: editBillingInfo } = useEditBillingInfo() + const { mutateAsync: addCreditCard } = useAddCreditCard() - const methods = useForm({ + const [showAddCard, setShowAddCard] = useState(false) + const [editInProcess, setEditInProcess] = useState(false) + const [cbInstance, setCbInstance] = useState(null) + const [isCardReady, setIsCardReady] = useState(false) + const [editingCardId, setEditingCardId] = useState(null) + const cardRef = useRef(null) + + const countryValues = countries.map((country) => ({ + label: country.name, + value: country.code, + icon: , + })) + + const methods = useForm({ mode: 'onChange', + values: billingInfo as BillingInfoRequest, }) - const openNewCreditCardModal = () => { - openModal({ - content: , - }) + const handleAddCard = async (cardId?: string) => { + setShowAddCard(true) + setEditingCardId(cardId || null) + + try { + const instance = await loadChargebee() + setCbInstance(instance) + } catch (error) { + return + } } + const handleCancelAddCard = () => { + setShowAddCard(false) + setIsCardReady(false) + setCbInstance(null) + setEditingCardId(null) + } + + const onSubmit = methods.handleSubmit(async (data) => { + if (!organizationId) return + + setEditInProcess(true) + + try { + const response = await editBillingInfo({ + organizationId, + billingInfoRequest: data, + }) + methods.reset(response as BillingInfoRequest) + + if (showAddCard && isCardReady && cardRef.current) { + const tokenData = await cardRef.current.tokenize({}) + + if (!tokenData.token) { + throw new Error('No token returned from Chargebee') + } + + await addCreditCard({ + organizationId, + creditCardRequest: { + token: tokenData.token, + cvv: '', + number: `****${tokenData.card?.last4 || ''}`, + expiry_year: tokenData.card?.expiry_year || 0, + expiry_month: tokenData.card?.expiry_month || 0, + }, + }) + + if (editingCardId) { + await deleteCreditCard({ organizationId, creditCardId: editingCardId }) + } + + setShowAddCard(false) + setIsCardReady(false) + setCbInstance(null) + setEditingCardId(null) + } + } catch (error) { + toastError(error as unknown as SerializedError) + } finally { + setEditInProcess(false) + } + }) + const onDeleteCreditCard = (creditCard: CreditCard) => { openModalConfirmation({ title: 'Delete credit card', @@ -40,9 +127,18 @@ export function PageOrganizationBillingFeature() { setIsCardReady(true)} + countryValues={countryValues} + loadingBillingInfos={isLoadingBillingInfo} + editInProcess={editInProcess} + onSubmit={onSubmit} /> ) diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing/billing-details/billing-details.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing/billing-details/billing-details.tsx index e6fa7c49f70..da224de8807 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-billing/billing-details/billing-details.tsx +++ b/libs/pages/settings/src/lib/ui/page-organization-billing/billing-details/billing-details.tsx @@ -2,10 +2,10 @@ import { type BillingInfoRequest } from 'qovery-typescript-axios' import { type FormEventHandler } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type Value } from '@qovery/shared/interfaces' -import { BlockContent, Button, InputSelect, InputText, LoaderSpinner } from '@qovery/shared/ui' +import { Button, InputSelect, InputText, LoaderSpinner } from '@qovery/shared/ui' export interface BillingDetailsProps { - onSubmit: FormEventHandler + onSubmit?: FormEventHandler loadingBillingInfos?: boolean editInProcess?: boolean countryValues?: Value[] @@ -15,25 +15,27 @@ export function BillingDetails(props: BillingDetailsProps) { const { control, formState } = useFormContext() return ( - + <> +

Billing information

{props.loadingBillingInfos ? (
) : ( <> -
+
( )} /> @@ -43,22 +45,23 @@ export function BillingDetails(props: BillingDetailsProps) { rules={{ required: 'Please provide a last name' }} render={({ field }) => ( )} />
-
+
( ( )} /> @@ -86,51 +91,58 @@ export function BillingDetails(props: BillingDetailsProps) { rules={{ required: 'Please provide a billing email' }} render={({ field }) => ( )} /> ( )} /> -
+
( )} /> ( )} /> @@ -139,14 +151,16 @@ export function BillingDetails(props: BillingDetailsProps) { ( )} /> @@ -169,7 +183,6 @@ export function BillingDetails(props: BillingDetailsProps) { data-testid="submit-button" type="submit" size="lg" - disabled={!formState.isValid} loading={props.editInProcess} onClick={props.onSubmit as () => void} > @@ -178,7 +191,7 @@ export function BillingDetails(props: BillingDetailsProps) {
)} - + ) } diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.spec.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.spec.tsx index 966c9b6f7e3..e65ad306a92 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.spec.tsx +++ b/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.spec.tsx @@ -1,35 +1,86 @@ import { getAllByTestId, getByTestId, render } from '__tests__/utils/setup-jest' +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { type BillingInfoRequest } from 'qovery-typescript-axios' import { creditCardsFactoryMock } from '@qovery/shared/factories' import PageOrganizationBilling, { type PageOrganizationBillingProps } from './page-organization-billing' -const mockOpenNewCreditCardModal = jest.fn() +const mockOnAddCard = jest.fn() const mockDeleteCard = jest.fn() +const mockOnCancelAddCard = jest.fn() +const mockOnSubmit = jest.fn() + +const defaultBillingValues: BillingInfoRequest = { + first_name: 'John', + last_name: 'Doe', + company: 'Qovery', + address: '1 rue de la paix', + city: 'Paris', + state: 'Ile de France', + zip: '75000', + country_code: 'FR', + vat_number: 'FR123456789', + email: 'test@qovery.com', +} + const props: PageOrganizationBillingProps = { creditCards: creditCardsFactoryMock(3), - openNewCreditCardModal: mockOpenNewCreditCardModal, + onAddCard: mockOnAddCard, onDeleteCard: mockDeleteCard, creditCardLoading: false, + showAddCard: false, + onCancelAddCard: mockOnCancelAddCard, + cbInstance: null, + countryValues: [ + { label: 'France', value: 'FR' }, + { label: 'United States', value: 'US' }, + ], + loadingBillingInfos: false, + editInProcess: false, + onSubmit: mockOnSubmit, } describe('PageOrganizationBilling', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + it('should render successfully', () => { - const { baseElement } = render() + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) expect(baseElement).toBeTruthy() }) it('should have 3 credit card rows', () => { - const { baseElement } = render() + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) expect(getAllByTestId(baseElement, 'credit-card-row')).toHaveLength(3) }) - it('should display 2 spinners if loading and no card in the store', () => { - const { baseElement } = render() - // 1 for the for the credit card and one for the billing form - expect(getAllByTestId(baseElement, 'spinner')).toHaveLength(2) + it('should display 1 spinner if loading and no card in the store', () => { + const { baseElement } = render( + wrapWithReactHookForm( + , + { + defaultValues: defaultBillingValues, + } + ) + ) + // 1 for the credit card section + expect(getAllByTestId(baseElement, 'spinner')).toHaveLength(1) }) - it('should call deleteCard methods', () => { - const { baseElement } = render() + it('should call deleteCard method', () => { + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) const deleteButton = getAllByTestId(baseElement, 'delete-credit-card')[0] deleteButton.click() @@ -37,12 +88,51 @@ describe('PageOrganizationBilling', () => { expect(mockDeleteCard).toHaveBeenCalledWith(props.creditCards[0]) }) - it('should open the creation modal', () => { - const { baseElement } = render() + it('should call onAddCard when clicking add new card button', () => { + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) const addButton = getByTestId(baseElement, 'add-new-card-button') addButton.click() - expect(mockOpenNewCreditCardModal).toHaveBeenCalled() + expect(mockOnAddCard).toHaveBeenCalledWith() + }) + + it('should call onAddCard with card id when clicking edit button', () => { + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) + const editButton = getAllByTestId(baseElement, 'edit-credit-card')[0] + + editButton.click() + + expect(mockOnAddCard).toHaveBeenCalledWith(props.creditCards[0].id) + }) + + it('should not display add new card button when cards exist', () => { + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) + const addButton = baseElement.querySelector('[data-testid="add-new-card-button"]') + + expect(addButton).not.toBeInTheDocument() + }) + + it('should display empty state when no cards and not showing add card form', () => { + const { baseElement } = render( + wrapWithReactHookForm(, { + defaultValues: defaultBillingValues, + }) + ) + const emptyState = getByTestId(baseElement, 'placeholder-credit-card') + + expect(emptyState).toBeInTheDocument() }) }) diff --git a/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.tsx b/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.tsx index e015e0028b9..c794a25285a 100644 --- a/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.tsx +++ b/libs/pages/settings/src/lib/ui/page-organization-billing/page-organization-billing.tsx @@ -1,4 +1,9 @@ +import { CardCVV, CardComponent, CardExpiry, CardNumber, Provider } from '@chargebee/chargebee-js-react-wrapper' +import { type default as FieldContainer } from '@chargebee/chargebee-js-react-wrapper/dist/components/FieldContainer' +import { type default as CbInstance } from '@chargebee/chargebee-js-types/cb-types/models/cb-instance' import { type CreditCard } from 'qovery-typescript-axios' +import { type FormEventHandler, type RefObject } from 'react' +import { type Value } from '@qovery/shared/interfaces' import { BlockContent, Button, @@ -9,82 +14,169 @@ import { LoaderSpinner, Section, } from '@qovery/shared/ui' -import BillingDetailsFeature from '../../feature/page-organization-billing-feature/billing-details-feature/billing-details-feature' +import { fieldStyles } from '@qovery/shared/util-payment' +import BillingDetails from './billing-details/billing-details' export interface PageOrganizationBillingProps { creditCards: CreditCard[] - openNewCreditCardModal: () => void + onAddCard: (cardId?: string) => void onDeleteCard: (creditCard: CreditCard) => void creditCardLoading?: boolean + showAddCard?: boolean + onCancelAddCard?: () => void + cbInstance?: CbInstance | null + cardRef?: RefObject + onCardReady?: () => void + countryValues?: Value[] + loadingBillingInfos?: boolean + editInProcess?: boolean + onSubmit?: FormEventHandler } export function PageOrganizationBilling(props: PageOrganizationBillingProps) { return (
-
- Payment method - -
+ Payment method - - {props.creditCardLoading && props.creditCards.length === 0 ? ( -
- + +
+
+

Credit card

- ) : props.creditCards.length > 0 ? ( -
-

- If you want to modify the card, delete the existing one and add a new. -

- {props.creditCards.map((creditCard) => ( -
- - - -
- ))} -
- ) : ( -
- -

- No credit cards found.
Please add one. -

-
- )} - - + {props.creditCardLoading && props.creditCards.length === 0 && !props.showAddCard ? ( +
+ +
+ ) : ( + <> + {props.creditCards.length > 0 && !props.showAddCard && ( +
+ {props.creditCards.map((creditCard) => ( +
+ + + + +
+ ))} +
+ )} + + {props.showAddCard && ( + <> +
+

Add credit card

+ +
+ + {!props.cbInstance ? ( +
+ +
+ ) : ( + + +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ )} + + )} + + {!props.showAddCard && props.creditCards.length === 0 && ( +
+ +

+ No credit card found.
Please add one. +

+
+ +
+
+ )} + + )} +
+ +
+ + +
)