diff --git a/src/components/creditNote/CreditNoteFormCalculation.tsx b/src/components/creditNote/CreditNoteFormCalculation.tsx index 6c1537dbf..ef86150da 100644 --- a/src/components/creditNote/CreditNoteFormCalculation.tsx +++ b/src/components/creditNote/CreditNoteFormCalculation.tsx @@ -1,28 +1,34 @@ import { gql } from '@apollo/client' import { InputAdornment } from '@mui/material' import { FormikProps } from 'formik' +import { debounce } from 'lodash' import _get from 'lodash/get' -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import styled, { css } from 'styled-components' +import { array, number, object, string } from 'yup' -import { Alert, Button, Icon, Tooltip, Typography } from '~/components/designSystem' +import { Alert, Button, Icon, Skeleton, Tooltip, Typography } from '~/components/designSystem' import { AmountInputField, ComboBox, ComboBoxField } from '~/components/form' import { getCurrencySymbol, intlFormatNumber } from '~/core/formats/intlFormatNumber' import { deserializeAmount, getCurrencyPrecision } from '~/core/serializers/serializeAmount' import { - CreditNoteFormFragment, + CreditNoteItemInput, CurrencyEnum, + InvoiceForCreditNoteFormCalculationFragment, InvoicePaymentStatusTypeEnum, LagoApiError, + useCreditNoteEstimateLazyQuery, } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' +import { DEBOUNCE_SEARCH_MS } from '~/hooks/useDebouncedSearch' import { theme } from '~/styles' import { CreditNoteForm, CreditTypeEnum, PayBackErrorEnum } from './types' -import { creditNoteFormCalculationCalculation } from './utils' + +const LOADING_VALUE_SKELETON_WIDTH = 90 gql` - fragment CreditNoteForm on Invoice { + fragment InvoiceForCreditNoteFormCalculation on Invoice { id couponsAmountCents paymentStatus @@ -43,66 +49,186 @@ gql` } } } + + query creditNoteEstimate($invoiceId: ID!, $items: [CreditNoteItemInput!]!) { + creditNoteEstimate(invoiceId: $invoiceId, items: $items) { + appliedTaxes { + taxCode + taxName + taxRate + amountCents + tax { + id + } + } + couponsAdjustmentAmountCents + currency + items { + amountCents + fee { + id + } + } + maxCreditableAmountCents + maxRefundableAmountCents + subTotalExcludingTaxesAmountCents + taxesAmountCents + taxesRate + } + } ` interface CreditNoteFormCalculationProps { - invoice?: CreditNoteFormFragment + invoice?: InvoiceForCreditNoteFormCalculationFragment formikProps: FormikProps> + feeForEstimate: CreditNoteItemInput[] | undefined + hasError: boolean + setPayBackValidation: Function } export const CreditNoteFormCalculation = ({ invoice, formikProps, + feeForEstimate, + hasError, + setPayBackValidation, }: CreditNoteFormCalculationProps) => { const { translate } = useInternationalization() const canOnlyCredit = invoice?.paymentStatus !== InvoicePaymentStatusTypeEnum.Succeeded - const hasFeeError = !!formikProps.errors.fees || !!formikProps.errors.addOnFee const currency = invoice?.currency || CurrencyEnum.Usd const currencyPrecision = getCurrencyPrecision(currency) const isLegacyInvoice = (invoice?.versionNumber || 0) < 3 - const { totalExcludedTax, taxes, proRatedCouponAmount } = useMemo( - () => - creditNoteFormCalculationCalculation({ - hasFeeError, - isLegacyInvoice, - addOnFee: formikProps.values.addOnFee, - couponsAmountCents: invoice?.couponsAmountCents, - fees: formikProps.values.fees, - feesAmountCents: invoice?.feesAmountCents, - }), - [ - formikProps.values.addOnFee, - formikProps.values.fees, - hasFeeError, - invoice?.couponsAmountCents, - invoice?.feesAmountCents, - isLegacyInvoice, - ] + const [getEstimate, { data: estimationData, error: estimationError, loading: estiationLoading }] = + useCreditNoteEstimateLazyQuery() + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedQuery = useCallback( + // We want to delay the query execution, to prevent sending a query on every key down + debounce(() => { + getEstimate && + invoice?.id && + feeForEstimate && + getEstimate({ + variables: { + invoiceId: invoice?.id, + items: feeForEstimate, + }, + }) + }, DEBOUNCE_SEARCH_MS), + [invoice?.id, feeForEstimate, getEstimate] ) - const totalTaxAmount = taxes?.size - ? Array.from(taxes.values()).reduce((acc, tax) => acc + tax.amount, 0) - : 0 - - const hasCreditOrCoupon = - (invoice?.creditableAmountCents || 0) > (invoice?.refundableAmountCents || 0) - const totalTaxIncluded = - !!totalExcludedTax && totalTaxAmount !== undefined - ? Number(totalExcludedTax + totalTaxAmount || 0) - : undefined + + useEffect(() => { + debouncedQuery() + + return () => { + debouncedQuery.cancel() + } + }, [getEstimate, debouncedQuery, feeForEstimate, formikProps.values.fees, invoice?.id]) + + const { + hasCreditOrCoupon, + maxCreditableAmountCents, + maxRefundableAmountCents, + proRatedCouponAmount, + taxes, + totalExcludedTax, + totalTaxIncluded, + } = useMemo(() => { + const isError = + estimationError || + estimationData?.creditNoteEstimate === null || + estimationData?.creditNoteEstimate === undefined + + return { + maxCreditableAmountCents: estimationData?.creditNoteEstimate.maxCreditableAmountCents || 0, + maxRefundableAmountCents: estimationData?.creditNoteEstimate.maxRefundableAmountCents || 0, + totalTaxIncluded: isError + ? 0 + : canOnlyCredit + ? deserializeAmount( + estimationData?.creditNoteEstimate.maxCreditableAmountCents || 0, + currency + ) + : deserializeAmount( + estimationData?.creditNoteEstimate.maxRefundableAmountCents || 0, + currency + ), + proRatedCouponAmount: isError + ? 0 + : deserializeAmount( + estimationData?.creditNoteEstimate?.couponsAdjustmentAmountCents || 0, + currency + ), + totalExcludedTax: isError + ? 0 + : deserializeAmount( + estimationData?.creditNoteEstimate?.subTotalExcludingTaxesAmountCents || 0, + currency + ), + taxes: isError + ? new Map() + : new Map( + estimationData?.creditNoteEstimate?.appliedTaxes?.map((tax) => [ + tax.taxCode, + { + label: tax.taxName, + taxRate: tax.taxRate, + amount: deserializeAmount(tax.amountCents || 0, currency), + }, + ]) + ), + hasCreditOrCoupon: isError + ? false + : (estimationData?.creditNoteEstimate?.maxCreditableAmountCents || 0) > + (estimationData?.creditNoteEstimate?.maxRefundableAmountCents || 0), + } + // IMPORTANT: not not add feeForEstimate to the dependencies, as it will cause an unexpected pre-reload of the prompted data, before BE has time to respond + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currency, estimationData?.creditNoteEstimate, estimationError]) + const payBack = formikProps.values.payBack || [] useEffect(() => { if (canOnlyCredit) { formikProps.setFieldValue('payBack', [ - { value: totalTaxIncluded?.toFixed(currencyPrecision), type: CreditTypeEnum.credit }, + { + value: Number(totalTaxIncluded || 0)?.toFixed(currencyPrecision), + type: CreditTypeEnum.credit, + }, ]) } else if (payBack.length < 2) { formikProps.setFieldValue( 'payBack.0.value', - !totalTaxIncluded ? undefined : totalTaxIncluded?.toFixed(currencyPrecision) + !totalTaxIncluded ? undefined : Number(totalTaxIncluded || 0)?.toFixed(currencyPrecision) ) } + + setPayBackValidation( + array().of( + object().shape({ + type: string().required(''), + value: number() + .required('') + .when('type', ([type]) => { + return type === CreditTypeEnum.refund + ? number().max( + deserializeAmount(maxRefundableAmountCents, currency) || 0, + PayBackErrorEnum.maxRefund + ) + : number().max( + deserializeAmount(maxCreditableAmountCents, currency) || 0, + PayBackErrorEnum.maxRefund + ) + }), + }) + ) + ) + formikProps.setTouched({ + payBack: true, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [totalTaxIncluded, canOnlyCredit]) @@ -120,38 +246,65 @@ export const CreditNoteFormCalculation = ({ - - - {intlFormatNumber(proRatedCouponAmount || 0, { - currency, - })} + {estiationLoading ? ( + + ) : !proRatedCouponAmount || hasError ? ( + '-' + ) : ( + `-${intlFormatNumber(proRatedCouponAmount || 0, { + currency, + })}` + )} )} {translate('text_636bedf292786b19d3398f02')} - {!totalExcludedTax - ? '-' - : intlFormatNumber(totalExcludedTax, { - currency, - })} + {estiationLoading ? ( + + ) : !totalExcludedTax || hasError ? ( + '-' + ) : ( + intlFormatNumber(totalExcludedTax, { + currency, + }) + )} {!totalExcludedTax ? ( {translate('text_636bedf292786b19d3398f06')} - - + + {estiationLoading ? ( + + ) : ( + '-' + )} + ) : !!taxes?.size ? ( Array.from(taxes.values()) .sort((a, b) => b.taxRate - a.taxRate) .map((tax) => ( - {tax.label} + + {tax.label} ({tax.taxRate}%) + - {intlFormatNumber(tax.amount, { - currency, - })} + {estiationLoading ? ( + + ) : !tax.amount || hasError ? ( + '-' + ) : ( + intlFormatNumber(tax.amount, { + currency, + }) + )} )) @@ -161,9 +314,15 @@ export const CreditNoteFormCalculation = ({ 'text_636bedf292786b19d3398f06' )} (0%)`} - {intlFormatNumber(0, { - currency, - })} + {estiationLoading ? ( + + ) : hasError ? ( + '-' + ) : ( + intlFormatNumber(0, { + currency, + }) + )} )} @@ -172,11 +331,15 @@ export const CreditNoteFormCalculation = ({ {translate('text_636bedf292786b19d3398f0a')} - {!totalTaxIncluded - ? '-' - : intlFormatNumber(totalTaxIncluded, { - currency, - })} + {estiationLoading ? ( + + ) : !totalTaxIncluded || hasError ? ( + '-' + ) : ( + intlFormatNumber(totalTaxIncluded, { + currency, + }) + )} {canOnlyCredit && ( @@ -185,11 +348,15 @@ export const CreditNoteFormCalculation = ({ {translate('text_636bedf292786b19d3398f0e')} - {totalTaxIncluded === undefined - ? '-' - : intlFormatNumber(totalTaxIncluded, { - currency, - })} + {estiationLoading ? ( + + ) : totalTaxIncluded === undefined || hasError ? ( + '-' + ) : ( + intlFormatNumber(totalTaxIncluded, { + currency, + }) + )} )} @@ -250,12 +417,9 @@ export const CreditNoteFormCalculation = ({ <> ) : ( - {!totalTaxIncluded - ? '-' - : intlFormatNumber(payBack[0]?.value || 0, { - currency, - })} + {estiationLoading ? ( + + ) : !payBack[0]?.value || hasError ? ( + '-' + ) : ( + intlFormatNumber(payBack[0]?.value || 0, { + currency, + }) + )} )} @@ -477,3 +645,7 @@ const InlineLabel = styled.div` height: 16px; } ` + +const ValueSkeleton = styled(Skeleton)` + width: ${LOADING_VALUE_SKELETON_WIDTH}px; +` diff --git a/src/components/creditNote/__tests__/CreditNoteFormCalculation.test.tsx b/src/components/creditNote/__tests__/CreditNoteFormCalculation.test.tsx deleted file mode 100644 index e92c0c036..000000000 --- a/src/components/creditNote/__tests__/CreditNoteFormCalculation.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { act, cleanup, screen } from '@testing-library/react' -import { useFormik } from 'formik' -import { object } from 'yup' - -import { chargeSchema } from '~/formValidation/chargeSchema' -import { render } from '~/test-utils' - -import { addOnFeeMock, feesMock, invoiceMock } from './fixtures' - -import { CreditNoteFormCalculation } from '../CreditNoteFormCalculation' -import { CreditNoteForm } from '../types' - -async function prepare() { - const CreditNoteFormCalculationMock = () => { - const formikProps = useFormik>({ - initialValues: { - description: undefined, - reason: undefined, - fees: feesMock, - addOnFee: addOnFeeMock, - payBack: [{ type: undefined, value: undefined }], - creditAmount: undefined, - refundAmount: undefined, - }, - validationSchema: object().shape({ - charges: chargeSchema, - }), - enableReinitialize: true, - validateOnMount: true, - onSubmit: () => {}, - }) - - return - } - - await act(() => { - render() - }) -} -describe('CreditNoteFormCalculation', () => { - afterEach(cleanup) - - it('renders with correct values', async () => { - await prepare() - - expect(screen.queryByTestId('prorated-coupon-amount')).toHaveTextContent('-€482.23') - expect(screen.queryByTestId('total-excluded-tax')).toHaveTextContent('€30,017.77') - expect(screen.queryByTestId('tax-10-amount')).toHaveTextContent('€1,033.12') - expect(screen.queryByTestId('tax-20-amount')).toHaveTextContent('€3,837.31') - expect(screen.queryByTestId('total-tax-included')).toHaveTextContent('€34,888.20') - }) -}) diff --git a/src/components/creditNote/__tests__/fixtures.ts b/src/components/creditNote/__tests__/fixtures.ts index 189b6b0bf..a5dd260b6 100644 --- a/src/components/creditNote/__tests__/fixtures.ts +++ b/src/components/creditNote/__tests__/fixtures.ts @@ -1,4 +1,9 @@ -import { CurrencyEnum, InvoicePaymentStatusTypeEnum } from '~/generated/graphql' +export const feeMockFormatedForEstimate = [ + { amountCents: 1000000, feeId: 'fee1' }, + { amountCents: 1900000, feeId: 'fee2' }, + { amountCents: 50000, feeId: 'fee4' }, + { amountCents: 50000, feeId: 'fee5' }, +] export const feesMock = { subscriptionId1: { @@ -101,7 +106,13 @@ export const feesMock = { }, }, } -export const feesMockAmountCents = '38010' + +export const addonMockFormatedForEstimate = [ + { + amountCents: 50000, + feeId: 'addOnFee1', + }, +] export const addOnFeeMock = [ { @@ -152,15 +163,15 @@ export const addOnFeeMock = [ }, ] -export const invoiceMock = { - id: '1234', - couponsAmountCents: '1010', - paymentStatus: InvoicePaymentStatusTypeEnum.Pending, - creditableAmountCents: '0', - refundableAmountCents: '0', - feesAmountCents: '62833', - currency: CurrencyEnum.Eur, - versionNumber: 3, - fees: addOnFeeMock, - invoiceSubscriptions: feesMock, -} +// export const invoiceMock = { +// id: '1234', +// couponsAmountCents: '1010', +// paymentStatus: InvoicePaymentStatusTypeEnum.Pending, +// creditableAmountCents: '0', +// refundableAmountCents: '0', +// feesAmountCents: '62833', +// currency: CurrencyEnum.Eur, +// versionNumber: 3, +// fees: addOnFeeMock, +// invoiceSubscriptions: feesMock, +// } diff --git a/src/components/creditNote/__tests__/utils.test.ts b/src/components/creditNote/__tests__/utils.test.ts index a67763f7d..27ec3b9be 100644 --- a/src/components/creditNote/__tests__/utils.test.ts +++ b/src/components/creditNote/__tests__/utils.test.ts @@ -1,153 +1,49 @@ -import { addOnFeeMock, feesMock, feesMockAmountCents } from './fixtures' +import { CurrencyEnum } from '~/generated/graphql' + +import { + addOnFeeMock, + addonMockFormatedForEstimate, + feeMockFormatedForEstimate, + feesMock, +} from './fixtures' import { creditNoteFormCalculationCalculation, CreditNoteFormCalculationCalculationProps, - mergeTaxMaps, - updateOrCreateTaxMap, } from '../utils' const prepare = ({ - hasFeeError = false, - isLegacyInvoice = false, - addOnFee = undefined, - couponsAmountCents = '0', + addonFees = undefined, fees = undefined, - feesAmountCents = '0', + hasError = false, + currency = CurrencyEnum.Eur, }: Partial = {}) => { - const { totalExcludedTax, taxes, proRatedCouponAmount } = creditNoteFormCalculationCalculation({ - hasFeeError, - isLegacyInvoice, - addOnFee, - couponsAmountCents, + const { feeForEstimate } = creditNoteFormCalculationCalculation({ + addonFees, fees, - feesAmountCents, + hasError, + currency, }) - return { totalExcludedTax, taxes, proRatedCouponAmount } + return { feeForEstimate } } describe('CreditNote utils', () => { describe('creditNoteFormCalculationCalculation()', () => { - it('should return object when error', () => { - const { totalExcludedTax, taxes, proRatedCouponAmount } = prepare({ - hasFeeError: true, - }) - - expect(totalExcludedTax).toBeUndefined() - expect(taxes).toEqual(new Map()) - expect(proRatedCouponAmount).toBeUndefined() - }) - - describe('without coupon', () => { - it('should return object correctly formated', () => { - const { totalExcludedTax, taxes, proRatedCouponAmount } = prepare({ - feesAmountCents: feesMockAmountCents, - fees: feesMock, - addOnFee: addOnFeeMock, - }) - - expect(totalExcludedTax).toBe(30500) - expect(proRatedCouponAmount).toBe(0) - expect(taxes).toEqual( - new Map([ - ['tax1tax1', { amount: 1100, label: 'Tax 1 (10%)', taxRate: 10 }], - ['tax2tax2', { amount: 4000, label: 'Tax 2 (20%)', taxRate: 20 }], - ]) - ) - }) - }) - - describe('with coupon', () => { - it('should return object correctly formated', () => { - const { totalExcludedTax, taxes, proRatedCouponAmount } = prepare({ - couponsAmountCents: '5000', - feesAmountCents: feesMockAmountCents, - fees: feesMock, - addOnFee: addOnFeeMock, - }) - - expect(totalExcludedTax).toBe(26553.670086819257) - expect(proRatedCouponAmount).toBe(3946.329913180742) - expect(taxes).toEqual( - new Map([ - ['tax1tax1', { amount: 911.8784530386741, label: 'Tax 1 (10%)', taxRate: 10 }], - ['tax2tax2', { amount: 3386.9771112865037, label: 'Tax 2 (20%)', taxRate: 20 }], - ]) - ) - }) - }) - }) - - describe('mergeTaxMaps()', () => { - it('return map 2 if map 1 is empty', () => { - const map1 = new Map() - const map2 = new Map([['tax1', { amount: 100, label: 'Tax 1', taxRate: 10 }]]) - - const mergedMap = mergeTaxMaps(map1, map2) - - expect(mergedMap).toEqual(map2) - }) + it('should return undefined for feeForEstimate if hasError is true', () => { + const { feeForEstimate } = prepare({ hasError: true }) - it('return map 1 if map 2 is empty', () => { - const map1 = new Map([['tax1', { amount: 100, label: 'Tax 1', taxRate: 10 }]]) - const map2 = new Map() - - const mergedMap = mergeTaxMaps(map1, map2) - - expect(mergedMap).toEqual(map1) + expect(feeForEstimate).toBeUndefined() }) + it('should return fees for estimate', () => { + const { feeForEstimate } = prepare({ fees: feesMock }) - it('properly merge two tax map', () => { - const map1 = new Map([['tax1', { amount: 100, label: 'Tax 1', taxRate: 10 }]]) - const map2 = new Map([['tax1', { amount: 200, label: 'Tax 1', taxRate: 10 }]]) - - const mergedMap = mergeTaxMaps(map1, map2) - - expect(mergedMap).toEqual(new Map([['tax1', { amount: 300, label: 'Tax 1', taxRate: 10 }]])) + expect(feeForEstimate).toEqual(feeMockFormatedForEstimate) }) - }) - - describe('updateOrCreateTaxMap()', () => { - it('returns the currentTaxMap if no feeAppliedTaxes', () => { - const currentTaxMap = new Map([['tax1', { amount: 100, label: 'Tax 1', taxRate: 10 }]]) - - const updatedTaxMap = updateOrCreateTaxMap(currentTaxMap, undefined) - - expect(updatedTaxMap).toEqual(currentTaxMap) - }) - - it('returns the currentTaxMap if none given', () => { - const feeAppliedTaxes = [ - { id: 'tax1', tax: { id: 'tax1', name: 'Tax 1', rate: 10 } }, - { id: 'tax2', tax: { id: 'tax2', name: 'Tax 2', rate: 20 } }, - ] - - const updatedTaxMap = updateOrCreateTaxMap(new Map(), 100, feeAppliedTaxes) - - expect(updatedTaxMap).toEqual( - new Map([ - ['tax1', { amount: 10, label: 'Tax 1 (10%)', taxRate: 10 }], - ['tax2', { amount: 20, label: 'Tax 2 (20%)', taxRate: 20 }], - ]) - ) - }) - - it('returns the currentTaxMap if one given', () => { - const currentTaxMap = new Map([['tax1', { amount: 100, label: 'Tax 1 (10%)', taxRate: 10 }]]) - const feeAppliedTaxes = [ - { id: 'tax1', tax: { id: 'tax1', name: 'Tax 1', rate: 10 } }, - { id: 'tax2', tax: { id: 'tax2', name: 'Tax 2', rate: 20 } }, - ] - - const updatedTaxMap = updateOrCreateTaxMap(currentTaxMap, 100, feeAppliedTaxes) + it('should return addonFees for estimate', () => { + const { feeForEstimate } = prepare({ addonFees: addOnFeeMock }) - expect(updatedTaxMap).toEqual( - new Map([ - ['tax1', { amount: 110, label: 'Tax 1 (10%)', taxRate: 10 }], - ['tax2', { amount: 20, label: 'Tax 2 (20%)', taxRate: 20 }], - ]) - ) + expect(feeForEstimate).toEqual(addonMockFormatedForEstimate) }) }) }) diff --git a/src/components/creditNote/utils.ts b/src/components/creditNote/utils.ts index 36dbd9886..6e69e2b77 100644 --- a/src/components/creditNote/utils.ts +++ b/src/components/creditNote/utils.ts @@ -1,269 +1,85 @@ -import { FeesPerInvoice, FromFee, GroupedFee } from './types' - -export const updateOrCreateTaxMap = ( - currentTaxesMap: TaxMapType, - feeAmount?: number, - feeAppliedTaxes?: { id: string; tax: { id: string; name: string; rate: number } }[] -) => { - if (!feeAppliedTaxes?.length) return currentTaxesMap - if (!currentTaxesMap?.size) currentTaxesMap = new Map() - - feeAppliedTaxes.forEach((appliedTax) => { - const { id, name, rate } = appliedTax.tax - const amount = ((feeAmount || 0) * rate) / 100 - - const previousTax = currentTaxesMap?.get(id) - - if (previousTax) { - previousTax.amount += amount - currentTaxesMap?.set(id, previousTax) - } else { - currentTaxesMap?.set(id, { amount, label: `${name} (${rate}%)`, taxRate: rate }) - } - }) - - return currentTaxesMap -} - -export const mergeTaxMaps = (map1: TaxMapType, map2: TaxMapType): TaxMapType => { - if (!map1.size) return map2 - if (!map2.size) return map1 - - // We assume both map1 and map2 are the same length and contain the same keys - const mergedMap = new Map() - - map1.forEach((_, key) => { - const previousTax1 = map1.get(key) - const previousTax2 = map2.get(key) +import { serializeAmount } from '~/core/serializers/serializeAmount' +import { CreditNoteItemInput, CurrencyEnum } from '~/generated/graphql' - if (previousTax1 && previousTax2) { - mergedMap.set(key, { - label: previousTax1.label, - amount: previousTax1.amount + previousTax2.amount, - taxRate: previousTax1.taxRate, - }) - } - }) - - return mergedMap -} - -type TaxMapType = Map< - string, // id of the tax - { - label: string - amount: number - taxRate: number // Used for sorting purpose - } -> +import { FeesPerInvoice, FromFee, GroupedFee } from './types' export type CreditNoteFormCalculationCalculationProps = { - addOnFee: FromFee[] | undefined - couponsAmountCents: string + currency: CurrencyEnum fees: FeesPerInvoice | undefined - feesAmountCents: string - hasFeeError: boolean - isLegacyInvoice: boolean + addonFees: FromFee[] | undefined + hasError: boolean } // This method calculate the credit notes amounts to display // It does parse once all items. If no coupon applied, values are used for display // If coupon applied, it will calculate the credit note tax amount based on the coupon value on pro rata of each item export const creditNoteFormCalculationCalculation = ({ - addOnFee, - couponsAmountCents, + currency, fees, - feesAmountCents, - hasFeeError, - isLegacyInvoice, -}: CreditNoteFormCalculationCalculationProps) => { - if (hasFeeError) return { totalExcludedTax: undefined, taxes: new Map() } - - const feeTotal = Object.keys(fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accSub, subKey) => { - const subChild = ((fees as FeesPerInvoice) || {})[subKey] - const subValues = Object.keys(subChild?.fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accGroup, groupKey) => { - const child = subChild?.fees[groupKey] as FromFee - - if (typeof child.checked === 'boolean') { - const childExcludedTax = Number(child.value as number) - - return !child.checked - ? accGroup - : (accGroup = { - totalExcludedTax: accGroup.totalExcludedTax + childExcludedTax, - taxes: updateOrCreateTaxMap( - accGroup.taxes, - childExcludedTax, - child?.appliedTaxes - ), - }) - } - - const grouped = (child as unknown as GroupedFee)?.grouped - const groupedValues = Object.keys(grouped || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accFee, feeKey) => { - const fee = grouped[feeKey] - const feeExcludedTax = Number(fee.value) - - return !fee.checked - ? accFee - : (accFee = { - totalExcludedTax: accFee.totalExcludedTax + feeExcludedTax, - taxes: updateOrCreateTaxMap(accFee.taxes, feeExcludedTax, fee?.appliedTaxes), - }) - }, - { totalExcludedTax: 0, taxes: new Map() } - ) - - return { - totalExcludedTax: accGroup.totalExcludedTax + groupedValues.totalExcludedTax, - taxes: mergeTaxMaps(accGroup.taxes, groupedValues.taxes), - } - }, - { totalExcludedTax: 0, taxes: new Map() } - ) - - return { - totalExcludedTax: accSub?.totalExcludedTax + subValues.totalExcludedTax, - taxes: mergeTaxMaps(accSub?.taxes, subValues.taxes), - } - }, - { totalExcludedTax: 0, taxes: new Map() } - ) - - const { value: addOnValue, taxes: addOnTaxes } = addOnFee?.reduce( - (acc, fee) => { - return { - value: acc.value + (fee.checked ? Number(fee.value) : 0), - taxes: updateOrCreateTaxMap( - acc.taxes, - fee.checked ? Number(fee.value) : 0, - fee?.appliedTaxes - ), - } - }, - { value: 0, taxes: new Map() } - ) || { value: 0, taxes: new Map() } - - let proRatedCouponAmount = 0 - let totalExcludedTax = feeTotal.totalExcludedTax + Number(addOnValue || 0) - const totalInvoiceFeesCreditableAmountCentsExcludingTax = Number(feesAmountCents || 0) - - // If legacy invoice or no coupon, return "basic" calculation - if (isLegacyInvoice || Number(couponsAmountCents) === 0) { - return { - proRatedCouponAmount, - totalExcludedTax, - taxes: mergeTaxMaps(feeTotal.taxes, addOnTaxes), - } - } - - const couponsAdjustmentAmountCents = () => { - return ( - (Number(couponsAmountCents) / totalInvoiceFeesCreditableAmountCentsExcludingTax) * - feeTotal.totalExcludedTax - ) - } - - // Parse fees a second time to calculate pro-rated amounts - const proRatedTotal = () => { - return Object.keys(fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accSub, subKey) => { + addonFees, + hasError, +}: CreditNoteFormCalculationCalculationProps): { + feeForEstimate: CreditNoteItemInput[] | undefined +} => { + if (hasError) return { feeForEstimate: undefined } + + const feeForEstimate = !!Object.keys(fees || {}).length + ? Object.keys(fees || {}).reduce((accSub, subKey) => { const subChild = ((fees as FeesPerInvoice) || {})[subKey] - const subValues = Object.keys(subChild?.fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( + const subValues = Object.keys(subChild?.fees || {}).reduce( (accGroup, groupKey) => { const child = subChild?.fees[groupKey] as FromFee - if (typeof child.checked === 'boolean') { - const childExcludedTax = Number(child.value as number) - - let itemRate = Number(child.value) / feeTotal.totalExcludedTax - let proratedCouponAmount = couponsAdjustmentAmountCents() * itemRate + if (typeof child.checked === 'boolean' && !!child.checked) { + accGroup.push({ + feeId: child.id, + amountCents: serializeAmount(child.value, currency), + }) - return !child.checked - ? accGroup - : (accGroup = { - totalExcludedTax: accGroup.totalExcludedTax + childExcludedTax, - taxes: updateOrCreateTaxMap( - accGroup.taxes, - childExcludedTax - proratedCouponAmount, - child?.appliedTaxes - ), - }) + return accGroup } const grouped = (child as unknown as GroupedFee)?.grouped - const groupedValues = Object.keys(grouped || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( + const groupedValues = Object.keys(grouped || {}).reduce( (accFee, feeKey) => { const fee = grouped[feeKey] - const feeExcludedTax = Number(fee.value) - let itemRate = Number(fee.value) / feeTotal.totalExcludedTax - let proratedCouponAmount = couponsAdjustmentAmountCents() * itemRate - return !fee.checked - ? accFee - : (accFee = { - totalExcludedTax: accFee.totalExcludedTax + feeExcludedTax, - taxes: updateOrCreateTaxMap( - accFee.taxes, - feeExcludedTax - proratedCouponAmount, - fee?.appliedTaxes - ), - }) + if (fee.checked) { + accFee.push({ + feeId: fee.id, + amountCents: serializeAmount(fee.value, currency), + }) + } + + return accFee }, - { totalExcludedTax: 0, taxes: new Map() } + [] ) - return { - totalExcludedTax: accGroup.totalExcludedTax + groupedValues.totalExcludedTax, - taxes: mergeTaxMaps(accGroup.taxes, groupedValues.taxes), - } + accGroup = [...accGroup, ...groupedValues] + return accGroup }, - { totalExcludedTax: 0, taxes: new Map() } + [] ) - return { - totalExcludedTax: accSub?.totalExcludedTax + subValues.totalExcludedTax, - taxes: mergeTaxMaps(accSub?.taxes, subValues.taxes), + accSub = [...accSub, ...subValues] + + return accSub + }, []) + : !!addonFees + ? addonFees?.reduce((acc, fee) => { + if (!!fee.checked) { + acc.push({ + feeId: fee.id, + amountCents: serializeAmount(fee.value, currency), + }) } - }, - { totalExcludedTax: 0, taxes: new Map() } - ) - } - - // If coupon is applied, we need to pro-rate the coupon amount and the tax amount - proRatedCouponAmount = - (Number(couponsAmountCents) / totalInvoiceFeesCreditableAmountCentsExcludingTax) * - feeTotal.totalExcludedTax - - // And deduct the coupon amount from the total excluding Tax - totalExcludedTax -= proRatedCouponAmount - const { taxes } = proRatedTotal() + return acc + }, []) + : undefined return { - proRatedCouponAmount, - totalExcludedTax, - taxes, + feeForEstimate, } } diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 3ed98a14e..1c5f64337 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -1062,6 +1062,8 @@ export type CreditNoteEstimate = { items: Array; maxCreditableAmountCents: Scalars['BigInt']['output']; maxRefundableAmountCents: Scalars['BigInt']['output']; + preciseCouponsAdjustmentAmountCents: Scalars['Float']['output']; + preciseTaxesAmountCents: Scalars['Float']['output']; subTotalExcludingTaxesAmountCents: Scalars['BigInt']['output']; taxesAmountCents: Scalars['BigInt']['output']; taxesRate: Scalars['Float']['output']; @@ -3855,7 +3857,15 @@ export type TerminateCouponMutationVariables = Exact<{ export type TerminateCouponMutation = { __typename?: 'Mutation', terminateCoupon?: { __typename?: 'Coupon', id: string } | null }; -export type CreditNoteFormFragment = { __typename?: 'Invoice', id: string, couponsAmountCents: any, paymentStatus: InvoicePaymentStatusTypeEnum, creditableAmountCents: any, refundableAmountCents: any, feesAmountCents: any, currency?: CurrencyEnum | null, versionNumber: number, fees?: Array<{ __typename?: 'Fee', id: string, appliedTaxes?: Array<{ __typename?: 'FeeAppliedTax', id: string, tax: { __typename?: 'Tax', id: string, name: string, rate: number } }> | null }> | null }; +export type InvoiceForCreditNoteFormCalculationFragment = { __typename?: 'Invoice', id: string, couponsAmountCents: any, paymentStatus: InvoicePaymentStatusTypeEnum, creditableAmountCents: any, refundableAmountCents: any, feesAmountCents: any, currency?: CurrencyEnum | null, versionNumber: number, fees?: Array<{ __typename?: 'Fee', id: string, appliedTaxes?: Array<{ __typename?: 'FeeAppliedTax', id: string, tax: { __typename?: 'Tax', id: string, name: string, rate: number } }> | null }> | null }; + +export type CreditNoteEstimateQueryVariables = Exact<{ + invoiceId: Scalars['ID']['input']; + items: Array | CreditNoteItemInput; +}>; + + +export type CreditNoteEstimateQuery = { __typename?: 'Query', creditNoteEstimate: { __typename?: 'CreditNoteEstimate', couponsAdjustmentAmountCents: any, currency: CurrencyEnum, maxCreditableAmountCents: any, maxRefundableAmountCents: any, subTotalExcludingTaxesAmountCents: any, taxesAmountCents: any, taxesRate: number, appliedTaxes: Array<{ __typename?: 'CreditNoteAppliedTax', taxCode: string, taxName: string, taxRate: number, amountCents: any, tax: { __typename?: 'Tax', id: string } }>, items: Array<{ __typename?: 'CreditNoteItemEstimate', amountCents: any, fee: { __typename?: 'Fee', id: string } }> } }; export type GetPortalCustomerInfosQueryVariables = Exact<{ [key: string]: never; }>; @@ -5839,8 +5849,8 @@ export const InvoiceFeeFragmentDoc = gql` } } `; -export const CreditNoteFormFragmentDoc = gql` - fragment CreditNoteForm on Invoice { +export const InvoiceForCreditNoteFormCalculationFragmentDoc = gql` + fragment InvoiceForCreditNoteFormCalculation on Invoice { id couponsAmountCents paymentStatus @@ -5871,9 +5881,9 @@ export const CreateCreditNoteInvoiceFragmentDoc = gql` creditableAmountCents refundableAmountCents subTotalIncludingTaxesAmountCents - ...CreditNoteForm + ...InvoiceForCreditNoteFormCalculation } - ${CreditNoteFormFragmentDoc}`; + ${InvoiceForCreditNoteFormCalculationFragmentDoc}`; export const InvoiceCreateCreditNoteFragmentDoc = gql` fragment InvoiceCreateCreditNote on Invoice { id @@ -7117,6 +7127,63 @@ export function useTerminateCouponMutation(baseOptions?: Apollo.MutationHookOpti export type TerminateCouponMutationHookResult = ReturnType; export type TerminateCouponMutationResult = Apollo.MutationResult; export type TerminateCouponMutationOptions = Apollo.BaseMutationOptions; +export const CreditNoteEstimateDocument = gql` + query creditNoteEstimate($invoiceId: ID!, $items: [CreditNoteItemInput!]!) { + creditNoteEstimate(invoiceId: $invoiceId, items: $items) { + appliedTaxes { + taxCode + taxName + taxRate + amountCents + tax { + id + } + } + couponsAdjustmentAmountCents + currency + items { + amountCents + fee { + id + } + } + maxCreditableAmountCents + maxRefundableAmountCents + subTotalExcludingTaxesAmountCents + taxesAmountCents + taxesRate + } +} + `; + +/** + * __useCreditNoteEstimateQuery__ + * + * To run a query within a React component, call `useCreditNoteEstimateQuery` and pass it any options that fit your needs. + * When your component renders, `useCreditNoteEstimateQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCreditNoteEstimateQuery({ + * variables: { + * invoiceId: // value for 'invoiceId' + * items: // value for 'items' + * }, + * }); + */ +export function useCreditNoteEstimateQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(CreditNoteEstimateDocument, options); + } +export function useCreditNoteEstimateLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(CreditNoteEstimateDocument, options); + } +export type CreditNoteEstimateQueryHookResult = ReturnType; +export type CreditNoteEstimateLazyQueryHookResult = ReturnType; +export type CreditNoteEstimateQueryResult = Apollo.QueryResult; export const GetPortalCustomerInfosDocument = gql` query getPortalCustomerInfos { customerPortalUser { diff --git a/src/pages/CreateCreditNote.tsx b/src/pages/CreateCreditNote.tsx index bfa44cf80..54daef28a 100644 --- a/src/pages/CreateCreditNote.tsx +++ b/src/pages/CreateCreditNote.tsx @@ -1,21 +1,16 @@ import { gql } from '@apollo/client' import { useFormik } from 'formik' import _get from 'lodash/get' -import { useMemo, useRef } from 'react' +import { useMemo, useRef, useState } from 'react' import { generatePath, useNavigate, useParams } from 'react-router-dom' import styled, { css } from 'styled-components' -import { array, number, object, string } from 'yup' +import { array, object, string } from 'yup' import { CreditNoteCodeSnippet } from '~/components/creditNote/CreditNoteCodeSnippet' import { CreditNoteFormCalculation } from '~/components/creditNote/CreditNoteFormCalculation' import { CreditNoteFormItem } from '~/components/creditNote/CreditNoteFormItem' -import { - CreditNoteForm, - CreditTypeEnum, - FromFee, - GroupedFee, - PayBackErrorEnum, -} from '~/components/creditNote/types' +import { CreditNoteForm, FromFee, GroupedFee } from '~/components/creditNote/types' +import { creditNoteFormCalculationCalculation } from '~/components/creditNote/utils' import { Avatar, Button, @@ -33,9 +28,9 @@ import { CUSTOMER_INVOICE_DETAILS_ROUTE } from '~/core/router' import { deserializeAmount } from '~/core/serializers/serializeAmount' import { generateAddOnFeesSchema, generateFeesSchema } from '~/formValidation/feesSchema' import { - CreditNoteFormFragmentDoc, CreditNoteReasonEnum, CurrencyEnum, + InvoiceForCreditNoteFormCalculationFragmentDoc, InvoicePaymentStatusTypeEnum, LagoApiError, } from '~/generated/graphql' @@ -54,10 +49,10 @@ gql` creditableAmountCents refundableAmountCents subTotalIncludingTaxesAmountCents - ...CreditNoteForm + ...InvoiceForCreditNoteFormCalculation } - ${CreditNoteFormFragmentDoc} + ${InvoiceForCreditNoteFormCalculationFragmentDoc} ` const determineCheckboxValue = ( @@ -95,13 +90,36 @@ const mapStatus = (type?: InvoicePaymentStatusTypeEnum | undefined) => { } const CreateCreditNote = () => { + const navigate = useNavigate() const { translate } = useInternationalization() const warningDialogRef = useRef(null) const { customerId, invoiceId } = useParams() - const navigate = useNavigate() const { loading, invoice, feesPerInvoice, feeForAddOn, onCreate } = useCreateCreditNote() const currency = invoice?.currency || CurrencyEnum.Usd + // setInterval(() => { + // setPayBackValidation( + // array().of( + // object().shape({ + // type: string().required(''), + // value: number() + // .required('') + // .when('type', ([type]) => { + // return type === CreditTypeEnum.refund + // ? number().max( + // deserializeAmount(Math.round(Math.random() * 1000), currency) || 0, + // PayBackErrorEnum.maxRefund + // ) + // : number().max( + // deserializeAmount(Math.round(Math.random() * 1000), currency) || 0, + // PayBackErrorEnum.maxRefund + // ) + // }), + // }) + // ) + // ) + // }, 1000) + const addOnFeesValidation = useMemo( () => generateAddOnFeesSchema(feeForAddOn || [], currency), [feeForAddOn, currency] @@ -112,6 +130,8 @@ const CreateCreditNote = () => { [feesPerInvoice, currency] ) + const [payBackValidation, setPayBackValidation] = useState(array()) + const statusMap = mapStatus(invoice?.paymentStatus) const formikProps = useFormik>({ initialValues: { @@ -127,24 +147,7 @@ const CreateCreditNote = () => { reason: string().required(''), fees: feesValidation, addOnFee: addOnFeesValidation, - payBack: array().of( - object().shape({ - type: string().required(''), - value: number() - .required('') - .when('type', ([type]) => { - return type === CreditTypeEnum.refund - ? number().max( - deserializeAmount(invoice?.refundableAmountCents, currency) || 0, - PayBackErrorEnum.maxRefund - ) - : number().max( - deserializeAmount(invoice?.creditableAmountCents, currency) || 0, - PayBackErrorEnum.maxRefund - ) - }), - }) - ), + payBack: payBackValidation, }), validateOnMount: true, enableReinitialize: true, @@ -163,6 +166,8 @@ const CreateCreditNote = () => { }, }) + const hasError = !!formikProps.errors.fees || !!formikProps.errors.addOnFee + const checkboxGroupValue = useMemo(() => { const fees = formikProps.values.fees || {} @@ -212,6 +217,17 @@ const CreateCreditNote = () => { ) }, [formikProps.values.fees]) + const { feeForEstimate } = useMemo( + () => + creditNoteFormCalculationCalculation({ + currency, + hasError, + fees: formikProps.values.fees, + addonFees: formikProps.values.addOnFee, + }), + [currency, formikProps.values.addOnFee, formikProps.values.fees, hasError] + ) + return (
@@ -484,7 +500,13 @@ const CreateCreditNote = () => { ) })} - +