From 39110ac251c8600e6fb181a1e0975ed0ef1af63b Mon Sep 17 00:00:00 2001 From: Alexandre Monjol Date: Wed, 6 Sep 2023 16:23:16 -0300 Subject: [PATCH] chore(test) extract and test CreditNoteFormCalculation's calculation method --- .../creditNote/CreditNoteFormCalculation.tsx | 288 ++---------------- .../creditNote/__tests__/fixtures.ts | 151 +++++++++ .../creditNote/__tests__/utils.test.ts | 153 ++++++++++ src/components/creditNote/utils.ts | 269 ++++++++++++++++ 4 files changed, 594 insertions(+), 267 deletions(-) create mode 100644 src/components/creditNote/__tests__/fixtures.ts create mode 100644 src/components/creditNote/__tests__/utils.test.ts create mode 100644 src/components/creditNote/utils.ts diff --git a/src/components/creditNote/CreditNoteFormCalculation.tsx b/src/components/creditNote/CreditNoteFormCalculation.tsx index 30add991e..63f30ff6c 100644 --- a/src/components/creditNote/CreditNoteFormCalculation.tsx +++ b/src/components/creditNote/CreditNoteFormCalculation.tsx @@ -18,14 +18,8 @@ import { Typography, Button, Tooltip, Alert, Icon } from '~/components/designSys import { theme } from '~/styles' import { deserializeAmount, getCurrencyPrecision } from '~/core/serializers/serializeAmount' -import { - CreditNoteForm, - GroupedFee, - FromFee, - CreditTypeEnum, - PayBackErrorEnum, - FeesPerInvoice, -} from './types' +import { CreditNoteForm, CreditTypeEnum, PayBackErrorEnum } from './types' +import { creditNoteFormCalculationCalculation } from './utils' gql` fragment CreditNoteForm on Invoice { @@ -51,15 +45,6 @@ gql` } ` -type TaxMapType = Map< - string, // id of the tax - { - label: string - amount: number - taxRate: number // Used for sorting purpose - } -> - interface CreditNoteFormCalculationProps { invoice?: CreditNoteFormFragment formikProps: FormikProps> @@ -76,256 +61,25 @@ export const CreditNoteFormCalculation = ({ const currencyPrecision = getCurrencyPrecision(currency) const isLegacyInvoice = (invoice?.versionNumber || 0) < 3 - // 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 - const calculation = useMemo(() => { - if (hasFeeError) return { totalExcludedTax: undefined, taxes: new Map() } - - 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) - - if (previousTax1 && previousTax2) { - mergedMap.set(key, { - label: previousTax1.label, - amount: previousTax1.amount + previousTax2.amount, - taxRate: previousTax1.taxRate, - }) - } - }) - - return mergedMap - } - - const updateOrCreateTaxMap = ( - currentTaxesMap: TaxMapType, - feeAmount?: number, - feeAppliedTaxes?: { id: string; tax: { id: string; name: string; rate: number } }[] - ) => { - if (!feeAppliedTaxes?.length) return currentTaxesMap - if (!currentTaxesMap) 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 - } - - const feeTotal = Object.keys(formikProps?.values.fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accSub, subKey) => { - const subChild = ((formikProps?.values.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 } = formikProps.values.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(invoice?.feesAmountCents || 0) - - // If legacy invoice or no coupon, return "basic" calculation - if (isLegacyInvoice || Number(invoice?.couponsAmountCents) === 0) { - return { - proRatedCouponAmount, - totalExcludedTax, - taxes: mergeTaxMaps(feeTotal.taxes, addOnTaxes), - } - } - - const couponsAdjustmentAmountCents = () => { - return ( - (Number(invoice?.couponsAmountCents) / totalInvoiceFeesCreditableAmountCentsExcludingTax) * - feeTotal.totalExcludedTax - ) - } - - // Parse fees a second time to calculate pro-rated amounts - const proRatedTotal = () => { - return Object.keys(formikProps?.values.fees || {}).reduce<{ - totalExcludedTax: number - taxes: TaxMapType - }>( - (accSub, subKey) => { - const subChild = ((formikProps?.values.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) - let itemRate = Number(child.value) / feeTotal.totalExcludedTax - let proratedCouponAmount = couponsAdjustmentAmountCents() * itemRate - - return !child.checked - ? accGroup - : (accGroup = { - totalExcludedTax: accGroup.totalExcludedTax + childExcludedTax, - taxes: updateOrCreateTaxMap( - accGroup.taxes, - childExcludedTax - proratedCouponAmount, - 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) - 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 - ), - }) - }, - { 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() } - ) - } - - // If coupon is applied, we need to pro-rate the coupon amount and the tax amount - proRatedCouponAmount = - (Number(invoice?.couponsAmountCents) / totalInvoiceFeesCreditableAmountCentsExcludingTax) * - feeTotal.totalExcludedTax - - // And deduct the coupon amount from the total excluding Tax - totalExcludedTax -= proRatedCouponAmount - - const { taxes } = proRatedTotal() - - return { - proRatedCouponAmount, - totalExcludedTax, - taxes, - } - }, [ - formikProps?.values.fees, - formikProps.values.addOnFee, - hasFeeError, - invoice?.feesAmountCents, - invoice?.couponsAmountCents, - isLegacyInvoice, - ]) - - const { totalExcludedTax, taxes, proRatedCouponAmount } = calculation + 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 totalTaxAmount = taxes?.size ? Array.from(taxes.values()).reduce((acc, tax) => acc + tax.amount, 0) : 0 diff --git a/src/components/creditNote/__tests__/fixtures.ts b/src/components/creditNote/__tests__/fixtures.ts new file mode 100644 index 000000000..f3a132e2c --- /dev/null +++ b/src/components/creditNote/__tests__/fixtures.ts @@ -0,0 +1,151 @@ +export const feesMock = { + subscriptionId1: { + subscriptionName: 'Subscription 1', + fees: { + sub1feegroup1: { + id: 'fee1', + name: 'Fee 1', + amount: 10000, + taxRate: 10, + checked: true, + maxAmount: 10000, + value: 10000, + appliedTaxes: [ + { + id: 'tax1', + tax: { + id: 'tax1tax1', + name: 'Tax 1', + rate: 10, + }, + }, + ], + }, + sub1feegroup2: { + id: 'fee2', + name: 'Fee 2', + amount: 20000, + taxRate: 20, + checked: true, + maxAmount: 20000, + value: 19000, + appliedTaxes: [ + { + id: 'tax2', + tax: { + id: 'tax2tax2', + name: 'Tax 2', + rate: 20, + }, + }, + ], + }, + sub1feegroup3: { + id: 'fee3', + name: 'Fee 3', + amount: 10, + taxRate: 20, + checked: false, + maxAmount: 10, + value: 10, + appliedTaxes: [], + }, + }, + }, + subscriptionId2: { + subscriptionName: 'Subscription 2', + fees: { + sub2feegroup1: { + id: 'fee4', + name: 'Fee 4', + amount: 4000, + taxRate: 0, + checked: true, + maxAmount: 10000, + value: 500, + }, + sub2feegroup2: { + name: 'Fee 5 group 1', + grouped: { + fee5Group1: { + id: 'fee5', + name: 'Fee 5', + amount: 4000, + taxRate: 0, + checked: true, + maxAmount: 10000, + value: 500, + appliedTaxes: [ + { + id: 'tax1', + tax: { + id: 'tax1tax1', + name: 'Tax 1', + rate: 10, + }, + }, + { + id: 'tax2', + tax: { + id: 'tax2tax2', + name: 'Tax 2', + rate: 20, + }, + }, + ], + }, + }, + }, + }, + }, +} +export const feesMockAmountCents = '38010' + +export const addOnFeeMock = [ + { + id: 'addOnFee1', + name: 'Add on fee', + amount: 10000, + taxRate: 30, + checked: true, + maxAmount: 10000, + value: 500, + appliedTaxes: [ + { + id: 'tax1', + tax: { + id: 'tax1tax1', + name: 'Tax 1', + rate: 10, + }, + }, + { + id: 'tax2', + tax: { + id: 'tax2tax2', + name: 'Tax 2', + rate: 20, + }, + }, + ], + }, + { + id: 'addOnFee2', + name: 'Add on fee', + amount: 20000, + taxRate: 30, + checked: false, + maxAmount: 10000, + value: 500, + appliedTaxes: [ + { + id: 'tax1', + tax: { + id: 'tax1tax1', + name: 'Tax 1', + rate: 10, + }, + }, + ], + }, +] diff --git a/src/components/creditNote/__tests__/utils.test.ts b/src/components/creditNote/__tests__/utils.test.ts new file mode 100644 index 000000000..0a63085de --- /dev/null +++ b/src/components/creditNote/__tests__/utils.test.ts @@ -0,0 +1,153 @@ +import { addOnFeeMock, feesMock, feesMockAmountCents } from './fixtures' + +import { + CreditNoteFormCalculationCalculationProps, + creditNoteFormCalculationCalculation, + mergeTaxMaps, + updateOrCreateTaxMap, +} from '../utils' + +const prepare = ({ + hasFeeError = false, + isLegacyInvoice = false, + addOnFee = undefined, + couponsAmountCents = '0', + fees = undefined, + feesAmountCents = '0', +}: Partial = {}) => { + const { totalExcludedTax, taxes, proRatedCouponAmount } = creditNoteFormCalculationCalculation({ + hasFeeError, + isLegacyInvoice, + addOnFee, + couponsAmountCents, + fees, + feesAmountCents, + }) + + return { totalExcludedTax, taxes, proRatedCouponAmount } +} + +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('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) + }) + + 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 }]])) + }) + }) + + 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) + + expect(updatedTaxMap).toEqual( + new Map([ + ['tax1', { amount: 110, label: 'Tax 1 (10%)', taxRate: 10 }], + ['tax2', { amount: 20, label: 'Tax 2 (20%)', taxRate: 20 }], + ]) + ) + }) + }) +}) diff --git a/src/components/creditNote/utils.ts b/src/components/creditNote/utils.ts new file mode 100644 index 000000000..36dbd9886 --- /dev/null +++ b/src/components/creditNote/utils.ts @@ -0,0 +1,269 @@ +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) + + 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 + } +> + +export type CreditNoteFormCalculationCalculationProps = { + addOnFee: FromFee[] | undefined + couponsAmountCents: string + fees: FeesPerInvoice | undefined + feesAmountCents: string + hasFeeError: boolean + isLegacyInvoice: 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, + 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) => { + 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) + + let itemRate = Number(child.value) / feeTotal.totalExcludedTax + let proratedCouponAmount = couponsAdjustmentAmountCents() * itemRate + + return !child.checked + ? accGroup + : (accGroup = { + totalExcludedTax: accGroup.totalExcludedTax + childExcludedTax, + taxes: updateOrCreateTaxMap( + accGroup.taxes, + childExcludedTax - proratedCouponAmount, + 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) + 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 + ), + }) + }, + { 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() } + ) + } + + // 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 { + proRatedCouponAmount, + totalExcludedTax, + taxes, + } +}