diff --git a/cypress/e2e/10-resources/t100-payment-terms.cy.ts b/cypress/e2e/10-resources/t100-payment-terms.cy.ts new file mode 100644 index 0000000000..3ffecd1700 --- /dev/null +++ b/cypress/e2e/10-resources/t100-payment-terms.cy.ts @@ -0,0 +1,119 @@ +import { CENTRALIZED_DIALOG_CONFIRM_BUTTON_TEST_ID } from '~/components/dialogs/const' +import { + EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID, + PAYMENT_TERM_ADD_BUTTON_TEST_ID, + PAYMENT_TERM_DELETE_BUTTON_TEST_ID, + PAYMENT_TERM_EDIT_BUTTON_TEST_ID, + PAYMENT_TERM_SETTINGS_ROW_TEST_ID, +} from '~/components/paymentTerms/dataTestConstants' +import { PaymentTermTypeEnum } from '~/generated/graphql' + +import { customerName } from '../../support/reusableConstants' + +type TermFields = { days?: string; dayOfMonth?: string; monthOffset?: string } + +/** + * One row per term type. A new term option is a new row here, never a new spec: the + * billing-entity and customer flows below are both driven off this table. + */ +const TERM_CASES: Array<{ termType: PaymentTermTypeEnum; fields: TermFields; label: string }> = [ + { termType: PaymentTermTypeEnum.DueOnReceipt, fields: {}, label: 'Due on receipt' }, + { termType: PaymentTermTypeEnum.Net, fields: { days: '30' }, label: 'Net 30 days' }, + { termType: PaymentTermTypeEnum.EndOfMonth, fields: {}, label: 'End of month' }, + { + termType: PaymentTermTypeEnum.NetEndOfMonth, + fields: { days: '30' }, + label: '30 net days after end of month', + }, + { + termType: PaymentTermTypeEnum.DaysEndOfMonth, + fields: { days: '45' }, + label: '45 days end of month', + }, + { + termType: PaymentTermTypeEnum.DayOfMonth, + fields: { dayOfMonth: '15', monthOffset: '1' }, + label: '15 MFI, 1 month offset', + }, +] + +const CUSTOMER_TERM_CASE = TERM_CASES[1] + +const fillTermForm = ({ + termType, + fields, +}: { + termType: PaymentTermTypeEnum + fields: TermFields +}) => { + // The dialog opens its term-type combo box on entry, so the options are already listed. + // Each option row carries the term type as its own `data-test`. + cy.get('[data-test="form-dialog"]').should('exist') + cy.get(`[data-test="${termType}"]`).click() + + Object.entries(fields).forEach(([name, value]) => { + cy.get(`input[name="${name}"]`).clear().type(value) + }) + + cy.get(`[data-test="${EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID}"]`).click() + cy.get('[data-test="form-dialog"]').should('not.exist') +} + +const paymentTermRow = () => cy.get(`[data-test="${PAYMENT_TERM_SETTINGS_ROW_TEST_ID}"]`) + +describe('Payment terms', () => { + beforeEach(() => { + cy.login() + }) + + describe('billing entity', () => { + const visitInvoiceSettings = () => { + // `/settings` redirects to the default billing entity once its query resolves. + cy.visitApp('/settings') + cy.url().should('include', '/billing-entity/') + + cy.url().then((url) => { + const billingEntityCode = url.match(/billing-entity\/([^/]+)/)?.[1] as string + + cy.visitApp(`/settings/billing-entity/${billingEntityCode}/invoice-settings`) + cy.url().should('match', /\/settings\/billing-entity\/[^/]+\/invoice-settings$/) + }) + } + + TERM_CASES.forEach(({ termType, fields, label }) => { + it(`should set a ${termType} term and show it on the row`, () => { + visitInvoiceSettings() + + paymentTermRow().find(`[data-test="${PAYMENT_TERM_EDIT_BUTTON_TEST_ID}"]`).click() + fillTermForm({ termType, fields }) + + paymentTermRow().should('contain', label) + }) + }) + }) + + describe('customer', () => { + const visitCustomerSettings = () => { + cy.visitApp('/customers') + cy.contains(customerName).click() + cy.url().should('include', '/customer/') + cy.get('button[role="tab"]').contains('Settings').click() + } + + it('should override the billing entity term, then delete it to inherit again', () => { + visitCustomerSettings() + + paymentTermRow().find(`[data-test="${PAYMENT_TERM_ADD_BUTTON_TEST_ID}"]`).click() + fillTermForm(CUSTOMER_TERM_CASE) + + paymentTermRow() + .should('contain', CUSTOMER_TERM_CASE.label) + .and('not.contain', 'inherit from billing entity') + + paymentTermRow().find(`[data-test="${PAYMENT_TERM_DELETE_BUTTON_TEST_ID}"]`).click() + cy.get(`[data-test="${CENTRALIZED_DIALOG_CONFIRM_BUTTON_TEST_ID}"]`).click() + + paymentTermRow().should('contain', 'inherit from billing entity') + }) + }) +}) diff --git a/src/components/customers/CustomerSettings.tsx b/src/components/customers/CustomerSettings.tsx index dccdeb16a5..bfc1bae8d1 100644 --- a/src/components/customers/CustomerSettings.tsx +++ b/src/components/customers/CustomerSettings.tsx @@ -5,7 +5,7 @@ import { useMemo } from 'react' import { useDeleteCustomerDocumentLocaleDialog } from '~/components/customers/DeleteCustomerDocumentLocaleDialog' import { useDeleteCustomerFinalizeZeroAmountInvoiceDialog } from '~/components/customers/DeleteCustomerFinalizeZeroAmountInvoiceDialog' import { useDeleteCustomerGracePeriodeDialog } from '~/components/customers/DeleteCustomerGracePeriodeDialog' -import { useDeleteCustomerNetPaymentTermDialog } from '~/components/customers/DeleteCustomerNetPaymentTermDialog' +import { useDeleteCustomerPaymentTermDialog } from '~/components/customers/DeleteCustomerPaymentTermDialog' import { useDeleteCustomerVatRateDialog } from '~/components/customers/DeleteCustomerVatRateDialog' import { useEditCustomerDocumentLocaleDialog } from '~/components/customers/EditCustomerDocumentLocaleDialog' import { useEditCustomerDunningCampaignDialog } from '~/components/customers/EditCustomerDunningCampaignDialog' @@ -29,8 +29,14 @@ import { SettingsListWrapper, SettingsPaddedContainer, } from '~/components/layouts/Settings' -import { useEditFinalizeZeroAmountInvoiceDialog } from '~/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog' -import { useEditNetPaymentTermDialog } from '~/components/settings/invoices/EditNetPaymentTermDialog' +import { + PAYMENT_TERM_ADD_BUTTON_TEST_ID, + PAYMENT_TERM_DELETE_BUTTON_TEST_ID, + PAYMENT_TERM_EDIT_BUTTON_TEST_ID, + PAYMENT_TERM_SETTINGS_ROW_TEST_ID, +} from '~/components/paymentTerms/dataTestConstants' +import { useEditFinalizeZeroAmountInvoiceDialog } from '~/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/EditFinalizeZeroAmountInvoiceDialog' +import { useEditPaymentTermDialog } from '~/components/settings/invoices/EditPaymentTermDialog/EditPaymentTermDialog' import { INVOICE_ISSUING_DATE_ADJUSTMENT_SETTING_KEYS, INVOICE_ISSUING_DATE_ANCHOR_SETTING_KEYS, @@ -45,12 +51,13 @@ import { CustomerSubscriptionInvoiceIssuingDateAnchorEnum, DeleteCustomerDocumentLocaleFragmentDoc, DeleteCustomerGracePeriodFragmentDoc, - DeleteCustomerNetPaymentTermFragmentDoc, + DeleteCustomerPaymentTermFragmentDoc, EditCustomerDocumentLocaleFragmentDoc, EditCustomerDunningCampaignFragmentDoc, EditCustomerInvoiceCustomSectionFragmentDoc, EditCustomerInvoiceGracePeriodFragmentDoc, EditCustomerIssuingDatePolicyDialogFragmentDoc, + EditCustomerPaymentTermForDialogFragmentDoc, EditCustomerVatRateFragmentDoc, FinalizeZeroAmountInvoiceEnum, PremiumIntegrationTypeEnum, @@ -59,6 +66,7 @@ import { import { useInternationalization } from '~/hooks/core/useInternationalization' import { useCurrentUser } from '~/hooks/useCurrentUser' import { useOrganizationInfos } from '~/hooks/useOrganizationInfos' +import { usePaymentTerm } from '~/hooks/usePaymentTerm' import { usePermissions } from '~/hooks/usePermissions' import ErrorImage from '~/public/images/maneki/error.svg' import { MenuPopper } from '~/styles' @@ -103,12 +111,10 @@ gql` customer(id: $id) { id invoiceGracePeriod - netPaymentTerm finalizeZeroAmountInvoice billingEntity { id - netPaymentTerm finalizeZeroAmountInvoice billingConfiguration { id @@ -146,7 +152,8 @@ gql` ...DeleteCustomerGracePeriod ...DeleteCustomerDocumentLocale ...CustomerForDeleteVatRateDialog - ...DeleteCustomerNetPaymentTerm + ...DeleteCustomerPaymentTerm + ...EditCustomerPaymentTermForDialog ...EditCustomerIssuingDatePolicyDialog } } @@ -159,7 +166,8 @@ gql` ${DeleteCustomerGracePeriodFragmentDoc} ${DeleteCustomerDocumentLocaleFragmentDoc} ${CustomerForDeleteVatRateDialogFragmentDoc} - ${DeleteCustomerNetPaymentTermFragmentDoc} + ${DeleteCustomerPaymentTermFragmentDoc} + ${EditCustomerPaymentTermForDialogFragmentDoc} ${EditCustomerIssuingDatePolicyDialogFragmentDoc} ` @@ -189,9 +197,9 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { useEditCustomerInvoiceCustomSectionsDialog(customerId) const { openDeleteCustomerDocumentLocaleDialog } = useDeleteCustomerDocumentLocaleDialog() const { open: openPremiumWarningDialog } = usePremiumWarningDialog() - const { openEditNetPaymentTermDialog } = useEditNetPaymentTermDialog() - const netPaymentTermDialogDescription = translate('text_64c7a89b6c67eb6c988980eb') - const { openDeleteCustomerNetPaymentTermDialog } = useDeleteCustomerNetPaymentTermDialog() + const { openEditPaymentTermDialog } = useEditPaymentTermDialog() + const { openDeleteCustomerPaymentTermDialog } = useDeleteCustomerPaymentTermDialog() + const { getPaymentTermCopy } = usePaymentTerm() const { openEditFinalizeZeroAmountInvoiceDialog } = useEditFinalizeZeroAmountInvoiceDialog() const { openDeleteCustomerFinalizeZeroAmountInvoiceDialog } = useDeleteCustomerFinalizeZeroAmountInvoiceDialog() @@ -273,35 +281,6 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { const isInvoiceCustomSectionConfigurable = !!customer?.configurableInvoiceCustomSections?.length - function getNetPaymentTermCopy( - customerNetPaymentTerm: number | null | undefined, - billingEntityNetPaymentTerm: number, - ): string { - const isCustomerNetPaymentTermDefined = typeof customerNetPaymentTerm === 'number' - - if (!isCustomerNetPaymentTermDefined) { - return translate( - 'text_64c7a89b6c67eb6c98898241', - { - days: billingEntityNetPaymentTerm ?? 0, - }, - billingEntityNetPaymentTerm ?? 0, - ) - } - - if (customerNetPaymentTerm === 0) { - return translate('text_64c7a89b6c67eb6c98898125') - } - - return translate( - 'text_64c7a89b6c67eb6c9889815f', - { - days: customerNetPaymentTerm, - }, - customerNetPaymentTerm, - ) - } - function getDunningCampaignContent(): React.ReactNode { if (!dunningCampaign || customer?.excludeFromDunningCampaign) { return ( @@ -725,24 +704,20 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { )} - {/* Net payment term */} - + {/* Payment terms */} + - {typeof customer?.netPaymentTerm !== 'number' ? ( + {!customer?.paymentTerm ? ( @@ -765,12 +740,10 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { variant="quaternary" align="left" onClick={() => { - openEditNetPaymentTermDialog({ - model: customer, - description: netPaymentTermDialogDescription, - }) + openEditPaymentTermDialog({ model: customer }) closePopper() }} + data-test={PAYMENT_TERM_EDIT_BUTTON_TEST_ID} > {translate('text_63aa15caab5b16980b21b0b8')} @@ -781,10 +754,11 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { align="left" onClick={() => { if (customer) { - openDeleteCustomerNetPaymentTermDialog({ customer }) + openDeleteCustomerPaymentTermDialog({ customer }) } closePopper() }} + data-test={PAYMENT_TERM_DELETE_BUTTON_TEST_ID} > {translate('text_63aa15caab5b16980b21b0ba')} @@ -798,10 +772,10 @@ export const CustomerSettings = ({ customerId }: CustomerSettingsProps) => { /> - {getNetPaymentTermCopy( - customer?.netPaymentTerm, - billingEntity?.netPaymentTerm || 0, - )} + {getPaymentTermCopy({ + ownTerm: customer?.paymentTerm, + parentTerm: billingEntity?.paymentTerm, + })} diff --git a/src/components/customers/DeleteCustomerNetPaymentTermDialog.tsx b/src/components/customers/DeleteCustomerPaymentTermDialog.tsx similarity index 53% rename from src/components/customers/DeleteCustomerNetPaymentTermDialog.tsx rename to src/components/customers/DeleteCustomerPaymentTermDialog.tsx index 781efc485a..fa8ecbfb43 100644 --- a/src/components/customers/DeleteCustomerNetPaymentTermDialog.tsx +++ b/src/components/customers/DeleteCustomerPaymentTermDialog.tsx @@ -4,57 +4,62 @@ import { Typography } from '~/components/designSystem/Typography' import { useCentralizedDialog } from '~/components/dialogs/CentralizedDialog' import { addToast } from '~/core/apolloClient' import { - DeleteCustomerNetPaymentTermFragment, - useDeleteCustomerNetPaymentTermMutation, + DeleteCustomerPaymentTermFragment, + useDeleteCustomerPaymentTermMutation, } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' gql` - fragment DeleteCustomerNetPaymentTerm on Customer { + fragment DeleteCustomerPaymentTerm on Customer { id externalId name displayName - netPaymentTerm + paymentTerm { + termType + days + dayOfMonth + monthOffset + } } - mutation deleteCustomerNetPaymentTerm($input: UpdateCustomerInput!) { + mutation deleteCustomerPaymentTerm($input: UpdateCustomerInput!) { updateCustomer(input: $input) { id - ...DeleteCustomerNetPaymentTerm + ...DeleteCustomerPaymentTerm } } ` -type DeleteCustomerNetPaymentTermDialogData = { - customer: DeleteCustomerNetPaymentTermFragment +type DeleteCustomerPaymentTermDialogData = { + customer: DeleteCustomerPaymentTermFragment } -export const useDeleteCustomerNetPaymentTermDialog = (): { - openDeleteCustomerNetPaymentTermDialog: (data: DeleteCustomerNetPaymentTermDialogData) => void +export const useDeleteCustomerPaymentTermDialog = (): { + openDeleteCustomerPaymentTermDialog: (data: DeleteCustomerPaymentTermDialogData) => void } => { const centralizedDialog = useCentralizedDialog() const { translate } = useInternationalization() - const [deleteCustomerNetPaymentTerm] = useDeleteCustomerNetPaymentTermMutation({ + const [deleteCustomerPaymentTerm] = useDeleteCustomerPaymentTermMutation({ onCompleted(data) { if (data && data.updateCustomer) { addToast({ - message: translate('text_64c7a89b6c67eb6c98898357'), + message: translate('text_1787603382163macepxq32tf'), severity: 'success', }) } }, }) - const openDeleteCustomerNetPaymentTermDialog = ({ + const openDeleteCustomerPaymentTermDialog = ({ customer, - }: DeleteCustomerNetPaymentTermDialogData): void => { + }: DeleteCustomerPaymentTermDialogData): void => { centralizedDialog.open({ - title: translate('text_64c7a89b6c67eb6c988980db'), + title: translate('text_1787603382163xl4mmi1owjh'), description: ( ${customer?.displayName}`, })} /> @@ -62,11 +67,12 @@ export const useDeleteCustomerNetPaymentTermDialog = (): { colorVariant: 'danger', actionText: translate('text_64c7a89b6c67eb6c98898133'), onAction: async () => { - await deleteCustomerNetPaymentTerm({ + // Clearing the term makes the customer inherit from the billing entity again. + await deleteCustomerPaymentTerm({ variables: { input: { id: customer.id, - netPaymentTerm: null, + paymentTerm: null, externalId: customer.externalId, name: customer.name || '', }, @@ -76,5 +82,5 @@ export const useDeleteCustomerNetPaymentTermDialog = (): { }) } - return { openDeleteCustomerNetPaymentTermDialog } + return { openDeleteCustomerPaymentTermDialog } } diff --git a/src/components/customers/__tests__/CustomerSettings.test.tsx b/src/components/customers/__tests__/CustomerSettings.test.tsx index 98fcaa9194..f06bd53ca5 100644 --- a/src/components/customers/__tests__/CustomerSettings.test.tsx +++ b/src/components/customers/__tests__/CustomerSettings.test.tsx @@ -4,6 +4,7 @@ import { CurrencyEnum, FinalizeZeroAmountInvoiceEnum, GetCustomerSettingsDocument, + PaymentTermTypeEnum, } from '~/generated/graphql' import { render, TestMocksType } from '~/test-utils' @@ -98,23 +99,26 @@ jest.mock('~/components/customers/DeleteCustomerFinalizeZeroAmountInvoiceDialog' }), })) -jest.mock('~/components/customers/DeleteCustomerNetPaymentTermDialog', () => ({ - useDeleteCustomerNetPaymentTermDialog: () => ({ - openDeleteCustomerNetPaymentTermDialog: jest.fn(), +jest.mock('~/components/customers/DeleteCustomerPaymentTermDialog', () => ({ + useDeleteCustomerPaymentTermDialog: () => ({ + openDeleteCustomerPaymentTermDialog: jest.fn(), }), })) -jest.mock('~/components/settings/invoices/EditNetPaymentTermDialog', () => ({ - useEditNetPaymentTermDialog: () => ({ - openEditNetPaymentTermDialog: jest.fn(), +jest.mock('~/components/settings/invoices/EditPaymentTermDialog/EditPaymentTermDialog', () => ({ + useEditPaymentTermDialog: () => ({ + openEditPaymentTermDialog: jest.fn(), }), })) -jest.mock('~/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog', () => ({ - useEditFinalizeZeroAmountInvoiceDialog: () => ({ - openEditFinalizeZeroAmountInvoiceDialog: jest.fn(), +jest.mock( + '~/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/EditFinalizeZeroAmountInvoiceDialog', + () => ({ + useEditFinalizeZeroAmountInvoiceDialog: () => ({ + openEditFinalizeZeroAmountInvoiceDialog: jest.fn(), + }), }), -})) +) jest.mock('~/components/dialogs/PremiumWarningDialog', () => ({ usePremiumWarningDialog: () => ({ open: jest.fn(), close: jest.fn() }), @@ -134,7 +138,7 @@ const createCustomerSettingsMock = (overrides = {}) => ({ name: 'Test Customer', displayName: 'Test Customer', invoiceGracePeriod: null, - netPaymentTerm: null, + paymentTerm: null, finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum.Inherit, currency: CurrencyEnum.Usd, excludeFromDunningCampaign: false, @@ -145,7 +149,13 @@ const createCustomerSettingsMock = (overrides = {}) => ({ billingEntity: { __typename: 'BillingEntity', id: 'billing-entity-123', - netPaymentTerm: 30, + paymentTerm: { + __typename: 'PaymentTerm', + termType: PaymentTermTypeEnum.Net, + days: 30, + dayOfMonth: null, + monthOffset: null, + }, finalizeZeroAmountInvoice: true, billingConfiguration: { __typename: 'BillingEntityBillingConfiguration', @@ -233,11 +243,11 @@ describe('CustomerSettings', () => { }) }) - it('renders net payment term section', async () => { + it('renders payment terms section', async () => { await prepare() await waitFor(() => { - expect(screen.getByText(/net payment term/i)).toBeInTheDocument() + expect(screen.getByText('Payment terms')).toBeInTheDocument() }) }) @@ -377,40 +387,52 @@ describe('CustomerSettings', () => { }) }) - describe('Net Payment Term Setting', () => { - it('displays inherited net payment term when not set on customer', async () => { + describe('Payment Term Setting', () => { + const customerTerm = (paymentTerm: unknown) => + createCustomerSettingsMock({ paymentTerm } as Record) + + it('displays the billing entity term, marked as inherited, when the customer has none', async () => { await prepare() await waitFor(() => { - // Should show inherited value (30 days) - expect(screen.getByText(/30 day/i)).toBeInTheDocument() + expect(screen.getByText(/Net 30 days \(inherit from billing entity\)/i)).toBeInTheDocument() }) }) - it('displays custom net payment term when set on customer', async () => { - const customNetPaymentTermMock = createCustomerSettingsMock({ - netPaymentTerm: 15, + it('displays the customer term, unmarked, when it overrides the billing entity', async () => { + await prepare({ + mocks: [ + customerTerm({ + __typename: 'PaymentTerm', + termType: PaymentTermTypeEnum.Net, + days: 15, + dayOfMonth: null, + monthOffset: null, + }), + ], }) - await prepare({ mocks: [customNetPaymentTermMock] }) - await waitFor(() => { - expect(screen.getByText(/15 day/i)).toBeInTheDocument() + expect(screen.getByText('Net 15 days')).toBeInTheDocument() }) + expect(screen.queryByText(/Net 15 days \(inherit/i)).not.toBeInTheDocument() }) - it('displays zero days when net payment term is 0', async () => { - const zeroNetPaymentTermMock = createCustomerSettingsMock({ - netPaymentTerm: 0, + it('displays a term type carrying no numeric field', async () => { + await prepare({ + mocks: [ + customerTerm({ + __typename: 'PaymentTerm', + termType: PaymentTermTypeEnum.EndOfMonth, + days: null, + dayOfMonth: null, + monthOffset: null, + }), + ], }) - await prepare({ mocks: [zeroNetPaymentTermMock] }) - await waitFor(() => { - // Net payment term of 0 shows as "0 day" - const elements = screen.getAllByText(/0 day/i) - - expect(elements.length).toBeGreaterThan(0) + expect(screen.getByText('End of month')).toBeInTheDocument() }) }) }) diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/AddOnSelectionContent.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/AddOnSelectionContent.tsx index 918ea4f66b..14471c18de 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/AddOnSelectionContent.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/AddOnSelectionContent.tsx @@ -14,6 +14,7 @@ import { ComboboxItem } from '~/components/form' import { ComboBox } from '~/components/form/ComboBox/ComboBox' import { MUI_INPUT_BASE_ROOT_CLASSNAME } from '~/core/constants/form' import { getCurrencySymbol, intlFormatNumber } from '~/core/formats/intlFormatNumber' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' import { addUnsupportedDateIssue } from '~/formValidation/zodCustoms' import { type AddOnForPricingSectionFragment, @@ -60,7 +61,7 @@ gql` interface AddOnSelectionContentExtraProps { currency: CurrencyEnum onAddOnPayloadCapture?: (localId: string, addOn: AddOnForPricingSectionFragment) => void - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null } function TotalAmountCell({ @@ -313,7 +314,7 @@ const ConfirmedAddOnRow = withForm({ const addOnSelectionContentDefaultProps: AddOnSelectionContentExtraProps = { currency: CurrencyEnum.Usd, onAddOnPayloadCapture: undefined, - netPaymentTerm: undefined, + paymentTerm: undefined, } const AddOnSelectionContent = withForm({ @@ -323,7 +324,7 @@ const AddOnSelectionContent = withForm({ form, currency, onAddOnPayloadCapture, - netPaymentTerm, + paymentTerm, }) { const { translate } = useInternationalization() const { intlFormatDateTimeOrgaTZ } = useOrganizationInfos() @@ -394,7 +395,7 @@ const AddOnSelectionContent = withForm({ {translate('text_17295436903260tlyb1gp1i7')} ), - children: , + children: , }) } diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/EditAddOnDrawer.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/EditAddOnDrawer.tsx index b0c09cf67d..bf99f4af95 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/EditAddOnDrawer.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/EditAddOnDrawer.tsx @@ -1,4 +1,5 @@ import { Typography } from '~/components/designSystem/Typography' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' import { useInternationalization } from '~/hooks/core/useInternationalization' import { withForm } from '~/hooks/forms/useAppform' @@ -14,17 +15,17 @@ export const editAddOnDrawerDefaultValues = { const DESCRIPTION_MAX_LENGTH = 255 interface EditAddOnDrawerExtraProps { - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null } const editAddOnDrawerDefaultProps: EditAddOnDrawerExtraProps = { - netPaymentTerm: undefined, + paymentTerm: undefined, } const EditAddOnDrawer = withForm({ defaultValues: editAddOnDrawerDefaultValues, props: editAddOnDrawerDefaultProps, - render: function EditAddOnDrawerRender({ form, netPaymentTerm }) { + render: function EditAddOnDrawerRender({ form, paymentTerm }) { const { translate } = useInternationalization() return ( @@ -57,7 +58,7 @@ const EditAddOnDrawer = withForm({ )} - +
diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/PricingDrawerContent.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/PricingDrawerContent.tsx index bb787c046e..50e3728ead 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/PricingDrawerContent.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/PricingDrawerContent.tsx @@ -1,3 +1,4 @@ +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' import { type AddOnForPricingSectionFragment, CurrencyEnum } from '~/generated/graphql' import { withForm } from '~/hooks/forms/useAppform' @@ -7,13 +8,13 @@ import { pricingDrawerDefaultValues } from './constants' interface PricingDrawerContentExtraProps { currency: CurrencyEnum onAddOnPayloadCapture?: (localId: string, addOn: AddOnForPricingSectionFragment) => void - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null } const pricingDrawerContentDefaultProps: PricingDrawerContentExtraProps = { currency: CurrencyEnum.Usd, onAddOnPayloadCapture: undefined, - netPaymentTerm: undefined, + paymentTerm: undefined, } const PricingDrawerContent = withForm({ @@ -23,14 +24,14 @@ const PricingDrawerContent = withForm({ form, currency, onAddOnPayloadCapture, - netPaymentTerm, + paymentTerm, }) { return ( ) }, diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/QuotePaymentTermLine.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/QuotePaymentTermLine.tsx index 1bac82fcdf..5dcbb39b27 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/QuotePaymentTermLine.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/QuotePaymentTermLine.tsx @@ -1,24 +1,22 @@ import { Typography } from '~/components/designSystem/Typography' -import { type TranslateFunc, useInternationalization } from '~/hooks/core/useInternationalization' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { usePaymentTerm } from '~/hooks/usePaymentTerm' export const QUOTE_PAYMENT_TERM_LINE_TEST_ID = 'quote-payment-term-line' -export const formatNetPaymentTerm = ( - netPaymentTerm: number | null | undefined, - translate: TranslateFunc, -): string => { - if (typeof netPaymentTerm !== 'number') return '-' - if (netPaymentTerm === 0) return translate('text_64c7a89b6c67eb6c98898125') - - return translate('text_64c7a89b6c67eb6c9889815f', { days: netPaymentTerm }, netPaymentTerm) -} - +/** + * Quotes carry no term of their own — they display the term resolved from the customer, + * then the billing entity, then the default. Resolution happens once in `EditQuote`, so + * this only has to render what it is handed. + */ export const QuotePaymentTermLine = ({ - netPaymentTerm, + paymentTerm, }: { - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null }): JSX.Element => { const { translate } = useInternationalization() + const { formatPaymentTerm } = usePaymentTerm() return (
@@ -26,7 +24,7 @@ export const QuotePaymentTermLine = ({ {translate('text_1778660219891rv2r5gjmklq')} - {formatNetPaymentTerm(netPaymentTerm, translate)} + {paymentTerm ? formatPaymentTerm(paymentTerm) : '-'} {translate('text_17871360906936tl2in6avzh')} diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/SubscriptionPricingContent.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/SubscriptionPricingContent.tsx index dbf99d1cf9..31205526d9 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/SubscriptionPricingContent.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/SubscriptionPricingContent.tsx @@ -27,6 +27,7 @@ import { DEFAULT_SUBSCRIPTION_SETTINGS, type SubscriptionPricingState, } from '~/core/serializers/serializeQuotePlanBillingItems' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' import { CurrencyEnum, PlanInterval, usePlansLazyQuery } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { usePlanFormSetup } from '~/hooks/plans/usePlanFormSetup' @@ -47,7 +48,7 @@ interface SubscriptionPricingContentProps { basePlanFormValuesRef: MutableRefObject initialState?: SubscriptionPricingState | null customer?: QuoteCustomer | null - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null /** Currency used to display amounts — may be a customer/organization fallback. */ currency?: CurrencyEnum | null /** @@ -72,7 +73,7 @@ export function SubscriptionPricingContent({ basePlanFormValuesRef, initialState, customer, - netPaymentTerm, + paymentTerm, currency, hasQuoteCurrency, billingItemPlan, @@ -203,7 +204,7 @@ export function SubscriptionPricingContent({ setSubscriptionSettings(values) }, isAmendment, - netPaymentTerm, + paymentTerm, }) const showInvoicingSection = Boolean(customer?.externalId || customer?.id) const planSettingsDrawer = useQuotePlanSettingsDrawer(planForm, { diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/EditAddOnDrawer.test.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/EditAddOnDrawer.test.tsx index dcd08e0a3a..5cdf14bd84 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/EditAddOnDrawer.test.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/EditAddOnDrawer.test.tsx @@ -1,5 +1,7 @@ import { screen } from '@testing-library/react' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' import { render } from '~/test-utils' import EditAddOnDrawer, { editAddOnDrawerDefaultValues } from '../EditAddOnDrawer' @@ -19,7 +21,7 @@ jest.mock('~/hooks/useOrganizationInfos', () => ({ const renderWithForm = ( initialValues?: Partial, - netPaymentTerm?: number | null, + paymentTerm?: ResolvablePaymentTerm | null, ) => { const { useAppForm: useAppFormHook } = jest.requireActual('~/hooks/forms/useAppform') @@ -31,7 +33,7 @@ const renderWithForm = ( }, }) - return + return } return render() @@ -219,10 +221,10 @@ describe('EditAddOnDrawer', () => { describe('GIVEN the drawer carries the one-off deal-term dates', () => { describe('WHEN a resolved payment term is passed', () => { it.each([ - ['a positive term', 30], - ['a zero term', 0], - ])('THEN should display the read-only payment term for %s', (_, netPaymentTerm) => { - renderWithForm(undefined, netPaymentTerm) + ['a term carrying days', { termType: PaymentTermTypeEnum.Net, days: 30 }], + ['a term carrying none', { termType: PaymentTermTypeEnum.DueOnReceipt }], + ])('THEN should display the read-only payment term for %s', (_, paymentTerm) => { + renderWithForm(undefined, paymentTerm) expect(screen.getByTestId(QUOTE_PAYMENT_TERM_LINE_TEST_ID)).toBeInTheDocument() }) diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/QuotePaymentTermLine.test.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/QuotePaymentTermLine.test.tsx index 5fb8dd43b4..4cb1c0abf7 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/QuotePaymentTermLine.test.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/QuotePaymentTermLine.test.tsx @@ -1,12 +1,9 @@ import { screen } from '@testing-library/react' +import { PaymentTermTypeEnum } from '~/generated/graphql' import { render } from '~/test-utils' -import { - formatNetPaymentTerm, - QUOTE_PAYMENT_TERM_LINE_TEST_ID, - QuotePaymentTermLine, -} from '../QuotePaymentTermLine' +import { QUOTE_PAYMENT_TERM_LINE_TEST_ID, QuotePaymentTermLine } from '../QuotePaymentTermLine' jest.mock('~/hooks/core/useInternationalization', () => ({ useInternationalization: () => ({ @@ -14,58 +11,13 @@ jest.mock('~/hooks/core/useInternationalization', () => ({ }), })) -const translate = jest.fn( - (key: string, data?: Record) => - `${key}${data ? `:${JSON.stringify(data)}` : ''}`, -) - -describe('formatNetPaymentTerm', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - describe('GIVEN no payment term is known', () => { - describe.each([ - ['undefined', undefined], - ['null', null], - ])('WHEN the value is %s', (_, value) => { - it('THEN should return a placeholder without translating', () => { - expect(formatNetPaymentTerm(value, translate)).toBe('-') - expect(translate).not.toHaveBeenCalled() - }) - }) - }) - - describe('GIVEN the payment term is zero', () => { - describe('WHEN formatting it', () => { - it('THEN should use the dedicated at-issuing-date copy', () => { - const result = formatNetPaymentTerm(0, translate) - - expect(translate).toHaveBeenCalledTimes(1) - expect(result).not.toContain('{') - }) - }) - }) - - describe('GIVEN a positive payment term', () => { - describe.each([ - ['a single day', 1], - ['several days', 30], - ])('WHEN formatting %s', (_, days) => { - it('THEN should interpolate the day count and pass it as the plural driver', () => { - formatNetPaymentTerm(days, translate) - - expect(translate).toHaveBeenCalledWith(expect.any(String), { days }, days) - }) - }) - }) -}) - describe('QuotePaymentTermLine', () => { describe('GIVEN a resolved payment term', () => { describe('WHEN the row renders', () => { it('THEN should display the formatted value', () => { - render() + render( + , + ) const value = screen.getByTestId(QUOTE_PAYMENT_TERM_LINE_TEST_ID) @@ -75,10 +27,23 @@ describe('QuotePaymentTermLine', () => { }) }) - describe('GIVEN no payment term', () => { + describe('GIVEN a term type carrying no numeric field', () => { describe('WHEN the row renders', () => { + it('THEN should still display a value rather than the placeholder', () => { + render() + + expect(screen.getByTestId(QUOTE_PAYMENT_TERM_LINE_TEST_ID)).not.toHaveTextContent('-') + }) + }) + }) + + describe('GIVEN no payment term', () => { + describe.each([ + ['undefined', undefined], + ['null', null], + ])('WHEN the value is %s', (_, paymentTerm) => { it('THEN should display the placeholder rather than hiding the row', () => { - render() + render() expect(screen.getByTestId(QUOTE_PAYMENT_TERM_LINE_TEST_ID)).toHaveTextContent('-') }) @@ -88,7 +53,9 @@ describe('QuotePaymentTermLine', () => { describe('GIVEN the row is read-only', () => { describe('WHEN it renders', () => { it('THEN should render no editable control', () => { - const { container } = render() + const { container } = render( + , + ) expect(container.querySelector('input')).toBeNull() expect(container.querySelector('button')).toBeNull() diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/useSubscriptionSettingsDrawer.test.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/useSubscriptionSettingsDrawer.test.tsx index 2ada471a13..b095f3659b 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/useSubscriptionSettingsDrawer.test.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/__tests__/useSubscriptionSettingsDrawer.test.tsx @@ -2,6 +2,8 @@ import { act, renderHook, screen, waitFor, within } from '@testing-library/react import userEvent from '@testing-library/user-event' import { ReactElement } from 'react' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' import { render } from '~/test-utils' import { QUOTE_PAYMENT_TERM_LINE_TEST_ID } from '../QuotePaymentTermLine' @@ -52,10 +54,10 @@ const populatedValues: SubscriptionSettingsFormValues = { const openAndRenderDrawer = ( values: SubscriptionSettingsFormValues = defaultValues, isAmendment = false, - netPaymentTerm: number | null | undefined = undefined, + paymentTerm: ResolvablePaymentTerm | null | undefined = undefined, ) => { const hookReturn = renderHook(() => - useSubscriptionSettingsDrawer({ onSave: mockOnSave, isAmendment, netPaymentTerm }), + useSubscriptionSettingsDrawer({ onSave: mockOnSave, isAmendment, paymentTerm }), ) act(() => { @@ -495,10 +497,10 @@ describe('useSubscriptionSettingsDrawer', () => { describe('GIVEN the drawer carries the deal-term dates', () => { describe('WHEN a resolved payment term is passed', () => { it.each([ - ['a positive term', 30], - ['a zero term', 0], - ])('THEN should display the read-only payment term for %s', (_, netPaymentTerm) => { - openAndRenderDrawer(defaultValues, false, netPaymentTerm) + ['a term carrying days', { termType: PaymentTermTypeEnum.Net, days: 30 }], + ['a term carrying none', { termType: PaymentTermTypeEnum.DueOnReceipt }], + ])('THEN should display the read-only payment term for %s', (_, paymentTerm) => { + openAndRenderDrawer(defaultValues, false, paymentTerm) expect(screen.getByTestId(QUOTE_PAYMENT_TERM_LINE_TEST_ID)).toBeInTheDocument() }) @@ -506,7 +508,7 @@ describe('useSubscriptionSettingsDrawer', () => { it('THEN should not turn the payment term into a form field', async () => { const user = userEvent.setup() - openAndRenderDrawer(populatedValues, false, 30) + openAndRenderDrawer(populatedValues, false, { termType: PaymentTermTypeEnum.Net, days: 30 }) const saveButton = screen.getByTestId(SUBSCRIPTION_SETTINGS_DRAWER_SAVE_TEST_ID) @@ -516,7 +518,7 @@ describe('useSubscriptionSettingsDrawer', () => { expect(mockOnSave).toHaveBeenCalledTimes(1) }) - expect(mockOnSave.mock.calls[0][0]).not.toHaveProperty('netPaymentTerm') + expect(mockOnSave.mock.calls[0][0]).not.toHaveProperty('paymentTerm') }) }) diff --git a/src/components/designSystem/RichTextEditor/PricingBlock/useSubscriptionSettingsDrawer.tsx b/src/components/designSystem/RichTextEditor/PricingBlock/useSubscriptionSettingsDrawer.tsx index 66156daac4..cc9f7a5972 100644 --- a/src/components/designSystem/RichTextEditor/PricingBlock/useSubscriptionSettingsDrawer.tsx +++ b/src/components/designSystem/RichTextEditor/PricingBlock/useSubscriptionSettingsDrawer.tsx @@ -7,6 +7,7 @@ import { Tooltip } from '~/components/designSystem/Tooltip' import { Typography } from '~/components/designSystem/Typography' import { useDrawer } from '~/components/drawers/useDrawer' import { CenteredPage } from '~/components/layouts/CenteredPage' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' import { addUnsupportedDateIssue } from '~/formValidation/zodCustoms' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm, withForm } from '~/hooks/forms/useAppform' @@ -59,19 +60,19 @@ const DEFAULT_VALUES: SubscriptionSettingsFormValues = { interface SubscriptionSettingsDrawerContentProps { initialValues: SubscriptionSettingsFormValues isAmendment: boolean - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null } const subscriptionSettingsDrawerContentDefaultProps: SubscriptionSettingsDrawerContentProps = { initialValues: DEFAULT_VALUES, isAmendment: false, - netPaymentTerm: undefined, + paymentTerm: undefined, } const SubscriptionSettingsDrawerContent = withForm({ defaultValues: DEFAULT_VALUES, props: subscriptionSettingsDrawerContentDefaultProps, - render: function Render({ form, initialValues, isAmendment, netPaymentTerm }) { + render: function Render({ form, initialValues, isAmendment, paymentTerm }) { const { translate } = useInternationalization() const [showExternalId, setShowExternalId] = useState(!!initialValues.externalId) // On an amendment the external id identifies the subscription being amended, so once it @@ -215,7 +216,7 @@ const SubscriptionSettingsDrawerContent = withForm({ )}
- +
) }, @@ -224,13 +225,13 @@ const SubscriptionSettingsDrawerContent = withForm({ interface UseSubscriptionSettingsDrawerProps { onSave: (values: SubscriptionSettingsFormValues) => void isAmendment?: boolean - netPaymentTerm?: number | null + paymentTerm?: ResolvablePaymentTerm | null } export const useSubscriptionSettingsDrawer = ({ onSave, isAmendment = false, - netPaymentTerm, + paymentTerm, }: UseSubscriptionSettingsDrawerProps) => { const { translate } = useInternationalization() const drawer = useDrawer() @@ -263,7 +264,7 @@ export const useSubscriptionSettingsDrawer = ({ form={form} initialValues={values} isAmendment={isAmendment} - netPaymentTerm={netPaymentTerm} + paymentTerm={paymentTerm} /> ), @@ -288,7 +289,7 @@ export const useSubscriptionSettingsDrawer = ({ }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [drawer, form, translate, netPaymentTerm], + [drawer, form, translate, paymentTerm], ) return { openDrawer } diff --git a/src/components/emails/DunningEmail.tsx b/src/components/emails/DunningEmail.tsx index 82dfb38eb8..60de24ea7a 100644 --- a/src/components/emails/DunningEmail.tsx +++ b/src/components/emails/DunningEmail.tsx @@ -23,7 +23,6 @@ gql` id displayName paymentProvider - netPaymentTerm billingConfiguration { id documentLocale @@ -35,7 +34,6 @@ gql` name logoUrl email - netPaymentTerm billingConfiguration { id documentLocale @@ -58,14 +56,8 @@ gql` export interface DunningEmailProps { locale: LocaleEnum invoices: InvoicesForDunningEmailFragment[] - customer?: Pick< - CustomerForDunningEmailFragment, - 'displayName' | 'paymentProvider' | 'netPaymentTerm' - > - // Omit the netPaymentTerm from the organization and add it possible string - organization?: Pick & { - netPaymentTerm: OrganizationForDunningEmailFragment['netPaymentTerm'] | string - } + customer?: Pick + organization?: Pick currency: CurrencyEnum overdueAmount: number } @@ -100,8 +92,6 @@ export const DunningEmail: FC = ({ currencyDisplay: 'narrowSymbol', }) - const netPaymentTerm = customer?.netPaymentTerm ?? organization?.netPaymentTerm - return ( <>
@@ -114,16 +104,6 @@ export const DunningEmail: FC = ({ {translate('text_66b378e748cda1004ff00db2', { amount: formattedOverdueAmount })} - - {translate( - 'text_66b378e748cda1004ff00db3', - { netPaymentTerm: netPaymentTerm }, - typeof netPaymentTerm === 'number' - ? netPaymentTerm - : // If netPaymentTerm is a string (fake data), the plural version is returned - 2, - )} - {translate('text_66b378e748cda1004ff00db4')} diff --git a/src/components/emails/__tests__/DunningEmail.test.tsx b/src/components/emails/__tests__/DunningEmail.test.tsx index c22029766c..5541eaf968 100644 --- a/src/components/emails/__tests__/DunningEmail.test.tsx +++ b/src/components/emails/__tests__/DunningEmail.test.tsx @@ -23,7 +23,6 @@ const mockInvoices = [ const mockCustomer = { displayName: 'Test Customer', paymentProvider: ProviderTypeEnum.Stripe, - netPaymentTerm: 30, billingConfiguration: { documentLocale: LocaleEnum.en, }, @@ -33,7 +32,6 @@ const mockOrganization = { name: 'Test Organization', logoUrl: 'https://example.com/logo.png', email: 'billing@example.com', - netPaymentTerm: 15, billingConfiguration: { documentLocale: LocaleEnum.en, }, @@ -126,8 +124,10 @@ describe('DunningEmail', () => { expect(screen.getByText('billing@example.com')).toBeInTheDocument() }) - it('uses customer netPaymentTerm when available', () => { - const { container } = render( + // Spec #1 §7: with terms set per customer and billing entity, a single generic sentence + // can no longer state one truthfully, so the email no longer mentions payment terms. + it('does not mention payment terms', () => { + render( { />, ) - // Component renders with customer data - expect(container).toBeInTheDocument() - }) - - it('falls back to organization netPaymentTerm when customer has none', () => { - const customerWithoutTerm = { - ...mockCustomer, - netPaymentTerm: undefined, - } - - const { container } = render( - , - ) - - // Component renders with organization fallback - expect(container).toBeInTheDocument() - }) - - it('handles string netPaymentTerm for fake data', () => { - const organizationWithStringTerm = { - ...mockOrganization, - netPaymentTerm: '30' as unknown as number, - } - - const { container } = render( - , - ) - - // Component renders with string netPaymentTerm - expect(container).toBeInTheDocument() + expect(screen.queryByText(/payment terms/i)).not.toBeInTheDocument() }) it('renders without customer data', () => { diff --git a/src/components/layouts/Settings.tsx b/src/components/layouts/Settings.tsx index 20ff10edb9..9f3cf9bd56 100644 --- a/src/components/layouts/Settings.tsx +++ b/src/components/layouts/Settings.tsx @@ -32,9 +32,11 @@ export const SettingsListWrapper = ({ export const SettingsListItem = ({ children, className, -}: PropsWithChildren & { className?: string }) => ( + dataTest, +}: PropsWithChildren & { className?: string; dataTest?: string }) => (
{children}
diff --git a/src/components/paymentTerms/PaymentTermFormContent.tsx b/src/components/paymentTerms/PaymentTermFormContent.tsx new file mode 100644 index 0000000000..50306ecf0d --- /dev/null +++ b/src/components/paymentTerms/PaymentTermFormContent.tsx @@ -0,0 +1,137 @@ +import InputAdornment from '@mui/material/InputAdornment' +import { useStore } from '@tanstack/react-form' + +import { Alert } from '~/components/designSystem/Alert' +import { Typography } from '~/components/designSystem/Typography' +import { PAYMENT_TERM_FIELDS_BY_TYPE } from '~/core/constants/paymentTerm' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { withForm } from '~/hooks/forms/useAppform' +import { usePaymentTerm } from '~/hooks/usePaymentTerm' + +import { + PAYMENT_TERM_DUE_DATE_PREVIEW_TEST_ID, + PAYMENT_TERM_TYPE_COMBOBOX_TEST_CLASSNAME, +} from './dataTestConstants' +import { PAYMENT_TERM_FORM_DEFAULT_VALUES, PaymentTermInheritedFrom } from './types' +import { isConcreteTermType, paymentTermFromFormValues } from './utils' + +type PaymentTermFormContentExtraProps = { + /** + * Prepends an inherit choice to the term type list, labelled with the value that would + * be inherited. Omit it on a level that has no parent to fall back to. + */ + inheritedFrom?: PaymentTermInheritedFrom + /** Set when the form is rendered inside a dialog, so the popper escapes it. */ + displayInDialog?: boolean +} + +const paymentTermFormContentDefaultProps: PaymentTermFormContentExtraProps = { + inheritedFrom: undefined, + displayInDialog: false, +} + +/** + * The payment term editor. Every surface that can carry a term renders this same block: + * the term type, the numeric fields that type accepts, and a preview of the due date it + * would produce. + */ +export const PaymentTermFormContent = withForm({ + defaultValues: PAYMENT_TERM_FORM_DEFAULT_VALUES, + props: paymentTermFormContentDefaultProps, + render: function PaymentTermFormContentRender({ form, inheritedFrom, displayInDialog }) { + const { translate } = useInternationalization() + const { getDueDatePreviewCopy, getTermTypeComboboxData } = usePaymentTerm() + + const termType = useStore(form.store, (state) => state.values.termType) + const days = useStore(form.store, (state) => state.values.days) + const dayOfMonth = useStore(form.store, (state) => state.values.dayOfMonth) + const monthOffset = useStore(form.store, (state) => state.values.monthOffset) + + const fields = isConcreteTermType(termType) ? PAYMENT_TERM_FIELDS_BY_TYPE[termType] : [] + const previewTerm = paymentTermFromFormValues({ termType, days, dayOfMonth, monthOffset }) + + return ( +
+ + {(field) => ( + + )} + + + {fields.includes('days') && ( + + {(field) => ( + + {translate('text_638dc196fb209d551f3d814d')} + + ), + }} + /> + )} + + )} + + {fields.includes('dayOfMonth') && ( +
+ + {(field) => ( + + )} + + + + - + + + + {(field) => ( + + {translate('text_1787603382163u2kxy2qxchd')} + + ), + }} + /> + )} + +
+ )} + + {!!previewTerm && ( + + + {getDueDatePreviewCopy(previewTerm)} + + + )} +
+ ) + }, +}) diff --git a/src/components/paymentTerms/__tests__/validationSchema.test.ts b/src/components/paymentTerms/__tests__/validationSchema.test.ts new file mode 100644 index 0000000000..b66b96ba7b --- /dev/null +++ b/src/components/paymentTerms/__tests__/validationSchema.test.ts @@ -0,0 +1,109 @@ +import { PAYMENT_TERM_INHERIT } from '~/core/constants/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' + +import { PAYMENT_TERM_FORM_DEFAULT_VALUES, PaymentTermFormValues } from '../types' +import { paymentTermFormSchema } from '../validationSchema' + +const TERM_TYPE_REQUIRED = 'text_1789042962229hmb871mrfas' +const DAYS_INVALID = 'text_1789042962229bk0kdnqlbpc' +const DAY_OF_MONTH_INVALID = 'text_1789042962229ojqymcu3289' +const MONTH_OFFSET_INVALID = 'text_1789042962229esmw981wwyt' + +const values = (overrides: Partial = {}): PaymentTermFormValues => ({ + ...PAYMENT_TERM_FORM_DEFAULT_VALUES, + ...overrides, +}) + +const errorsOf = (input: unknown): { path: string; message: string }[] => { + const result = paymentTermFormSchema.safeParse(input) + + if (result.success) return [] + + return result.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })) +} + +describe('paymentTermFormSchema', () => { + describe('GIVEN no term type', () => { + describe('WHEN validating', () => { + it('THEN should report the required term type', () => { + // Without this the dialog submits an empty term and the button spins with no error. + expect(errorsOf(values({ termType: undefined }))).toEqual([ + { path: 'termType', message: TERM_TYPE_REQUIRED }, + ]) + }) + }) + }) + + describe('GIVEN the inherit choice', () => { + describe('WHEN validating', () => { + it('THEN should report no error and skip the numeric rules', () => { + expect( + errorsOf(values({ termType: PAYMENT_TERM_INHERIT, days: '', dayOfMonth: '' })), + ).toEqual([]) + }) + }) + }) + + describe('GIVEN a type that carries no numeric field', () => { + describe('WHEN its unrelated fields are blank', () => { + it('THEN should report no error', () => { + expect( + errorsOf( + values({ termType: PaymentTermTypeEnum.DueOnReceipt, days: '', dayOfMonth: '' }), + ), + ).toEqual([]) + }) + }) + }) + + describe.each([ + PaymentTermTypeEnum.Net, + PaymentTermTypeEnum.NetEndOfMonth, + PaymentTermTypeEnum.DaysEndOfMonth, + ])('GIVEN a %s term', (termType) => { + describe('WHEN days is blank', () => { + it('THEN should report the days error', () => { + expect(errorsOf(values({ termType, days: '' }))).toEqual([ + { path: 'days', message: DAYS_INVALID }, + ]) + }) + }) + + describe('WHEN days is zero', () => { + it('THEN should report no error', () => { + expect(errorsOf(values({ termType, days: 0 }))).toEqual([]) + }) + }) + }) + + describe('GIVEN a day of month term', () => { + const dayOfMonthTerm = (overrides: Partial = {}) => + values({ termType: PaymentTermTypeEnum.DayOfMonth, ...overrides }) + + describe.each([0, 32])('WHEN the day of month is %s', (dayOfMonth) => { + it('THEN should report the day of month error', () => { + expect(errorsOf(dayOfMonthTerm({ dayOfMonth }))).toEqual([ + { path: 'dayOfMonth', message: DAY_OF_MONTH_INVALID }, + ]) + }) + }) + + describe('WHEN the month offset is out of range', () => { + it('THEN should report the month offset error', () => { + expect(errorsOf(dayOfMonthTerm({ monthOffset: 13 }))).toEqual([ + { path: 'monthOffset', message: MONTH_OFFSET_INVALID }, + ]) + }) + }) + + describe('WHEN the month offset is blank', () => { + it('THEN should report no error', () => { + // The API fills its own default when the field is absent. + expect(errorsOf(dayOfMonthTerm({ monthOffset: '' }))).toEqual([]) + }) + }) + }) +}) diff --git a/src/components/paymentTerms/dataTestConstants.ts b/src/components/paymentTerms/dataTestConstants.ts new file mode 100644 index 0000000000..fe4ae93490 --- /dev/null +++ b/src/components/paymentTerms/dataTestConstants.ts @@ -0,0 +1,11 @@ +import { PAYMENT_TERM_INPUT_CLASSNAME } from '~/core/constants/form' + +export const PAYMENT_TERM_SETTINGS_ROW_TEST_ID = 'payment-term-settings-row' +export const PAYMENT_TERM_ADD_BUTTON_TEST_ID = 'payment-term-add-button' +export const PAYMENT_TERM_EDIT_BUTTON_TEST_ID = 'payment-term-edit-button' +export const PAYMENT_TERM_DELETE_BUTTON_TEST_ID = 'payment-term-delete-button' + +export const EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID = 'edit-payment-term-submit' + +export const PAYMENT_TERM_TYPE_COMBOBOX_TEST_CLASSNAME = PAYMENT_TERM_INPUT_CLASSNAME +export const PAYMENT_TERM_DUE_DATE_PREVIEW_TEST_ID = 'payment-term-due-date-preview' diff --git a/src/components/paymentTerms/types.ts b/src/components/paymentTerms/types.ts new file mode 100644 index 0000000000..3104e78d1d --- /dev/null +++ b/src/components/paymentTerms/types.ts @@ -0,0 +1,35 @@ +import { + PAYMENT_TERM_DAY_OF_MONTH_MIN, + PAYMENT_TERM_DEFAULT_MONTH_OFFSET, + PAYMENT_TERM_INHERIT, +} from '~/core/constants/paymentTerm' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' + +export type PaymentTermType = PaymentTermTypeEnum | typeof PAYMENT_TERM_INHERIT + +/** The term a level would fall back to, and the label naming the level it comes from. */ +export type PaymentTermInheritedFrom = { term: ResolvablePaymentTerm; labelKey: string } + +export type PaymentTermFormValues = { + termType: PaymentTermType | undefined + days: number | '' + dayOfMonth: number | '' + monthOffset: number | '' +} + +/** + * `termType` starts out unset, so a level with no term of its own and nothing to inherit + * from reports a required-field error instead of submitting an empty term. A level that + * can inherit is seeded with `PAYMENT_TERM_INHERIT` by its caller. + * + * The numeric fields are seeded rather than left blank so that switching term type + * reveals a usable value straight away. Values belonging to a type the user moved away + * from are simply not sent: `buildPaymentTermInput` emits only the chosen type's fields. + */ +export const PAYMENT_TERM_FORM_DEFAULT_VALUES: PaymentTermFormValues = { + termType: undefined, + days: 0, + dayOfMonth: PAYMENT_TERM_DAY_OF_MONTH_MIN, + monthOffset: PAYMENT_TERM_DEFAULT_MONTH_OFFSET, +} diff --git a/src/components/paymentTerms/utils.ts b/src/components/paymentTerms/utils.ts new file mode 100644 index 0000000000..89a41f0b90 --- /dev/null +++ b/src/components/paymentTerms/utils.ts @@ -0,0 +1,24 @@ +import { PAYMENT_TERM_INHERIT } from '~/core/constants/paymentTerm' +import { ResolvablePaymentTerm } from '~/core/utils/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' + +import { PaymentTermFormValues, PaymentTermType } from './types' + +/** Whether the value stands for a concrete term rather than the inherit choice. */ +export const isConcreteTermType = ( + termType: PaymentTermType | undefined, +): termType is PaymentTermTypeEnum => !!termType && termType !== PAYMENT_TERM_INHERIT + +/** Turns the form's string-tolerant values into a term the date math can read. */ +export const paymentTermFromFormValues = ( + values: PaymentTermFormValues, +): ResolvablePaymentTerm | null => { + if (!isConcreteTermType(values.termType)) return null + + return { + termType: values.termType, + days: values.days === '' ? 0 : Number(values.days), + dayOfMonth: values.dayOfMonth === '' ? null : Number(values.dayOfMonth), + monthOffset: values.monthOffset === '' ? null : Number(values.monthOffset), + } +} diff --git a/src/components/paymentTerms/validationSchema.ts b/src/components/paymentTerms/validationSchema.ts new file mode 100644 index 0000000000..2cc175dc61 --- /dev/null +++ b/src/components/paymentTerms/validationSchema.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' + +import { + PAYMENT_TERM_DAY_OF_MONTH_MAX, + PAYMENT_TERM_DAY_OF_MONTH_MIN, + PAYMENT_TERM_FIELDS_BY_TYPE, + PAYMENT_TERM_INHERIT, + PAYMENT_TERM_MONTH_OFFSET_MAX, + PAYMENT_TERM_MONTH_OFFSET_MIN, +} from '~/core/constants/paymentTerm' +import { PaymentTermTypeEnum } from '~/generated/graphql' + +import { isConcreteTermType } from './utils' + +const isPositiveIntegerWithin = (value: number | '', min: number, max: number): boolean => + typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max + +/** + * Mirrors the API's discriminated-union validation: a type's own fields are required and + * bounded, and nothing else is looked at. + */ +export const paymentTermFormSchema = z + .object({ + termType: z.union([z.enum(PaymentTermTypeEnum), z.literal(PAYMENT_TERM_INHERIT)], { + message: 'text_1789042962229hmb871mrfas', + }), + days: z.union([z.number(), z.literal('')]), + dayOfMonth: z.union([z.number(), z.literal('')]), + monthOffset: z.union([z.number(), z.literal('')]), + }) + .superRefine((values, ctx) => { + if (!isConcreteTermType(values.termType)) return + + const fields = PAYMENT_TERM_FIELDS_BY_TYPE[values.termType] + + if ( + fields.includes('days') && + !isPositiveIntegerWithin(values.days, 0, Number.MAX_SAFE_INTEGER) + ) { + ctx.addIssue({ + code: 'custom', + path: ['days'], + message: 'text_1789042962229bk0kdnqlbpc', + }) + } + + if ( + fields.includes('dayOfMonth') && + !isPositiveIntegerWithin( + values.dayOfMonth, + PAYMENT_TERM_DAY_OF_MONTH_MIN, + PAYMENT_TERM_DAY_OF_MONTH_MAX, + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['dayOfMonth'], + message: 'text_1789042962229ojqymcu3289', + }) + } + + // Absent is valid — the API fills the default — but a value that is present must be in range. + if ( + fields.includes('monthOffset') && + values.monthOffset !== '' && + !isPositiveIntegerWithin( + values.monthOffset, + PAYMENT_TERM_MONTH_OFFSET_MIN, + PAYMENT_TERM_MONTH_OFFSET_MAX, + ) + ) { + ctx.addIssue({ + code: 'custom', + path: ['monthOffset'], + message: 'text_1789042962229esmw981wwyt', + }) + } + }) diff --git a/src/components/settings/dunnings/PreviewCampaignEmailDrawer.tsx b/src/components/settings/dunnings/PreviewCampaignEmailDrawer.tsx index cd17b9ed71..3a30f012a7 100644 --- a/src/components/settings/dunnings/PreviewCampaignEmailDrawer.tsx +++ b/src/components/settings/dunnings/PreviewCampaignEmailDrawer.tsx @@ -114,7 +114,6 @@ export const PreviewCampaignEmailDrawer = forwardRef { }) const form = useAppForm({ - defaultValues: { - documentLocale: '', - }, + defaultValues: EDIT_BILLING_ENTITY_DOCUMENT_LOCALE_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { onDynamic: editBillingEntityDocumentLocaleValidationSchema, }, onSubmit: async ({ value }) => { + const data = dataRef.current + + if (!data) return + await updateDocumentLocale({ variables: { input: { - id: dataRef.current?.id as string, + id: data.id, billingConfiguration: { documentLocale: value.documentLocale, }, diff --git a/src/components/settings/invoices/__tests__/EditBillingEntityDocumentLocaleDialog.test.tsx b/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/__tests__/EditBillingEntityDocumentLocaleDialog.test.tsx similarity index 100% rename from src/components/settings/invoices/__tests__/EditBillingEntityDocumentLocaleDialog.test.tsx rename to src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/__tests__/EditBillingEntityDocumentLocaleDialog.test.tsx diff --git a/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/types.ts b/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/types.ts new file mode 100644 index 0000000000..456e07d931 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +import { editBillingEntityDocumentLocaleValidationSchema } from './validationSchema' + +export type EditBillingEntityDocumentLocaleFormValues = z.infer< + typeof editBillingEntityDocumentLocaleValidationSchema +> + +export type OpenEditBillingEntityDocumentLocaleDialogProps = { + id: string + documentLocale: string +} + +export const EDIT_BILLING_ENTITY_DOCUMENT_LOCALE_INITIAL_VALUES: EditBillingEntityDocumentLocaleFormValues = + { + documentLocale: '', + } diff --git a/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/validationSchema.ts b/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/validationSchema.ts new file mode 100644 index 0000000000..e083526818 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityDocumentLocaleDialog/validationSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const editBillingEntityDocumentLocaleValidationSchema = z.object({ + // The combobox emits `undefined` when cleared, so cover both the missing + // (invalid_type) and empty-string cases with the same "required" message. + documentLocale: z + .string({ message: 'text_624ea7c29103fd010732ab7d' }) + .min(1, { message: 'text_624ea7c29103fd010732ab7d' }), +}) diff --git a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog.tsx b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/EditBillingEntityGracePeriodDialog.tsx similarity index 88% rename from src/components/settings/invoices/EditBillingEntityGracePeriodDialog.tsx rename to src/components/settings/invoices/EditBillingEntityGracePeriodDialog/EditBillingEntityGracePeriodDialog.tsx index 85676e9a3f..234644f851 100644 --- a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog.tsx +++ b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/EditBillingEntityGracePeriodDialog.tsx @@ -2,7 +2,6 @@ import { gql } from '@apollo/client' import InputAdornment from '@mui/material/InputAdornment' import { revalidateLogic } from '@tanstack/react-form' import { useRef } from 'react' -import { z } from 'zod' import { useFormDialog } from '~/components/dialogs/FormDialog' import { DialogResult } from '~/components/dialogs/types' @@ -12,6 +11,13 @@ import { useUpdateBillingEntityGracePeriodMutation } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' +import { + EDIT_BILLING_ENTITY_GRACE_PERIOD_INITIAL_VALUES, + EditBillingEntityGracePeriodDialogData, +} from './types' +import { getInitialGracePeriod } from './utils' +import { editBillingEntityGracePeriodValidationSchema } from './validationSchema' + gql` mutation updateBillingEntityGracePeriod($input: UpdateBillingEntityInput!) { updateBillingEntity(input: $input) { @@ -24,20 +30,8 @@ gql` } ` -const editBillingEntityGracePeriodValidationSchema = z.object({ - invoiceGracePeriod: z.union([ - z.number().max(365, { message: 'text_63bed78ae69de9cad5c348e4' }), - z.literal(''), - ]), -}) - const EDIT_BILLING_ENTITY_GRACE_PERIOD_FORM_ID = 'edit-billing-entity-grace-period-form' -type EditBillingEntityGracePeriodDialogData = { - id: string - invoiceGracePeriod: number -} - export const useEditBillingEntityGracePeriodDialog = () => { const formDialog = useFormDialog() const { translate } = useInternationalization() @@ -58,18 +52,20 @@ export const useEditBillingEntityGracePeriodDialog = () => { }) const form = useAppForm({ - defaultValues: { - invoiceGracePeriod: '' as number | '', - }, + defaultValues: EDIT_BILLING_ENTITY_GRACE_PERIOD_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { onDynamic: editBillingEntityGracePeriodValidationSchema, }, onSubmit: async ({ value }) => { + const data = dataRef.current + + if (!data) return + await updateBillingEntityGracePeriod({ variables: { input: { - id: dataRef.current?.id as string, + id: data.id, billingConfiguration: { invoiceGracePeriod: Number(value.invoiceGracePeriod) || 0, }, @@ -93,7 +89,7 @@ export const useEditBillingEntityGracePeriodDialog = () => { const openEditBillingEntityGracePeriodDialog = (data: EditBillingEntityGracePeriodDialogData) => { dataRef.current = data form.reset() - form.setFieldValue('invoiceGracePeriod', (data.invoiceGracePeriod ?? '') as number | '') + form.setFieldValue('invoiceGracePeriod', getInitialGracePeriod(data)) formDialog .open({ diff --git a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/__tests__/EditBillingEntityGracePeriodDialog.test.tsx b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/__tests__/EditBillingEntityGracePeriodDialog.test.tsx new file mode 100644 index 0000000000..e232092b8d --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/__tests__/EditBillingEntityGracePeriodDialog.test.tsx @@ -0,0 +1,198 @@ +import NiceModal from '@ebay/nice-modal-react' +import { cleanup, screen, waitFor } from '@testing-library/react' +import userEvent, { UserEvent } from '@testing-library/user-event' + +import { + FORM_DIALOG_CANCEL_BUTTON_TEST_ID, + FORM_DIALOG_NAME, + FORM_DIALOG_TEST_ID, +} from '~/components/dialogs/const' +import FormDialog from '~/components/dialogs/FormDialog' +import { useEditBillingEntityGracePeriodDialog } from '~/components/settings/invoices/EditBillingEntityGracePeriodDialog/EditBillingEntityGracePeriodDialog' +import { UpdateBillingEntityGracePeriodDocument } from '~/generated/graphql' +import { render, TestMocksType } from '~/test-utils' + +NiceModal.register(FORM_DIALOG_NAME, FormDialog) + +const BILLING_ENTITY_ID = 'billing-entity-123' +const BILLING_CONFIGURATION_ID = 'billing-configuration-1' +const OPEN_BUTTON_TEST_ID = 'open-grace-period-dialog' + +const mockAddToast = jest.fn() + +jest.mock('~/core/apolloClient', () => ({ + ...jest.requireActual('~/core/apolloClient'), + addToast: (params: unknown) => mockAddToast(params), +})) + +const buildMutationMock = (invoiceGracePeriod: number) => ({ + request: { + query: UpdateBillingEntityGracePeriodDocument, + variables: { + input: { + id: BILLING_ENTITY_ID, + billingConfiguration: { invoiceGracePeriod }, + }, + }, + }, + result: { + data: { + updateBillingEntity: { + id: BILLING_ENTITY_ID, + billingConfiguration: { + id: BILLING_CONFIGURATION_ID, + invoiceGracePeriod, + }, + }, + }, + }, +}) + +const Harness = ({ invoiceGracePeriod }: { invoiceGracePeriod: number }) => { + const { openEditBillingEntityGracePeriodDialog } = useEditBillingEntityGracePeriodDialog() + + return ( + + ) +} + +const getSubmitButton = () => document.querySelector('button[type="submit"]') as HTMLButtonElement + +const setGracePeriod = async (user: UserEvent, value: string): Promise => { + await user.clear(screen.getByRole('textbox')) + await user.paste(value) +} + +async function prepare({ + invoiceGracePeriod = 10, + mocks = [], +}: { + invoiceGracePeriod?: number + mocks?: TestMocksType +} = {}) { + const user = userEvent.setup() + + render( + + + , + { mocks }, + ) + + await user.click(screen.getByTestId(OPEN_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(screen.getByTestId(FORM_DIALOG_TEST_ID)).toBeInTheDocument() + }) + + return { user } +} + +describe('useEditBillingEntityGracePeriodDialog', () => { + afterEach(() => { + cleanup() + jest.clearAllMocks() + }) + + describe('GIVEN the dialog is opened', () => { + describe('WHEN the billing entity already has a grace period', () => { + it('THEN should seed the input with it', async () => { + await prepare({ invoiceGracePeriod: 42 }) + + expect(screen.getByRole('textbox')).toHaveValue('42') + }) + }) + + describe('WHEN rendered', () => { + it('THEN should focus the input and render both actions', async () => { + await prepare() + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveFocus() + }) + expect(screen.getByTestId(FORM_DIALOG_CANCEL_BUTTON_TEST_ID)).toBeInTheDocument() + expect(getSubmitButton()).toBeInTheDocument() + }) + }) + }) + + describe('GIVEN the form validation', () => { + describe('WHEN the grace period exceeds 365 days', () => { + it('THEN should keep the submit button disabled', async () => { + const { user } = await prepare() + + await setGracePeriod(user, '400') + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(getSubmitButton()).toBeDisabled() + }) + }) + }) + }) + + describe('GIVEN the form submission', () => { + describe('WHEN the user submits a valid grace period', () => { + it('THEN should call the mutation with it and show a success toast', async () => { + const { user } = await prepare({ + invoiceGracePeriod: 10, + mocks: [buildMutationMock(30)], + }) + + await setGracePeriod(user, '30') + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }), + ) + }) + }) + + it('THEN should close the dialog on success', async () => { + const { user } = await prepare({ + invoiceGracePeriod: 10, + mocks: [buildMutationMock(10)], + }) + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(screen.queryByTestId(FORM_DIALOG_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + }) + + describe('GIVEN the dialog actions', () => { + describe('WHEN the dialog is cancelled and reopened', () => { + it('THEN should reset the form to the seeded value', async () => { + const { user } = await prepare({ invoiceGracePeriod: 7 }) + + await setGracePeriod(user, '21') + expect(screen.getByRole('textbox')).toHaveValue('21') + + await user.click(screen.getByTestId(FORM_DIALOG_CANCEL_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(screen.queryByTestId(FORM_DIALOG_TEST_ID)).not.toBeInTheDocument() + }) + + await user.click(screen.getByTestId(OPEN_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('7') + }) + }) + }) + }) +}) diff --git a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/types.ts b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/types.ts new file mode 100644 index 0000000000..b08295cf59 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +import { editBillingEntityGracePeriodValidationSchema } from './validationSchema' + +export type EditBillingEntityGracePeriodFormValues = z.infer< + typeof editBillingEntityGracePeriodValidationSchema +> + +export type EditBillingEntityGracePeriodDialogData = { + id: string + invoiceGracePeriod: number +} + +export const EDIT_BILLING_ENTITY_GRACE_PERIOD_INITIAL_VALUES: EditBillingEntityGracePeriodFormValues = + { + invoiceGracePeriod: '', + } diff --git a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/utils.ts b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/utils.ts new file mode 100644 index 0000000000..2d958ead69 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/utils.ts @@ -0,0 +1,8 @@ +import { + EditBillingEntityGracePeriodDialogData, + EditBillingEntityGracePeriodFormValues, +} from './types' + +export const getInitialGracePeriod = ( + data: EditBillingEntityGracePeriodDialogData, +): EditBillingEntityGracePeriodFormValues['invoiceGracePeriod'] => data.invoiceGracePeriod ?? '' diff --git a/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/validationSchema.ts b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/validationSchema.ts new file mode 100644 index 0000000000..e409d5b9fe --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityGracePeriodDialog/validationSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const editBillingEntityGracePeriodValidationSchema = z.object({ + invoiceGracePeriod: z.union([ + z.number().max(365, { message: 'text_63bed78ae69de9cad5c348e4' }), + z.literal(''), + ]), +}) diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx similarity index 96% rename from src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx rename to src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx index 4d1c2b9d41..7053b94545 100644 --- a/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx +++ b/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/EditBillingEntityInvoiceIssuingDatePolicyDialog.tsx @@ -12,12 +12,13 @@ import { addToast } from '~/core/apolloClient' import { BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum, BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum, - EditBillingEntityInvoiceIssuingDatePolicyDialogFragment, useUpdateBillingEntityInvoiceIssuingDatePolicyMutation, } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' +import { EditBillingEntityInvoiceIssuingDatePolicyDialogData } from './types' + gql` fragment EditBillingEntityInvoiceIssuingDatePolicyDialog on BillingEntity { id @@ -39,10 +40,6 @@ gql` export const EDIT_BILLING_ENTITY_INVOICE_ISSUING_DATE_POLICY_FORM_ID = 'edit-billing-entity-invoice-issuing-date-policy-form' -type EditBillingEntityInvoiceIssuingDatePolicyDialogData = { - billingEntity: EditBillingEntityInvoiceIssuingDatePolicyDialogFragment -} - export const useEditBillingEntityInvoiceIssuingDatePolicyDialog = () => { const formDialog = useFormDialog() const { translate } = useInternationalization() diff --git a/src/components/settings/invoices/__tests__/EditBillingEntityInvoiceIssuingDatePolicyDialog.test.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/__tests__/EditBillingEntityInvoiceIssuingDatePolicyDialog.test.tsx similarity index 100% rename from src/components/settings/invoices/__tests__/EditBillingEntityInvoiceIssuingDatePolicyDialog.test.tsx rename to src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/__tests__/EditBillingEntityInvoiceIssuingDatePolicyDialog.test.tsx diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/types.ts b/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/types.ts new file mode 100644 index 0000000000..c8455fb10f --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceIssuingDatePolicyDialog/types.ts @@ -0,0 +1,5 @@ +import { EditBillingEntityInvoiceIssuingDatePolicyDialogFragment } from '~/generated/graphql' + +export type EditBillingEntityInvoiceIssuingDatePolicyDialogData = { + billingEntity: EditBillingEntityInvoiceIssuingDatePolicyDialogFragment +} diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/EditBillingEntityInvoiceNumberingDialog.tsx similarity index 88% rename from src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog.tsx rename to src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/EditBillingEntityInvoiceNumberingDialog.tsx index 40e5077e32..fb6e7ec7c5 100644 --- a/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog.tsx +++ b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/EditBillingEntityInvoiceNumberingDialog.tsx @@ -1,7 +1,6 @@ import { gql } from '@apollo/client' import { revalidateLogic } from '@tanstack/react-form' import { useRef } from 'react' -import { z } from 'zod' import { Chip } from '~/components/designSystem/Chip' import { Typography } from '~/components/designSystem/Typography' @@ -18,10 +17,12 @@ import { import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' -const DynamicPrefixTranslationLookup: Record = { - [BillingEntityDocumentNumberingEnum.PerCustomer]: 'text_6566f920a1d6c35693d6cce0', - [BillingEntityDocumentNumberingEnum.PerBillingEntity]: 'YYYYMM', -} +import { + DynamicPrefixTranslationLookup, + EDIT_BILLING_ENTITY_INVOICE_NUMBERING_INITIAL_VALUES, + EditBillingEntityInvoiceNumberingDialogData, +} from './types' +import { editBillingEntityInvoiceNumberingValidationSchema } from './validationSchema' gql` fragment EditBillingEntityInvoiceNumberingDialog on BillingEntity { @@ -38,17 +39,6 @@ gql` } ` -const editBillingEntityInvoiceNumberingValidationSchema = z.object({ - documentNumbering: z.enum(BillingEntityDocumentNumberingEnum), - documentNumberPrefix: z.string().min(1).max(10, { message: 'text_6566f920a1d6c35693d6cd77' }), -}) - -type EditBillingEntityInvoiceNumberingDialogData = { - id: string - documentNumbering?: BillingEntityDocumentNumberingEnum | null - documentNumberPrefix?: string | null -} - const FORM_ID = 'edit-billing-entity-invoice-numbering-form' export const useEditBillingEntityInvoiceNumberingDialog = () => { @@ -71,19 +61,20 @@ export const useEditBillingEntityInvoiceNumberingDialog = () => { }) const form = useAppForm({ - defaultValues: { - documentNumbering: BillingEntityDocumentNumberingEnum.PerCustomer, - documentNumberPrefix: '', - }, + defaultValues: EDIT_BILLING_ENTITY_INVOICE_NUMBERING_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { onDynamic: editBillingEntityInvoiceNumberingValidationSchema, }, onSubmit: async ({ value }) => { + const data = dataRef.current + + if (!data) return + const result = await updateBillingEntityInvoiceNumbering({ variables: { input: { - id: dataRef.current?.id as string, + id: data.id, documentNumbering: value.documentNumbering, documentNumberPrefix: value.documentNumberPrefix, }, diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/__tests__/EditBillingEntityInvoiceNumberingDialog.test.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/__tests__/EditBillingEntityInvoiceNumberingDialog.test.tsx new file mode 100644 index 0000000000..75cbe5fd9b --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/__tests__/EditBillingEntityInvoiceNumberingDialog.test.tsx @@ -0,0 +1,239 @@ +import NiceModal from '@ebay/nice-modal-react' +import { cleanup, screen, waitFor } from '@testing-library/react' +import userEvent, { UserEvent } from '@testing-library/user-event' + +import { + FORM_DIALOG_CANCEL_BUTTON_TEST_ID, + FORM_DIALOG_NAME, + FORM_DIALOG_TEST_ID, +} from '~/components/dialogs/const' +import FormDialog from '~/components/dialogs/FormDialog' +import { useEditBillingEntityInvoiceNumberingDialog } from '~/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/EditBillingEntityInvoiceNumberingDialog' +import { + BillingEntityDocumentNumberingEnum, + UpdateBillingEntityInvoiceNumberingDocument, +} from '~/generated/graphql' +import { render, TestMocksType } from '~/test-utils' + +import { EditBillingEntityInvoiceNumberingDialogData } from '../types' + +NiceModal.register(FORM_DIALOG_NAME, FormDialog) + +const BILLING_ENTITY_ID = 'billing-entity-123' +const OPEN_BUTTON_TEST_ID = 'open-invoice-numbering-dialog' + +const mockAddToast = jest.fn() + +jest.mock('~/core/apolloClient', () => ({ + ...jest.requireActual('~/core/apolloClient'), + addToast: (params: unknown) => mockAddToast(params), +})) + +const buildMutationMock = ( + documentNumbering: BillingEntityDocumentNumberingEnum, + documentNumberPrefix: string, +) => ({ + request: { + query: UpdateBillingEntityInvoiceNumberingDocument, + variables: { + input: { id: BILLING_ENTITY_ID, documentNumbering, documentNumberPrefix }, + }, + }, + result: { + data: { + updateBillingEntity: { + id: BILLING_ENTITY_ID, + documentNumbering, + documentNumberPrefix, + }, + }, + }, +}) + +const Harness = ({ data }: { data: EditBillingEntityInvoiceNumberingDialogData }) => { + const { openEditBillingEntityInvoiceNumberingDialog } = + useEditBillingEntityInvoiceNumberingDialog() + + return ( + + ) +} + +const getSubmitButton = () => document.querySelector('button[type="submit"]') as HTMLButtonElement +const getPrefixInput = () => screen.getAllByRole('textbox')[0] + +// Pasted in one event: the preview subscription re-renders on every keystroke, and +// `user.type` loses characters to the element it remounts underneath. +const setPrefix = async (user: UserEvent, value: string): Promise => { + await user.clear(getPrefixInput()) + + if (value) await user.paste(value) +} + +// The design-system radio is read-only and renders its selection as a filled circle. +const isNumberingChecked = (value: BillingEntityDocumentNumberingEnum): boolean => + !!document + .querySelector(`input[type="radio"][value="${value}"]`) + ?.closest('label') + ?.querySelector('circle[r="4"]') + +async function prepare({ + data = { + id: BILLING_ENTITY_ID, + documentNumbering: BillingEntityDocumentNumberingEnum.PerCustomer, + documentNumberPrefix: 'ACME', + }, + mocks = [], +}: { + data?: EditBillingEntityInvoiceNumberingDialogData + mocks?: TestMocksType +} = {}) { + const user = userEvent.setup() + + render( + + + , + { mocks }, + ) + + await user.click(screen.getByTestId(OPEN_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(screen.getByTestId(FORM_DIALOG_TEST_ID)).toBeInTheDocument() + }) + + return { user } +} + +describe('useEditBillingEntityInvoiceNumberingDialog', () => { + afterEach(() => { + cleanup() + jest.clearAllMocks() + }) + + describe('GIVEN the dialog is opened', () => { + describe('WHEN the billing entity carries a numbering setting', () => { + it('THEN should seed the prefix and select the matching numbering option', async () => { + await prepare({ + data: { + id: BILLING_ENTITY_ID, + documentNumbering: BillingEntityDocumentNumberingEnum.PerBillingEntity, + documentNumberPrefix: 'LAGO', + }, + }) + + expect(getPrefixInput()).toHaveValue('LAGO') + expect(isNumberingChecked(BillingEntityDocumentNumberingEnum.PerCustomer)).toBe(false) + expect(isNumberingChecked(BillingEntityDocumentNumberingEnum.PerBillingEntity)).toBe(true) + }) + }) + + describe('WHEN the billing entity carries no numbering setting', () => { + it('THEN should fall back to the per-customer default and an empty prefix', async () => { + await prepare({ data: { id: BILLING_ENTITY_ID } }) + + expect(getPrefixInput()).toHaveValue('') + expect(isNumberingChecked(BillingEntityDocumentNumberingEnum.PerCustomer)).toBe(true) + }) + }) + }) + + describe('GIVEN the form validation', () => { + describe('WHEN the prefix is longer than 10 characters', () => { + it('THEN should keep the submit button disabled', async () => { + const { user } = await prepare() + + await setPrefix(user, 'ABCDEFGHIJK') + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(getSubmitButton()).toBeDisabled() + }) + }) + }) + + describe('WHEN the prefix is emptied', () => { + it('THEN should keep the submit button disabled', async () => { + const { user } = await prepare() + + await setPrefix(user, '') + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(getSubmitButton()).toBeDisabled() + }) + }) + }) + }) + + describe('GIVEN the form submission', () => { + describe('WHEN the user submits a valid prefix', () => { + it('THEN should call the mutation with it and show a success toast', async () => { + const { user } = await prepare({ + mocks: [buildMutationMock(BillingEntityDocumentNumberingEnum.PerCustomer, 'NEW')], + }) + + await setPrefix(user, 'NEW') + + await waitFor(() => { + expect(getSubmitButton()).not.toBeDisabled() + }) + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(mockAddToast).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'success' }), + ) + }) + }) + }) + + describe('WHEN the user switches the numbering option', () => { + it('THEN should submit the newly selected one', async () => { + const { user } = await prepare({ + mocks: [buildMutationMock(BillingEntityDocumentNumberingEnum.PerBillingEntity, 'ACME')], + }) + + await user.click(screen.getAllByRole('radio')[1]) + + await user.click(getSubmitButton()) + + await waitFor(() => { + expect(screen.queryByTestId(FORM_DIALOG_TEST_ID)).not.toBeInTheDocument() + }) + }) + }) + }) + + describe('GIVEN the dialog actions', () => { + describe('WHEN the dialog is cancelled and reopened', () => { + it('THEN should reset the form to the seeded values', async () => { + const { user } = await prepare() + + await setPrefix(user, 'CHANGED') + expect(getPrefixInput()).toHaveValue('CHANGED') + + await user.click(screen.getByTestId(FORM_DIALOG_CANCEL_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(screen.queryByTestId(FORM_DIALOG_TEST_ID)).not.toBeInTheDocument() + }) + + await user.click(screen.getByTestId(OPEN_BUTTON_TEST_ID)) + + await waitFor(() => { + expect(getPrefixInput()).toHaveValue('ACME') + }) + }) + }) + }) +}) diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/types.ts b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/types.ts new file mode 100644 index 0000000000..82c2a61340 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/types.ts @@ -0,0 +1,26 @@ +import { z } from 'zod' + +import { BillingEntityDocumentNumberingEnum } from '~/generated/graphql' + +import { editBillingEntityInvoiceNumberingValidationSchema } from './validationSchema' + +export type EditBillingEntityInvoiceNumberingFormValues = z.infer< + typeof editBillingEntityInvoiceNumberingValidationSchema +> + +export type EditBillingEntityInvoiceNumberingDialogData = { + id: string + documentNumbering?: BillingEntityDocumentNumberingEnum | null + documentNumberPrefix?: string | null +} + +export const EDIT_BILLING_ENTITY_INVOICE_NUMBERING_INITIAL_VALUES: EditBillingEntityInvoiceNumberingFormValues = + { + documentNumbering: BillingEntityDocumentNumberingEnum.PerCustomer, + documentNumberPrefix: '', + } + +export const DynamicPrefixTranslationLookup: Record = { + [BillingEntityDocumentNumberingEnum.PerCustomer]: 'text_6566f920a1d6c35693d6cce0', + [BillingEntityDocumentNumberingEnum.PerBillingEntity]: 'YYYYMM', +} diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/validationSchema.ts b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/validationSchema.ts new file mode 100644 index 0000000000..1836fb0578 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceNumberingDialog/validationSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +import { BillingEntityDocumentNumberingEnum } from '~/generated/graphql' + +export const editBillingEntityInvoiceNumberingValidationSchema = z.object({ + documentNumbering: z.enum(BillingEntityDocumentNumberingEnum), + documentNumberPrefix: z.string().min(1).max(10, { message: 'text_6566f920a1d6c35693d6cd77' }), +}) diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/EditBillingEntityInvoiceTemplateDialog.tsx similarity index 89% rename from src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog.tsx rename to src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/EditBillingEntityInvoiceTemplateDialog.tsx index 4b715c427b..9a1a01721e 100644 --- a/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog.tsx +++ b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/EditBillingEntityInvoiceTemplateDialog.tsx @@ -1,7 +1,6 @@ import { gql } from '@apollo/client' import { revalidateLogic } from '@tanstack/react-form' import { useRef } from 'react' -import { z } from 'zod' import { useFormDialog } from '~/components/dialogs/FormDialog' import { DialogResult } from '~/components/dialogs/types' @@ -11,7 +10,14 @@ import { useUpdateBillingEntityInvoiceTemplateMutation } from '~/generated/graph import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' -const MAX_CHAR_LIMIT = 600 +import { + EDIT_BILLING_ENTITY_INVOICE_TEMPLATE_INITIAL_VALUES, + EditBillingEntityInvoiceTemplateDialogData, +} from './types' +import { + editBillingEntityInvoiceTemplateValidationSchema, + INVOICE_FOOTER_MAX_CHAR_LIMIT, +} from './validationSchema' gql` fragment EditBillingEntityInvoiceTemplateDialog on BillingEntity { @@ -29,15 +35,6 @@ gql` } ` -const editBillingEntityInvoiceTemplateValidationSchema = z.object({ - invoiceFooter: z.string().max(MAX_CHAR_LIMIT, { message: 'text_62bb10ad2a10bd182d00203b' }), -}) - -type EditBillingEntityInvoiceTemplateDialogData = { - id: string - invoiceFooter: string -} - const FORM_ID = 'edit-billing-entity-invoice-template-form' export const useEditBillingEntityInvoiceTemplateDialog = () => { @@ -60,18 +57,20 @@ export const useEditBillingEntityInvoiceTemplateDialog = () => { }) const form = useAppForm({ - defaultValues: { - invoiceFooter: '', - }, + defaultValues: EDIT_BILLING_ENTITY_INVOICE_TEMPLATE_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { onDynamic: editBillingEntityInvoiceTemplateValidationSchema, }, onSubmit: async ({ value }) => { + const data = dataRef.current + + if (!data) return + const result = await updateBillingEntityInvoiceTemplate({ variables: { input: { - id: dataRef.current?.id as string, + id: data.id, billingConfiguration: { invoiceFooter: value.invoiceFooter, }, @@ -120,7 +119,7 @@ export const useEditBillingEntityInvoiceTemplateDialog = () => {
{translate('text_62bc52dd8536260acc9eb762')}
- {field.state.value.length}/{MAX_CHAR_LIMIT} + {field.state.value.length}/{INVOICE_FOOTER_MAX_CHAR_LIMIT}
} diff --git a/src/components/settings/invoices/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx similarity index 99% rename from src/components/settings/invoices/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx rename to src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx index 6ad612d76c..c5ea3f6e2e 100644 --- a/src/components/settings/invoices/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx +++ b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/__tests__/EditBillingEntityInvoiceTemplateDialog.test.tsx @@ -8,7 +8,7 @@ import { FORM_DIALOG_TEST_ID, } from '~/components/dialogs/const' import FormDialog from '~/components/dialogs/FormDialog' -import { useEditBillingEntityInvoiceTemplateDialog } from '~/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog' +import { useEditBillingEntityInvoiceTemplateDialog } from '~/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/EditBillingEntityInvoiceTemplateDialog' import { UpdateBillingEntityInvoiceTemplateDocument } from '~/generated/graphql' import { render, TestMocksType } from '~/test-utils' diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/types.ts b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/types.ts new file mode 100644 index 0000000000..1a76d25c41 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +import { editBillingEntityInvoiceTemplateValidationSchema } from './validationSchema' + +export type EditBillingEntityInvoiceTemplateFormValues = z.infer< + typeof editBillingEntityInvoiceTemplateValidationSchema +> + +export type EditBillingEntityInvoiceTemplateDialogData = { + id: string + invoiceFooter: string +} + +export const EDIT_BILLING_ENTITY_INVOICE_TEMPLATE_INITIAL_VALUES: EditBillingEntityInvoiceTemplateFormValues = + { + invoiceFooter: '', + } diff --git a/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/validationSchema.ts b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/validationSchema.ts new file mode 100644 index 0000000000..3bb26006f0 --- /dev/null +++ b/src/components/settings/invoices/EditBillingEntityInvoiceTemplateDialog/validationSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const INVOICE_FOOTER_MAX_CHAR_LIMIT = 600 + +export const editBillingEntityInvoiceTemplateValidationSchema = z.object({ + invoiceFooter: z + .string() + .max(INVOICE_FOOTER_MAX_CHAR_LIMIT, { message: 'text_62bb10ad2a10bd182d00203b' }), +}) diff --git a/src/components/settings/invoices/EditDefaultCurrencyDialog.tsx b/src/components/settings/invoices/EditDefaultCurrencyDialog/EditDefaultCurrencyDialog.tsx similarity index 83% rename from src/components/settings/invoices/EditDefaultCurrencyDialog.tsx rename to src/components/settings/invoices/EditDefaultCurrencyDialog/EditDefaultCurrencyDialog.tsx index a41d7cdf96..d66349385f 100644 --- a/src/components/settings/invoices/EditDefaultCurrencyDialog.tsx +++ b/src/components/settings/invoices/EditDefaultCurrencyDialog/EditDefaultCurrencyDialog.tsx @@ -1,19 +1,21 @@ import { gql } from '@apollo/client' import { revalidateLogic } from '@tanstack/react-form' import { useRef } from 'react' -import { z } from 'zod' import { useFormDialog } from '~/components/dialogs/FormDialog' import { DialogResult } from '~/components/dialogs/types' import { addToast } from '~/core/apolloClient' -import { - CurrencyEnum, - EditBillingEntityDefaultCurrencyForDialogFragment, - useUpdateBillingEntityDefaultCurrencyMutation, -} from '~/generated/graphql' +import { CurrencyEnum, useUpdateBillingEntityDefaultCurrencyMutation } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' +import { + EDIT_DEFAULT_CURRENCY_DIALOG_CURRENCY_FIELD_TEST_ID, + EDIT_DEFAULT_CURRENCY_DIALOG_SUBMIT_BUTTON_TEST_ID, +} from './dataTestConstants' +import { EDIT_DEFAULT_CURRENCY_INITIAL_VALUES, EditDefaultCurrencyDialogData } from './types' +import { editDefaultCurrencyValidationSchema } from './validationSchema' + gql` fragment EditBillingEntityDefaultCurrencyForDialog on BillingEntity { id @@ -30,23 +32,6 @@ gql` export const EDIT_DEFAULT_CURRENCY_FORM_ID = 'edit-default-currency-form' -const EDIT_DEFAULT_CURRENCY_DIALOG_SUBMIT_BUTTON_TEST_ID = - 'edit-default-currency-dialog-submit-button' -const EDIT_DEFAULT_CURRENCY_DIALOG_CURRENCY_FIELD_TEST_ID = - 'edit-default-currency-dialog-currency-field' - -const editDefaultCurrencyValidationSchema = z.object({ - defaultCurrency: z.enum(CurrencyEnum), -}) - -const initialValues = { - defaultCurrency: CurrencyEnum.Usd, -} - -type EditDefaultCurrencyDialogData = { - billingEntity?: EditBillingEntityDefaultCurrencyForDialogFragment | null -} - export const useEditDefaultCurrencyDialog = () => { const formDialog = useFormDialog() const { translate } = useInternationalization() @@ -66,16 +51,20 @@ export const useEditDefaultCurrencyDialog = () => { }) const form = useAppForm({ - defaultValues: initialValues, + defaultValues: EDIT_DEFAULT_CURRENCY_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { onDynamic: editDefaultCurrencyValidationSchema, }, onSubmit: async ({ value }) => { + const billingEntity = dataRef.current?.billingEntity + + if (!billingEntity) return + const result = await updateBillingEntity({ variables: { input: { - id: dataRef.current?.billingEntity?.id as string, + id: billingEntity.id, ...value, }, }, diff --git a/src/components/settings/invoices/__tests__/EditDefaultCurrencyDialog.test.tsx b/src/components/settings/invoices/EditDefaultCurrencyDialog/__tests__/EditDefaultCurrencyDialog.test.tsx similarity index 100% rename from src/components/settings/invoices/__tests__/EditDefaultCurrencyDialog.test.tsx rename to src/components/settings/invoices/EditDefaultCurrencyDialog/__tests__/EditDefaultCurrencyDialog.test.tsx diff --git a/src/components/settings/invoices/EditDefaultCurrencyDialog/dataTestConstants.ts b/src/components/settings/invoices/EditDefaultCurrencyDialog/dataTestConstants.ts new file mode 100644 index 0000000000..25ecf59386 --- /dev/null +++ b/src/components/settings/invoices/EditDefaultCurrencyDialog/dataTestConstants.ts @@ -0,0 +1,4 @@ +export const EDIT_DEFAULT_CURRENCY_DIALOG_SUBMIT_BUTTON_TEST_ID = + 'edit-default-currency-dialog-submit-button' +export const EDIT_DEFAULT_CURRENCY_DIALOG_CURRENCY_FIELD_TEST_ID = + 'edit-default-currency-dialog-currency-field' diff --git a/src/components/settings/invoices/EditDefaultCurrencyDialog/types.ts b/src/components/settings/invoices/EditDefaultCurrencyDialog/types.ts new file mode 100644 index 0000000000..ee742a08db --- /dev/null +++ b/src/components/settings/invoices/EditDefaultCurrencyDialog/types.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +import { + CurrencyEnum, + EditBillingEntityDefaultCurrencyForDialogFragment, +} from '~/generated/graphql' + +import { editDefaultCurrencyValidationSchema } from './validationSchema' + +export type EditDefaultCurrencyFormValues = z.infer + +export type EditDefaultCurrencyDialogData = { + billingEntity?: EditBillingEntityDefaultCurrencyForDialogFragment | null +} + +export const EDIT_DEFAULT_CURRENCY_INITIAL_VALUES: EditDefaultCurrencyFormValues = { + defaultCurrency: CurrencyEnum.Usd, +} diff --git a/src/components/settings/invoices/EditDefaultCurrencyDialog/validationSchema.ts b/src/components/settings/invoices/EditDefaultCurrencyDialog/validationSchema.ts new file mode 100644 index 0000000000..640d61decb --- /dev/null +++ b/src/components/settings/invoices/EditDefaultCurrencyDialog/validationSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +import { CurrencyEnum } from '~/generated/graphql' + +export const editDefaultCurrencyValidationSchema = z.object({ + defaultCurrency: z.enum(CurrencyEnum), +}) diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog.tsx b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/EditFinalizeZeroAmountInvoiceDialog.tsx similarity index 73% rename from src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog.tsx rename to src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/EditFinalizeZeroAmountInvoiceDialog.tsx index c7940a66e9..87f3750e31 100644 --- a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog.tsx +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/EditFinalizeZeroAmountInvoiceDialog.tsx @@ -1,7 +1,6 @@ import { gql } from '@apollo/client' import { revalidateLogic } from '@tanstack/react-form' import { useRef } from 'react' -import { z } from 'zod' import { useFormDialog } from '~/components/dialogs/FormDialog' import { DialogResult } from '~/components/dialogs/types' @@ -11,15 +10,20 @@ import { MUI_INPUT_BASE_ROOT_CLASSNAME, } from '~/core/constants/form' import { - EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment, - EditCustomerFinalizeZeroAmountInvoiceForDialogFragment, - FinalizeZeroAmountInvoiceEnum, useUpdateBillingEntityFinalizeZeroAmountInvoiceMutation, useUpdateCustomerFinalizeZeroAmountInvoiceMutation, } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useAppForm } from '~/hooks/forms/useAppform' +import { EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_SUBMIT_BUTTON_TEST_ID } from './dataTestConstants' +import { + EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_INITIAL_VALUES, + EditFinalizeZeroAmountInvoiceDialogData, +} from './types' +import { getInitialValue, isCustomerEntity, toFinalizeZeroAmountInvoiceEnum } from './utils' +import { editFinalizeZeroAmountInvoiceValidationSchema } from './validationSchema' + gql` fragment EditCustomerFinalizeZeroAmountInvoiceForDialog on Customer { id @@ -50,39 +54,6 @@ gql` const EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_FORM_ID = 'edit-finalize-zero-amount-invoice-form' -export const EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_SUBMIT_BUTTON_TEST_ID = - 'edit-finalize-zero-amount-invoice-submit-button' - -const validationSchema = z.object({ - finalizeZeroAmountInvoice: z.string().min(1), -}) - -type FormValues = z.infer - -const initialValues: FormValues = { - finalizeZeroAmountInvoice: '', -} - -type EditFinalizeZeroAmountInvoiceDialogData = { - entity?: - | EditCustomerFinalizeZeroAmountInvoiceForDialogFragment - | EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment - | null - finalizeZeroAmountInvoice?: FinalizeZeroAmountInvoiceEnum | boolean | null -} - -const getInitialValue = (data: EditFinalizeZeroAmountInvoiceDialogData): string => { - const isCustomer = data.entity?.__typename === 'Customer' - - if (isCustomer) { - if (data.finalizeZeroAmountInvoice === FinalizeZeroAmountInvoiceEnum.Inherit) return '' - - return (data.finalizeZeroAmountInvoice as FinalizeZeroAmountInvoiceEnum | undefined) ?? '' - } - - return data.finalizeZeroAmountInvoice?.toString() ?? '' -} - export const useEditFinalizeZeroAmountInvoiceDialog = () => { const formDialog = useFormDialog() const { translate } = useInternationalization() @@ -117,29 +88,30 @@ export const useEditFinalizeZeroAmountInvoiceDialog = () => { }) const form = useAppForm({ - defaultValues: initialValues, + defaultValues: EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_INITIAL_VALUES, validationLogic: revalidateLogic(), validators: { - onDynamic: validationSchema, + onDynamic: editFinalizeZeroAmountInvoiceValidationSchema, }, onSubmit: async ({ value }) => { - const data = dataRef.current + const entity = dataRef.current?.entity - if (!data?.entity || !value.finalizeZeroAmountInvoice) return + if (!entity || !value.finalizeZeroAmountInvoice) return - const isCustomer = data.entity.__typename === 'Customer' + if (isCustomerEntity(entity)) { + const finalizeZeroAmountInvoice = toFinalizeZeroAmountInvoiceEnum( + value.finalizeZeroAmountInvoice, + ) - if (isCustomer) { - const customer = data.entity as EditCustomerFinalizeZeroAmountInvoiceForDialogFragment + if (!finalizeZeroAmountInvoice) return await updateCustomerFinalizeZeroAmountInvoice({ variables: { input: { - id: customer.id, - externalId: customer.externalId, - name: customer.name || '', - finalizeZeroAmountInvoice: - value.finalizeZeroAmountInvoice as FinalizeZeroAmountInvoiceEnum, + id: entity.id, + externalId: entity.externalId, + name: entity.name || '', + finalizeZeroAmountInvoice, }, }, }) @@ -147,13 +119,10 @@ export const useEditFinalizeZeroAmountInvoiceDialog = () => { return } - const billingEntity = - data.entity as EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment - await updateBillingEntityFinalizeZeroAmountInvoice({ variables: { input: { - id: billingEntity.id, + id: entity.id, finalizeZeroAmountInvoice: value.finalizeZeroAmountInvoice === 'true', }, }, @@ -179,8 +148,7 @@ export const useEditFinalizeZeroAmountInvoiceDialog = () => { form.reset() form.setFieldValue('finalizeZeroAmountInvoice', getInitialValue(data)) - const isCustomer = data.entity?.__typename === 'Customer' - const comboBoxData = isCustomer + const comboBoxData = isCustomerEntity(data.entity) ? [ { value: 'finalize', label: translate('text_1725549671287ancbf00edxx') }, { value: 'skip', label: translate('text_1725549671288zkq9sr0y46l') }, diff --git a/src/components/settings/invoices/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx similarity index 96% rename from src/components/settings/invoices/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx rename to src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx index 84bd363982..cfbcdbd6db 100644 --- a/src/components/settings/invoices/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/EditFinalizeZeroAmountInvoiceDialog.test.tsx @@ -12,10 +12,8 @@ import { } from '~/generated/graphql' import { render } from '~/test-utils' -import { - EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_SUBMIT_BUTTON_TEST_ID, - useEditFinalizeZeroAmountInvoiceDialog, -} from '../EditFinalizeZeroAmountInvoiceDialog' +import { EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_SUBMIT_BUTTON_TEST_ID } from '../dataTestConstants' +import { useEditFinalizeZeroAmountInvoiceDialog } from '../EditFinalizeZeroAmountInvoiceDialog' NiceModal.register(FORM_DIALOG_NAME, FormDialog) diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/utils.test.ts b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/utils.test.ts new file mode 100644 index 0000000000..c6504ebfa1 --- /dev/null +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/__tests__/utils.test.ts @@ -0,0 +1,83 @@ +import { + EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment, + EditCustomerFinalizeZeroAmountInvoiceForDialogFragment, + FinalizeZeroAmountInvoiceEnum, +} from '~/generated/graphql' + +import { getInitialValue, isCustomerEntity, toFinalizeZeroAmountInvoiceEnum } from '../utils' + +const customer: EditCustomerFinalizeZeroAmountInvoiceForDialogFragment = { + __typename: 'Customer', + id: 'customer-1', + externalId: 'customer-external-1', + name: 'Acme', + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum.Finalize, +} + +const billingEntity: EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment = { + __typename: 'BillingEntity', + id: 'billing-entity-1', + finalizeZeroAmountInvoice: true, +} + +describe('EditFinalizeZeroAmountInvoiceDialog utils', () => { + describe('isCustomerEntity', () => { + it('GIVEN a customer WHEN narrowing THEN it returns true', () => { + expect(isCustomerEntity(customer)).toBe(true) + }) + + it('GIVEN a billing entity or no entity WHEN narrowing THEN it returns false', () => { + expect(isCustomerEntity(billingEntity)).toBe(false) + expect(isCustomerEntity(null)).toBe(false) + expect(isCustomerEntity(undefined)).toBe(false) + }) + }) + + describe('getInitialValue', () => { + it('GIVEN a customer inheriting WHEN seeding THEN the field opens empty', () => { + expect( + getInitialValue({ + entity: customer, + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum.Inherit, + }), + ).toBe('') + }) + + it('GIVEN a customer with its own value WHEN seeding THEN the enum value is used', () => { + expect( + getInitialValue({ + entity: customer, + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum.Skip, + }), + ).toBe(FinalizeZeroAmountInvoiceEnum.Skip) + }) + + it('GIVEN a billing entity WHEN seeding THEN the boolean is stringified', () => { + expect(getInitialValue({ entity: billingEntity, finalizeZeroAmountInvoice: false })).toBe( + 'false', + ) + expect(getInitialValue({ entity: billingEntity, finalizeZeroAmountInvoice: true })).toBe( + 'true', + ) + }) + + it('GIVEN nothing set WHEN seeding THEN the field opens empty', () => { + expect(getInitialValue({ entity: billingEntity, finalizeZeroAmountInvoice: null })).toBe('') + expect(getInitialValue({})).toBe('') + }) + }) + + describe('toFinalizeZeroAmountInvoiceEnum', () => { + it('GIVEN a known option WHEN converting THEN the enum member is returned', () => { + expect(toFinalizeZeroAmountInvoiceEnum('finalize')).toBe( + FinalizeZeroAmountInvoiceEnum.Finalize, + ) + expect(toFinalizeZeroAmountInvoiceEnum('skip')).toBe(FinalizeZeroAmountInvoiceEnum.Skip) + }) + + it('GIVEN a value outside the enum WHEN converting THEN it returns undefined', () => { + expect(toFinalizeZeroAmountInvoiceEnum('true')).toBeUndefined() + expect(toFinalizeZeroAmountInvoiceEnum('')).toBeUndefined() + }) + }) +}) diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/dataTestConstants.ts b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/dataTestConstants.ts new file mode 100644 index 0000000000..835772ef0b --- /dev/null +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/dataTestConstants.ts @@ -0,0 +1,2 @@ +export const EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_SUBMIT_BUTTON_TEST_ID = + 'edit-finalize-zero-amount-invoice-submit-button' diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/types.ts b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/types.ts new file mode 100644 index 0000000000..74abfc20d5 --- /dev/null +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +import { + EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment, + EditCustomerFinalizeZeroAmountInvoiceForDialogFragment, + FinalizeZeroAmountInvoiceEnum, +} from '~/generated/graphql' + +import { editFinalizeZeroAmountInvoiceValidationSchema } from './validationSchema' + +export type FinalizeZeroAmountInvoiceEntity = + | EditCustomerFinalizeZeroAmountInvoiceForDialogFragment + | EditBillingEntityFinalizeZeroAmountInvoiceForDialogFragment + +export type EditFinalizeZeroAmountInvoiceDialogData = { + entity?: FinalizeZeroAmountInvoiceEntity | null + finalizeZeroAmountInvoice?: FinalizeZeroAmountInvoiceEnum | boolean | null +} + +export type EditFinalizeZeroAmountInvoiceFormValues = z.infer< + typeof editFinalizeZeroAmountInvoiceValidationSchema +> + +export const EDIT_FINALIZE_ZERO_AMOUNT_INVOICE_INITIAL_VALUES: EditFinalizeZeroAmountInvoiceFormValues = + { + finalizeZeroAmountInvoice: '', + } diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/utils.ts b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/utils.ts new file mode 100644 index 0000000000..7492ad1dea --- /dev/null +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/utils.ts @@ -0,0 +1,28 @@ +import { + EditCustomerFinalizeZeroAmountInvoiceForDialogFragment, + FinalizeZeroAmountInvoiceEnum, +} from '~/generated/graphql' + +import { EditFinalizeZeroAmountInvoiceDialogData, FinalizeZeroAmountInvoiceEntity } from './types' + +export const isCustomerEntity = ( + entity: FinalizeZeroAmountInvoiceEntity | null | undefined, +): entity is EditCustomerFinalizeZeroAmountInvoiceForDialogFragment => + entity?.__typename === 'Customer' + +/** A customer inheriting from its billing entity opens on an empty field, not on `inherit`. */ +export const getInitialValue = (data: EditFinalizeZeroAmountInvoiceDialogData): string => { + if ( + isCustomerEntity(data.entity) && + data.finalizeZeroAmountInvoice === FinalizeZeroAmountInvoiceEnum.Inherit + ) { + return '' + } + + return data.finalizeZeroAmountInvoice?.toString() ?? '' +} + +export const toFinalizeZeroAmountInvoiceEnum = ( + value: string, +): FinalizeZeroAmountInvoiceEnum | undefined => + Object.values(FinalizeZeroAmountInvoiceEnum).find((option) => option === value) diff --git a/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/validationSchema.ts b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/validationSchema.ts new file mode 100644 index 0000000000..0a056ab0ad --- /dev/null +++ b/src/components/settings/invoices/EditFinalizeZeroAmountInvoiceDialog/validationSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod' + +export const editFinalizeZeroAmountInvoiceValidationSchema = z.object({ + finalizeZeroAmountInvoice: z.string().min(1), +}) diff --git a/src/components/settings/invoices/EditNetPaymentTermDialog.tsx b/src/components/settings/invoices/EditNetPaymentTermDialog.tsx deleted file mode 100644 index bd0595c44b..0000000000 --- a/src/components/settings/invoices/EditNetPaymentTermDialog.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { gql } from '@apollo/client' -import InputAdornment from '@mui/material/InputAdornment' -import { revalidateLogic, useStore } from '@tanstack/react-form' -import { useRef } from 'react' -import { z } from 'zod' - -import { useFormDialog } from '~/components/dialogs/FormDialog' -import { DialogResult } from '~/components/dialogs/types' -import { addToast } from '~/core/apolloClient' -import { - MUI_INPUT_BASE_ROOT_CLASSNAME, - NET_PAYMENT_TERM_INPUT_CLASSNAME, -} from '~/core/constants/form' -import { NetPaymentTermValuesEnum } from '~/core/constants/paymentTerm' -import { - EditBillingEntityNetPaymentTermForDialogFragment, - EditCustomerNetPaymentTermForDialogFragment, - useUpdateBillingEntityNetPaymentTermMutation, - useUpdateCustomerNetPaymentTermMutation, -} from '~/generated/graphql' -import { useInternationalization } from '~/hooks/core/useInternationalization' -import { useAppForm, withForm } from '~/hooks/forms/useAppform' - -const EDIT_NET_PAYMENT_TERM_FORM_ID = 'edit-net-payment-term-form' - -gql` - fragment EditCustomerNetPaymentTermForDialog on Customer { - id - externalId - name - netPaymentTerm - } - - fragment EditBillingEntityNetPaymentTermForDialog on BillingEntity { - id - netPaymentTerm - } - - mutation updateCustomerNetPaymentTerm($input: UpdateCustomerInput!) { - updateCustomer(input: $input) { - id - ...EditCustomerNetPaymentTermForDialog - } - } - - mutation updateBillingEntityNetPaymentTerm($input: UpdateBillingEntityInput!) { - updateBillingEntity(input: $input) { - id - ...EditBillingEntityNetPaymentTermForDialog - } - } -` - -enum NetPaymentTermModelTypesEnum { - Customer = 'Customer', - BillingEntity = 'BillingEntity', -} - -type FormValues = { - netPaymentTerm: string - customPeriod: number | '' -} - -const initialValues: FormValues = { - netPaymentTerm: '', - customPeriod: '', -} - -const validationSchema = z - .object({ - netPaymentTerm: z.enum(NetPaymentTermValuesEnum), - customPeriod: z.union([z.number(), z.literal('')]), - }) - .refine( - (data) => - data.netPaymentTerm !== NetPaymentTermValuesEnum.custom || - (typeof data.customPeriod === 'number' && data.customPeriod >= 0), - { path: ['customPeriod'], message: '' }, - ) - -type ModelData = - EditCustomerNetPaymentTermForDialogFragment | EditBillingEntityNetPaymentTermForDialogFragment - -const getInitialFormValues = (model: ModelData | null): FormValues => { - if (!model || typeof model.netPaymentTerm !== 'number') { - return initialValues - } - - const isCustomValue = !Object.values(NetPaymentTermValuesEnum).includes( - String(model.netPaymentTerm) as unknown as NetPaymentTermValuesEnum, - ) - - return { - netPaymentTerm: isCustomValue ? NetPaymentTermValuesEnum.custom : String(model.netPaymentTerm), - customPeriod: isCustomValue ? model.netPaymentTerm : '', - } -} - -const DialogContent = withForm({ - defaultValues: initialValues, - render: function Render({ form }) { - const { translate } = useInternationalization() - const netPaymentTerm = useStore(form.store, (state) => state.values.netPaymentTerm) - - return ( -
- - {(field) => ( - - )} - - - {netPaymentTerm === NetPaymentTermValuesEnum.custom && ( - - {(field) => ( - - {translate('text_638dc196fb209d551f3d814d')} - - ), - }} - /> - )} - - )} -
- ) - }, -}) - -type EditNetPaymentTermDialogData = { - model: ModelData | null | undefined - description: string -} - -export const useEditNetPaymentTermDialog = () => { - const formDialog = useFormDialog() - const { translate } = useInternationalization() - const modelRef = useRef(null) - const isEditRef = useRef(false) - const successRef = useRef(false) - - const [updateBillingEntity] = useUpdateBillingEntityNetPaymentTermMutation({ - onCompleted(res) { - if (res?.updateBillingEntity) { - successRef.current = true - addToast({ - severity: 'success', - translateKey: isEditRef.current - ? 'text_64c7a89b6c67eb6c98898181' - : 'text_64c7a89b6c67eb6c98898350', - }) - } - }, - refetchQueries: ['getBillingEntitySettings'], - }) - const [updateCustomer] = useUpdateCustomerNetPaymentTermMutation({ - onCompleted(res) { - if (res?.updateCustomer) { - successRef.current = true - addToast({ - severity: 'success', - translateKey: isEditRef.current - ? 'text_64c7a89b6c67eb6c98898181' - : 'text_64c7a89b6c67eb6c98898350', - }) - } - }, - }) - - const form = useAppForm({ - defaultValues: initialValues, - validationLogic: revalidateLogic(), - validators: { onDynamic: validationSchema }, - onSubmit: async ({ value }) => { - const model = modelRef.current - - if (!model) return - - const localInput = { - netPaymentTerm: - value.netPaymentTerm === NetPaymentTermValuesEnum.custom - ? Number(value.customPeriod) - : Number(value.netPaymentTerm), - } - - if (model.__typename === NetPaymentTermModelTypesEnum.Customer) { - await updateCustomer({ - variables: { - input: { - id: model.id, - externalId: model.externalId, - name: model.name || '', - ...localInput, - }, - }, - }) - } else if (model.__typename === NetPaymentTermModelTypesEnum.BillingEntity) { - await updateBillingEntity({ - variables: { - input: { - ...localInput, - id: model.id, - }, - }, - }) - } - }, - }) - - const handleSubmit = async (): Promise => { - successRef.current = false - await form.handleSubmit() - - if (!successRef.current) { - throw new Error('Submit failed') - } - - return { reason: 'success' } - } - - const openEditNetPaymentTermDialog = ({ model, description }: EditNetPaymentTermDialogData) => { - modelRef.current = model ?? null - isEditRef.current = typeof model?.netPaymentTerm === 'number' - - const seeded = getInitialFormValues(model ?? null) - - form.reset() - form.setFieldValue('netPaymentTerm', seeded.netPaymentTerm) - form.setFieldValue('customPeriod', seeded.customPeriod) - - formDialog - .open({ - title: translate( - isEditRef.current ? 'text_64c7a89b6c67eb6c988981e0' : 'text_64c7a89b6c67eb6c9889822d', - ), - description, - closeOnError: false, - onEntered: (container) => { - container - .querySelector( - `.${NET_PAYMENT_TERM_INPUT_CLASSNAME} .${MUI_INPUT_BASE_ROOT_CLASSNAME}`, - ) - ?.click() - }, - children: , - mainAction: ( - - {translate('text_17432414198706rdwf76ek3u')} - - ), - form: { - id: EDIT_NET_PAYMENT_TERM_FORM_ID, - submit: handleSubmit, - }, - }) - .then((response) => { - if (response.reason === 'close') { - form.reset() - modelRef.current = null - isEditRef.current = false - } - }) - } - - return { openEditNetPaymentTermDialog } -} diff --git a/src/components/settings/invoices/EditPaymentTermDialog/EditPaymentTermDialog.tsx b/src/components/settings/invoices/EditPaymentTermDialog/EditPaymentTermDialog.tsx new file mode 100644 index 0000000000..3798def63c --- /dev/null +++ b/src/components/settings/invoices/EditPaymentTermDialog/EditPaymentTermDialog.tsx @@ -0,0 +1,227 @@ +import { gql } from '@apollo/client' +import { revalidateLogic } from '@tanstack/react-form' +import { useRef } from 'react' + +import { useFormDialog } from '~/components/dialogs/FormDialog' +import { DialogResult } from '~/components/dialogs/types' +import { EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID } from '~/components/paymentTerms/dataTestConstants' +import { PaymentTermFormContent } from '~/components/paymentTerms/PaymentTermFormContent' +import { PAYMENT_TERM_FORM_DEFAULT_VALUES } from '~/components/paymentTerms/types' +import { isConcreteTermType } from '~/components/paymentTerms/utils' +import { paymentTermFormSchema } from '~/components/paymentTerms/validationSchema' +import { addToast } from '~/core/apolloClient' +import { MUI_INPUT_BASE_ROOT_CLASSNAME, PAYMENT_TERM_INPUT_CLASSNAME } from '~/core/constants/form' +import { buildPaymentTermInput } from '~/core/utils/paymentTerm' +import { + useUpdateBillingEntityPaymentTermMutation, + useUpdateCustomerPaymentTermMutation, +} from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useAppForm } from '~/hooks/forms/useAppform' + +import { EditPaymentTermDialogData, ModelData, PaymentTermModelTypesEnum } from './types' +import { getInheritedFrom, getInitialFormValues, isCustomer } from './utils' + +const EDIT_PAYMENT_TERM_FORM_ID = 'edit-payment-term-form' + +gql` + fragment EditCustomerPaymentTermForDialog on Customer { + id + externalId + name + paymentTerm { + termType + days + dayOfMonth + monthOffset + } + billingEntity { + id + paymentTerm { + termType + days + dayOfMonth + monthOffset + } + } + } + + fragment EditBillingEntityPaymentTermForDialog on BillingEntity { + id + paymentTerm { + termType + days + dayOfMonth + monthOffset + } + } + + mutation updateCustomerPaymentTerm($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + id + ...EditCustomerPaymentTermForDialog + } + } + + mutation updateBillingEntityPaymentTerm($input: UpdateBillingEntityInput!) { + updateBillingEntity(input: $input) { + id + ...EditBillingEntityPaymentTermForDialog + } + } +` + +export const useEditPaymentTermDialog = () => { + const formDialog = useFormDialog() + const { translate } = useInternationalization() + const modelRef = useRef(null) + const isEditRef = useRef(false) + const isClearingRef = useRef(false) + const successRef = useRef(false) + + const onCompletedToast = () => { + successRef.current = true + + if (isClearingRef.current) { + return addToast({ severity: 'success', translateKey: 'text_1787603382163macepxq32tf' }) + } + + addToast({ + severity: 'success', + translateKey: isEditRef.current + ? 'text_1787603382163qy0ie341vhf' + : 'text_17876033821633lw8i7rs3et', + }) + } + + const [updateBillingEntity] = useUpdateBillingEntityPaymentTermMutation({ + onCompleted(res) { + if (res?.updateBillingEntity) onCompletedToast() + }, + refetchQueries: ['getBillingEntitySettings'], + }) + const [updateCustomer] = useUpdateCustomerPaymentTermMutation({ + onCompleted(res) { + if (res?.updateCustomer) onCompletedToast() + }, + }) + + const form = useAppForm({ + defaultValues: PAYMENT_TERM_FORM_DEFAULT_VALUES, + validationLogic: revalidateLogic(), + validators: { onDynamic: paymentTermFormSchema }, + onSubmit: async ({ value }) => { + const model = modelRef.current + + if (!model) return + + // The inherit choice sends `null`, which clears the override so the level above wins + // again — the same payload the delete dialog sends. + // + // Otherwise only the chosen type's own fields are sent — the API rejects the others. + // Never send `netPaymentTerm` alongside: the API mirrors the legacy alias itself. + const paymentTerm = isConcreteTermType(value.termType) + ? buildPaymentTermInput({ + termType: value.termType, + days: value.days === '' ? 0 : Number(value.days), + dayOfMonth: value.dayOfMonth === '' ? null : Number(value.dayOfMonth), + monthOffset: value.monthOffset === '' ? null : Number(value.monthOffset), + }) + : null + + // Inheriting a level that carries no term of its own changes nothing. Closing here + // keeps the Add flow from clearing an absent term and reporting a deletion. + if (!paymentTerm && !isEditRef.current) { + successRef.current = true + + return + } + + isClearingRef.current = !paymentTerm + + if (isCustomer(model)) { + await updateCustomer({ + variables: { + input: { + id: model.id, + // UpdateCustomerInput requires both, even when only the term changes. + externalId: model.externalId, + name: model.name || '', + paymentTerm, + }, + }, + }) + } else if (model.__typename === PaymentTermModelTypesEnum.BillingEntity) { + await updateBillingEntity({ variables: { input: { id: model.id, paymentTerm } } }) + } + }, + }) + + const handleSubmit = async (): Promise => { + successRef.current = false + await form.handleSubmit() + + if (!successRef.current) { + throw new Error('Submit failed') + } + + return { reason: 'success' } + } + + const openEditPaymentTermDialog = ({ model }: EditPaymentTermDialogData) => { + modelRef.current = model ?? null + isEditRef.current = !!model?.paymentTerm + isClearingRef.current = false + + const inheritedFrom = getInheritedFrom(model ?? null) + const seeded = getInitialFormValues(model ?? null, !!inheritedFrom) + + form.reset() + form.setFieldValue('termType', seeded.termType) + form.setFieldValue('days', seeded.days) + form.setFieldValue('dayOfMonth', seeded.dayOfMonth) + form.setFieldValue('monthOffset', seeded.monthOffset) + + formDialog + .open({ + title: translate( + isEditRef.current ? 'text_1787603382163c3k425lvr34' : 'text_1787603382163dshrngxccpy', + ), + description: translate('text_1787603382163te0ngv2t7cv'), + closeOnError: false, + onEntered: (container) => { + container + .querySelector( + `.${PAYMENT_TERM_INPUT_CLASSNAME} .${MUI_INPUT_BASE_ROOT_CLASSNAME}`, + ) + ?.click() + }, + children: ( +
+ +
+ ), + mainAction: ( + + + {translate('text_17432414198706rdwf76ek3u')} + + + ), + form: { + id: EDIT_PAYMENT_TERM_FORM_ID, + submit: handleSubmit, + }, + }) + .then((response) => { + if (response.reason === 'close') { + form.reset() + modelRef.current = null + isEditRef.current = false + isClearingRef.current = false + } + }) + } + + return { openEditPaymentTermDialog } +} diff --git a/src/components/settings/invoices/EditPaymentTermDialog/__tests__/EditPaymentTermDialog.test.tsx b/src/components/settings/invoices/EditPaymentTermDialog/__tests__/EditPaymentTermDialog.test.tsx new file mode 100644 index 0000000000..12144051fb --- /dev/null +++ b/src/components/settings/invoices/EditPaymentTermDialog/__tests__/EditPaymentTermDialog.test.tsx @@ -0,0 +1,349 @@ +import NiceModal from '@ebay/nice-modal-react' +import { act, cleanup, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ReactNode } from 'react' + +import { + DIALOG_TITLE_TEST_ID, + FORM_DIALOG_NAME, + FORM_DIALOG_TEST_ID, +} from '~/components/dialogs/const' +import FormDialog from '~/components/dialogs/FormDialog' +import { EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID } from '~/components/paymentTerms/dataTestConstants' +import { PAYMENT_TERM_INHERIT } from '~/core/constants/paymentTerm' +import { + EditBillingEntityPaymentTermForDialogFragment, + EditCustomerPaymentTermForDialogFragment, + PaymentTermInput, + PaymentTermTypeEnum, +} from '~/generated/graphql' +import { render } from '~/test-utils' + +import { useEditPaymentTermDialog } from '../EditPaymentTermDialog' + +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 56, + getVirtualItems: () => + Array.from({ length: count }, (_, i) => ({ + index: i, + key: String(i), + start: i * 56, + size: 56, + })), + scrollToIndex: jest.fn(), + measureElement: jest.fn(), + }), +})) + +NiceModal.register(FORM_DIALOG_NAME, FormDialog) + +jest.mock('~/hooks/core/useInternationalization', () => ({ + useInternationalization: () => ({ + translate: (key: string) => key, + }), +})) + +const mockAddToast = jest.fn() + +jest.mock('~/core/apolloClient', () => ({ + ...jest.requireActual('~/core/apolloClient'), + addToast: (...args: unknown[]) => mockAddToast(...args), +})) + +const mockUpdateCustomer = jest.fn() +const mockUpdateBillingEntity = jest.fn() + +let customerCallbacks: { onCompleted?: (data: unknown) => void } = {} +let billingEntityCallbacks: { onCompleted?: (data: unknown) => void } = {} + +jest.mock('~/generated/graphql', () => ({ + ...jest.requireActual('~/generated/graphql'), + useUpdateCustomerPaymentTermMutation: (options: typeof customerCallbacks) => { + customerCallbacks = options + return [mockUpdateCustomer, { loading: false }] + }, + useUpdateBillingEntityPaymentTermMutation: (options: typeof billingEntityCallbacks) => { + billingEntityCallbacks = options + return [mockUpdateBillingEntity, { loading: false }] + }, +})) + +/** `translate` is stubbed to the key, so the inherit row renders as its own key. */ +const INHERIT_OPTION_LABEL_KEY = 'text_1728374331992d2alok9y3kr' + +const CUSTOMER_ID = 'customer-1' +const CUSTOMER_EXTERNAL_ID = 'customer-external-1' +const BILLING_ENTITY_ID = 'billing-entity-1' + +const term = ( + overrides: Partial & { + termType: PaymentTermTypeEnum + }, +) => ({ + __typename: 'PaymentTerm' as const, + days: null, + dayOfMonth: null, + monthOffset: null, + ...overrides, +}) + +const buildCustomer = ( + paymentTerm: EditCustomerPaymentTermForDialogFragment['paymentTerm'] = null, + billingEntityTerm: EditCustomerPaymentTermForDialogFragment['paymentTerm'] = term({ + termType: PaymentTermTypeEnum.Net, + days: 30, + }), +): EditCustomerPaymentTermForDialogFragment => ({ + __typename: 'Customer', + id: CUSTOMER_ID, + externalId: CUSTOMER_EXTERNAL_ID, + name: 'Acme', + paymentTerm, + billingEntity: { + __typename: 'BillingEntity', + id: BILLING_ENTITY_ID, + paymentTerm: billingEntityTerm, + }, +}) + +const buildBillingEntity = ( + paymentTerm: EditBillingEntityPaymentTermForDialogFragment['paymentTerm'] = null, +): EditBillingEntityPaymentTermForDialogFragment => ({ + __typename: 'BillingEntity', + id: BILLING_ENTITY_ID, + paymentTerm, +}) + +type OpenArgs = Parameters< + ReturnType['openEditPaymentTermDialog'] +>[0] + +function TestComponent({ openArgs }: { openArgs: OpenArgs }): ReactNode { + const { openEditPaymentTermDialog } = useEditPaymentTermDialog() + + return ( + + ) +} + +async function renderAndOpenDialog(openArgs: OpenArgs): Promise { + await act(() => + render( + + + , + ), + ) + + await act(async () => { + screen.getByTestId('open-dialog').click() + }) + + await waitFor(() => { + expect(screen.getByTestId(DIALOG_TITLE_TEST_ID)).toBeInTheDocument() + }) +} + +const submit = () => userEvent.click(screen.getByTestId(EDIT_PAYMENT_TERM_SUBMIT_BUTTON_TEST_ID)) + +const resolveCustomerMutation = () => + mockUpdateCustomer.mockImplementation(async () => { + customerCallbacks.onCompleted?.({ updateCustomer: { id: CUSTOMER_ID } }) + + return { data: { updateCustomer: { id: CUSTOMER_ID } } } + }) + +const resolveBillingEntityMutation = () => + mockUpdateBillingEntity.mockImplementation(async () => { + billingEntityCallbacks.onCompleted?.({ updateBillingEntity: { id: BILLING_ENTITY_ID } }) + + return { data: { updateBillingEntity: { id: BILLING_ENTITY_ID } } } + }) + +/** + * `combobox-item-