diff --git a/src/exports.test.ts b/src/exports.test.ts index 0be3e22b..b3df9f3e 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -34,6 +34,7 @@ const expectedNamedExports = [ 'extractGetAgConfig', 'getAmountWithTax', 'getTaxValue', + 'removeTrailingDecimalZeros', ]; /** diff --git a/src/index.ts b/src/index.ts index f6c8614b..7f3a0353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { formatAmountFromString, formatPriceUnit, parseDecimalValue, + removeTrailingDecimalZeros, toIntegerAmount, addSeparatorToDineroString, } from './money/formatters'; diff --git a/src/money/formatters.test.ts b/src/money/formatters.test.ts index 10c257ae..a6e9c2b8 100644 --- a/src/money/formatters.test.ts +++ b/src/money/formatters.test.ts @@ -6,6 +6,7 @@ import { formatAmountFromString, formatPriceUnit, parseDecimalValue, + removeTrailingDecimalZeros, toIntegerAmount, unitDisplayLabels, } from './formatters'; @@ -308,3 +309,125 @@ describe('parseDecimalValue', () => { expect(parseDecimalValue(value)).toEqual(expected); }); }); + +describe('removeTrailingDoubleDecimalZeros', () => { + describe('with dot decimal separator', () => { + it.each` + input | expected + ${'10.00'} | ${'10'} + ${'10.50'} | ${'10.50'} + ${'10.0000'} | ${'10'} + ${'10.1000'} | ${'10.10'} + ${'10.1200'} | ${'10.12'} + ${'10.0500'} | ${'10.05'} + ${'0.500'} | ${'0.50'} + ${'0.00'} | ${'0'} + ${'123.4500'} | ${'123.45'} + ${'999.9900'} | ${'999.99'} + ${'1.000000'} | ${'1'} + `('should remove trailing double zeros from $input to get $expected', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('with comma decimal separator', () => { + it.each` + input | expected + ${'10,00'} | ${'10'} + ${'10,50'} | ${'10,50'} + ${'10,0000'} | ${'10'} + ${'10,1000'} | ${'10,10'} + ${'10,1200'} | ${'10,12'} + ${'10,0500'} | ${'10,05'} + ${'0,500'} | ${'0,50'} + ${'0,00'} | ${'0'} + ${'123,4500'} | ${'123,45'} + ${'999,9900'} | ${'999,99'} + ${'1,000000'} | ${'1'} + `('should remove trailing double zeros from $input to get $expected', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('with currency symbols and units', () => { + it.each` + input | expected + ${'10.00 €'} | ${'10 €'} + ${'10.00 €/Stück'} | ${'10 €/Stück'} + ${'10.1200 USD'} | ${'10.12 USD'} + ${'0.00 €/kWh'} | ${'0 €/kWh'} + ${'123.4500 CHF/month'} | ${'123.45 CHF/month'} + ${'10,00 €'} | ${'10 €'} + ${'10,00€/Stück'} | ${'10€/Stück'} + ${'10,1200 USD'} | ${'10,12 USD'} + ${'0,00 €/kWh'} | ${'0 €/kWh'} + ${'123,4500 CHF/month'} | ${'123,45 CHF/month'} + `('should remove trailing double zeros from $input with suffix to get $expected', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('with complex suffixes', () => { + it.each` + input | expected + ${'10.00 €/Stück pro Monat'} | ${'10 €/Stück pro Monat'} + ${'15.0000/unit'} | ${'15/unit'} + ${'25.1200 per item'} | ${'25.12 per item'} + ${'100.00€'} | ${'100€'} + ${'50.0000$'} | ${'50$'} + ${'10,00 €/Stück pro Monat'} | ${'10 €/Stück pro Monat'} + ${'15,0000/unit'} | ${'15/unit'} + ${'25,1200 per item'} | ${'25,12 per item'} + ${'100,00€'} | ${'100€'} + ${'50,0000$'} | ${'50$'} + `('should handle complex suffixes correctly for $input', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it.each` + input | expected | description + ${'10.01'} | ${'10.01'} | ${'should not modify numbers without trailing double zeros'} + ${'10.10'} | ${'10.10'} | ${'should not modify single trailing zero'} + ${'10'} | ${'10'} | ${'should not modify integers without decimals'} + ${'10.'} | ${'10.'} | ${'should not modify numbers ending with decimal separator only'} + ${'10.0'} | ${'10'} | ${'should not modify single decimal zero'} + ${'abc'} | ${'abc'} | ${'should not modify non-numeric strings'} + ${'10.00.00'} | ${'10.00.00'} | ${'should not modify invalid number formats'} + ${''} | ${''} | ${'should handle empty strings'} + ${'10.000000000'} | ${'10'} | ${'should remove multiple consecutive double zeros'} + ${'10,01'} | ${'10,01'} | ${'should not modify numbers without trailing double zeros (comma)'} + ${'10,10'} | ${'10,10'} | ${'should not modify single trailing zero (comma)'} + ${'10,0'} | ${'10'} | ${'should not modify single decimal zero (comma)'} + `('$description: $input -> $expected', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('preserves whitespace and formatting', () => { + it.each` + input | expected + ${'10.00 €'} | ${'10 €'} + ${'10.00\t€'} | ${'10\t€'} + ${'10.00\n€/unit'} | ${'10\n€/unit'} + ${'10,00 €'} | ${'10 €'} + ${'10,00\t€'} | ${'10\t€'} + ${'10,00\n€/unit'} | ${'10\n€/unit'} + `('should preserve whitespace in $input', ({ input, expected }) => { + expect(removeTrailingDecimalZeros(input)).toBe(expected); + }); + }); + + describe('with minDecimals', () => { + it.each` + input | expected | minDecimals + ${'10.1000'} | ${'10.1'} | ${0} + ${'10.1000'} | ${'10.10'} | ${2} + ${'10.1000'} | ${'10.100'} | ${3} + ${'10.1000'} | ${'10.1000'} | ${4} + `('should remove trailing double zeros from $input to get $expected', ({ input, expected, minDecimals }) => { + expect(removeTrailingDecimalZeros(input, minDecimals)).toBe(expected); + }); + }); +}); diff --git a/src/money/formatters.ts b/src/money/formatters.ts index 408d1c3a..57f8f3ad 100644 --- a/src/money/formatters.ts +++ b/src/money/formatters.ts @@ -282,6 +282,52 @@ export const unitDisplayLabels = { kwp: 'kWp', } as const; +/** + * Removes trailing double-zero groups from the decimal part of a formatted decimal amount string. + * + * - Always preserves any suffixes after the number (whitespace, newlines, currency symbols, unit names, etc.). + * - Removes the entire decimal part if all digits after the separator are zeros. + * - Keeps at least `minDecimals` decimal digits when a fractional part remains after trimming. + * - Handles both dot `.` and comma `,` as decimal separators. + * + * @param decimalAmount - The decimal amount to remove trailing zeros from. + * @param minDecimals - The minimum number of decimal digits to keep. + * @returns The decimal amount with trailing zeros removed. + */ +export const removeTrailingDecimalZeros = (decimalAmount: string, minDecimals = 2): string => { + if (decimalAmount.split(/[.,]/).length > 2) return decimalAmount; + + const match = decimalAmount.match(/^(-?\d+)([.,])(\d+)(.*)$/s); + if (!match) return decimalAmount; + + const [, intPart, sep, decimals, suffix] = match; + + // Remove trailing double-zero pairs + let trimmed = decimals.replace(/(00)+$/, ''); + const removedDoubleZeros = decimals !== trimmed; + + // If nothing remains, or all remaining decimals are zeros, remove decimal entirely + if (trimmed === '' || /^0+$/.test(trimmed)) { + return intPart + suffix; // preserve suffix (spaces, newlines, currency) + } + + // If minDecimals is 0, we can remove trailing single zeros as well + if (minDecimals === 0) { + trimmed = trimmed.replace(/0+$/, ''); + // If all decimals were removed, don't include the decimal separator + if (trimmed === '') { + return intPart + suffix; + } + } + + // Pad to minDecimals only if we removed double zeros and minDecimals > 0 + if (removedDoubleZeros && minDecimals > 0 && trimmed.length < minDecimals) { + trimmed = trimmed.padEnd(minDecimals, '0'); + } + + return `${intPart}${sep}${trimmed}${suffix}`; // always append suffix +}; + /** * Formats built-in price units into a displayable representation. Eg. kw -> kW *