From 503015374d14fbba7912554c2345682aaf4a9aee Mon Sep 17 00:00:00 2001 From: Alexandre Marques Date: Wed, 1 Apr 2026 17:43:30 +0100 Subject: [PATCH 1/2] feat: enhance currency formatting with trailing zero omission - Added support for omitting trailing zeros in formatAmount and formatAmountFromString functions when omitTrailingDoubleZeros is true. - Updated tests to verify correct formatting for various locales and amounts, including handling of subunits and zero amounts. --- src/money/formatters.test.ts | 66 ++++++++++++++++++++++++++++++++++++ src/money/formatters.ts | 27 +++++++++------ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/money/formatters.test.ts b/src/money/formatters.test.ts index 10c257ae..4db05a37 100644 --- a/src/money/formatters.test.ts +++ b/src/money/formatters.test.ts @@ -112,6 +112,38 @@ describe('formatAmount', () => { formatAmount({ amount: 'invalid_amount' }); }).not.toThrow(); }); + + it.each` + amount | locale | expected + ${1000} | ${'de'} | ${'10\xa0€'} + ${1000} | ${'en-GB'} | ${'€10'} + ${100023} | ${'de'} | ${'1.000,23\xa0€'} + ${0} | ${'de'} | ${'0\xa0€'} + `( + 'should omit trailing zeros when omitTrailingDoubleZeros is true', + ({ amount, locale, expected }: { amount: number; locale: string; expected: string }) => { + const formattedAmount = formatAmount({ amount, locale, omitTrailingDoubleZeros: true }); + + expect(formattedAmount).toEqual(expected); + }, + ); + + it.each` + amount | expected + ${23} | ${'23 Cent'} + ${-3} | ${'-3 Cent'} + `( + 'should handle omitTrailingDoubleZeros with enableSubunitDisplay', + ({ amount, expected }: { amount: number; expected: string }) => { + const formattedAmount = formatAmount({ + amount, + enableSubunitDisplay: true, + omitTrailingDoubleZeros: true, + }); + + expect(formattedAmount).toEqual(expected); + }, + ); }); describe('formatAmountFromString', () => { @@ -241,6 +273,40 @@ describe('formatAmountFromString', () => { expect(formattedAmount).toEqual('1.000,23329\xa0€'); }); + + it.each` + decimalAmount | locale | expected + ${'10.00'} | ${'de'} | ${'10\xa0€'} + ${'10.00'} | ${'en-GB'} | ${'€10'} + ${'10.50'} | ${'de'} | ${'10,50\xa0€'} + ${'0.00'} | ${'de'} | ${'0\xa0€'} + `( + 'should omit trailing zeros when omitTrailingDoubleZeros is true', + ({ decimalAmount, locale, expected }: { decimalAmount: string; locale: string; expected: string }) => { + const formattedAmount = formatAmountFromString({ decimalAmount, locale, omitTrailingDoubleZeros: true }); + + expect(formattedAmount).toEqual(expected); + }, + ); + + it.each` + decimalAmount | expected + ${'0.0100'} | ${'1 Cent'} + ${'0.0200'} | ${'2 Cent'} + ${'0.0250'} | ${'2,50 Cent'} + `( + 'should handle omitTrailingDoubleZeros with enableSubunitDisplay', + ({ decimalAmount, expected }: { decimalAmount: string; expected: string }) => { + const formattedAmount = formatAmountFromString({ + decimalAmount, + locale: 'de', + enableSubunitDisplay: true, + omitTrailingDoubleZeros: true, + }); + + expect(formattedAmount).toEqual(expected); + }, + ); }); describe('toDinero', () => { diff --git a/src/money/formatters.ts b/src/money/formatters.ts index 408d1c3a..30918b3b 100644 --- a/src/money/formatters.ts +++ b/src/money/formatters.ts @@ -87,12 +87,14 @@ export const formatAmount = ({ format, locale = DEFAULT_LOCALE, enableSubunitDisplay = false, + omitTrailingDoubleZeros = false, }: { amount: number | string; currency?: Currency; format?: string; locale?: string; enableSubunitDisplay?: boolean; + omitTrailingDoubleZeros?: boolean; }): string => { const integerAmount = parseUnknownAmount(amount); @@ -115,7 +117,10 @@ export const formatAmount = ({ ); } - return dAmount.setLocale(locale).toFormat(format || DEFAULT_FORMAT); + const effectiveFormat = + omitTrailingDoubleZeros && !dAmount.hasSubUnits() ? DEFAULT_SUBUNIT_FORMAT : format || DEFAULT_FORMAT; + + return dAmount.setLocale(locale).toFormat(effectiveFormat); }; /** @@ -185,6 +190,7 @@ export const formatAmountFromString = ({ locale, useRealPrecision = false, enableSubunitDisplay = false, + omitTrailingDoubleZeros = false, }: { decimalAmount: string; precision?: number; @@ -193,6 +199,7 @@ export const formatAmountFromString = ({ locale?: string; useRealPrecision?: boolean; enableSubunitDisplay?: boolean; + omitTrailingDoubleZeros?: boolean; }): string => { /** * Decimal amounts can sometimes come in an invalid format, such as 1.000.000,00. @@ -217,20 +224,20 @@ export const formatAmountFromString = ({ subunitFromAmount, ); - return formatWithSubunit( - dineroObjectFromAmount - .multiply(100) - .convertPrecision(precision ?? amountPrecision) - .setLocale(locale || DEFAULT_LOCALE) - .toFormat(format || amountFormat), - subunit, - ); + const dSubunit = dineroObjectFromAmount.multiply(100).convertPrecision(precision ?? amountPrecision); + const subunitFormat = + omitTrailingDoubleZeros && !dSubunit.hasSubUnits() ? DEFAULT_SUBUNIT_FORMAT : format || amountFormat; + + return formatWithSubunit(dSubunit.setLocale(locale || DEFAULT_LOCALE).toFormat(subunitFormat), subunit); } + const hasNoSubUnits = !dineroObjectFromAmount.convertPrecision(precision ?? amountPrecision).hasSubUnits(); + const effectiveFormat = omitTrailingDoubleZeros && hasNoSubUnits ? DEFAULT_SUBUNIT_FORMAT : format || amountFormat; + return dineroObjectFromAmount .setLocale(locale || DEFAULT_LOCALE) .convertPrecision(precision ?? amountPrecision) - .toFormat(format || amountFormat); + .toFormat(effectiveFormat); }; /** From 5d6e7063aee3a7d1b3aa90b9e26a54e8f46415f5 Mon Sep 17 00:00:00 2001 From: Alexandre Marques Date: Thu, 2 Apr 2026 10:33:38 +0100 Subject: [PATCH 2/2] feat: implement omitTrailingDecimalZeros function and update exports - Added omitTrailingDecimalZeros function to remove trailing decimal zeros from formatted price strings. - Updated exports to include the new function. - Enhanced tests to verify the correct behavior of the omitTrailingDecimalZeros function across various price formats. --- src/exports.test.ts | 1 + src/index.ts | 1 + src/money/formatters.test.ts | 87 +++++++++--------------------------- src/money/formatters.ts | 38 +++++++++------- 4 files changed, 46 insertions(+), 81 deletions(-) diff --git a/src/exports.test.ts b/src/exports.test.ts index 0f563e1f..34e202df 100644 --- a/src/exports.test.ts +++ b/src/exports.test.ts @@ -6,6 +6,7 @@ const expectedNamedExports = [ 'formatAmount', 'formatAmountFromString', 'formatPriceUnit', + 'omitTrailingDecimalZeros', 'parseDecimalValue', 'toDinero', 'toDineroFromInteger', diff --git a/src/index.ts b/src/index.ts index d76ff598..9d2ef9ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { formatAmount, formatAmountFromString, formatPriceUnit, + omitTrailingDecimalZeros, parseDecimalValue, toIntegerAmount, addSeparatorToDineroString, diff --git a/src/money/formatters.test.ts b/src/money/formatters.test.ts index 4db05a37..86038380 100644 --- a/src/money/formatters.test.ts +++ b/src/money/formatters.test.ts @@ -5,6 +5,7 @@ import { formatAmount, formatAmountFromString, formatPriceUnit, + omitTrailingDecimalZeros, parseDecimalValue, toIntegerAmount, unitDisplayLabels, @@ -112,38 +113,6 @@ describe('formatAmount', () => { formatAmount({ amount: 'invalid_amount' }); }).not.toThrow(); }); - - it.each` - amount | locale | expected - ${1000} | ${'de'} | ${'10\xa0€'} - ${1000} | ${'en-GB'} | ${'€10'} - ${100023} | ${'de'} | ${'1.000,23\xa0€'} - ${0} | ${'de'} | ${'0\xa0€'} - `( - 'should omit trailing zeros when omitTrailingDoubleZeros is true', - ({ amount, locale, expected }: { amount: number; locale: string; expected: string }) => { - const formattedAmount = formatAmount({ amount, locale, omitTrailingDoubleZeros: true }); - - expect(formattedAmount).toEqual(expected); - }, - ); - - it.each` - amount | expected - ${23} | ${'23 Cent'} - ${-3} | ${'-3 Cent'} - `( - 'should handle omitTrailingDoubleZeros with enableSubunitDisplay', - ({ amount, expected }: { amount: number; expected: string }) => { - const formattedAmount = formatAmount({ - amount, - enableSubunitDisplay: true, - omitTrailingDoubleZeros: true, - }); - - expect(formattedAmount).toEqual(expected); - }, - ); }); describe('formatAmountFromString', () => { @@ -273,40 +242,6 @@ describe('formatAmountFromString', () => { expect(formattedAmount).toEqual('1.000,23329\xa0€'); }); - - it.each` - decimalAmount | locale | expected - ${'10.00'} | ${'de'} | ${'10\xa0€'} - ${'10.00'} | ${'en-GB'} | ${'€10'} - ${'10.50'} | ${'de'} | ${'10,50\xa0€'} - ${'0.00'} | ${'de'} | ${'0\xa0€'} - `( - 'should omit trailing zeros when omitTrailingDoubleZeros is true', - ({ decimalAmount, locale, expected }: { decimalAmount: string; locale: string; expected: string }) => { - const formattedAmount = formatAmountFromString({ decimalAmount, locale, omitTrailingDoubleZeros: true }); - - expect(formattedAmount).toEqual(expected); - }, - ); - - it.each` - decimalAmount | expected - ${'0.0100'} | ${'1 Cent'} - ${'0.0200'} | ${'2 Cent'} - ${'0.0250'} | ${'2,50 Cent'} - `( - 'should handle omitTrailingDoubleZeros with enableSubunitDisplay', - ({ decimalAmount, expected }: { decimalAmount: string; expected: string }) => { - const formattedAmount = formatAmountFromString({ - decimalAmount, - locale: 'de', - enableSubunitDisplay: true, - omitTrailingDoubleZeros: true, - }); - - expect(formattedAmount).toEqual(expected); - }, - ); }); describe('toDinero', () => { @@ -360,6 +295,26 @@ describe('formatPriceUnit', () => { ); }); +describe('omitTrailingDecimalZeros', () => { + it.each` + price | expected + ${'10.00 €'} | ${'10 €'} + ${'10,00 €'} | ${'10 €'} + ${'1.000,00 €'} | ${'1.000 €'} + ${'10.50 €'} | ${'10.50 €'} + ${'10,50 €'} | ${'10,50 €'} + ${'€10.00'} | ${'€10'} + ${'€10.00/Stück'} | ${'€10/Stück'} + ${'€10,00/Stück'} | ${'€10/Stück'} + ${'10.00 € / Monat'} | ${'10 € / Monat'} + ${'10,00 € / Monat'} | ${'10 € / Monat'} + ${'0.00 €'} | ${'0 €'} + ${'10.20 €'} | ${'10.20 €'} + `('should transform "$price" into "$expected"', ({ price, expected }) => { + expect(omitTrailingDecimalZeros(price)).toEqual(expected); + }); +}); + describe('parseDecimalValue', () => { it.each` value | expected diff --git a/src/money/formatters.ts b/src/money/formatters.ts index 30918b3b..771404ab 100644 --- a/src/money/formatters.ts +++ b/src/money/formatters.ts @@ -87,14 +87,12 @@ export const formatAmount = ({ format, locale = DEFAULT_LOCALE, enableSubunitDisplay = false, - omitTrailingDoubleZeros = false, }: { amount: number | string; currency?: Currency; format?: string; locale?: string; enableSubunitDisplay?: boolean; - omitTrailingDoubleZeros?: boolean; }): string => { const integerAmount = parseUnknownAmount(amount); @@ -117,10 +115,7 @@ export const formatAmount = ({ ); } - const effectiveFormat = - omitTrailingDoubleZeros && !dAmount.hasSubUnits() ? DEFAULT_SUBUNIT_FORMAT : format || DEFAULT_FORMAT; - - return dAmount.setLocale(locale).toFormat(effectiveFormat); + return dAmount.setLocale(locale).toFormat(format || DEFAULT_FORMAT); }; /** @@ -190,7 +185,6 @@ export const formatAmountFromString = ({ locale, useRealPrecision = false, enableSubunitDisplay = false, - omitTrailingDoubleZeros = false, }: { decimalAmount: string; precision?: number; @@ -199,7 +193,6 @@ export const formatAmountFromString = ({ locale?: string; useRealPrecision?: boolean; enableSubunitDisplay?: boolean; - omitTrailingDoubleZeros?: boolean; }): string => { /** * Decimal amounts can sometimes come in an invalid format, such as 1.000.000,00. @@ -225,19 +218,14 @@ export const formatAmountFromString = ({ ); const dSubunit = dineroObjectFromAmount.multiply(100).convertPrecision(precision ?? amountPrecision); - const subunitFormat = - omitTrailingDoubleZeros && !dSubunit.hasSubUnits() ? DEFAULT_SUBUNIT_FORMAT : format || amountFormat; - return formatWithSubunit(dSubunit.setLocale(locale || DEFAULT_LOCALE).toFormat(subunitFormat), subunit); + return formatWithSubunit(dSubunit.setLocale(locale || DEFAULT_LOCALE).toFormat(format || amountFormat), subunit); } - const hasNoSubUnits = !dineroObjectFromAmount.convertPrecision(precision ?? amountPrecision).hasSubUnits(); - const effectiveFormat = omitTrailingDoubleZeros && hasNoSubUnits ? DEFAULT_SUBUNIT_FORMAT : format || amountFormat; - return dineroObjectFromAmount .setLocale(locale || DEFAULT_LOCALE) .convertPrecision(precision ?? amountPrecision) - .toFormat(effectiveFormat); + .toFormat(format || amountFormat); }; /** @@ -359,6 +347,26 @@ function shouldDisplayAmountAsCents(amount: number, currency?: Currency) { return dAbsoluteAmount.hasSubUnits() && dAbsoluteAmount.lessThan(dAmountOfOneUnit); } +/** + * Removes trailing decimal zeros (.00 or ,00) from a formatted price string. + * Handles prices with currency symbols, billing period suffixes, and tiered pricing unit suffixes (e.g. €10.00/Stück). + * + * @param price - The formatted price string + * @returns The price string without trailing decimal zeros + */ +export const omitTrailingDecimalZeros = (price: string): string => { + const trailingZerosWithDot = /(\.00)(\s.*)?$/; + const trailingZerosWithComma = /(,00)(\s.*)?$/; + const trailingZerosWithDotBeforeSlash = /(\.00)(\/[\w\W]*)$/; + const trailingZerosWithCommaBeforeSlash = /(,00)(\/[\w\W]*)$/; + + return price + .replace(trailingZerosWithDot, '$2') + .replace(trailingZerosWithComma, '$2') + .replace(trailingZerosWithDotBeforeSlash, '$2') + .replace(trailingZerosWithCommaBeforeSlash, '$2'); +}; + /** * Converts a decimal string value into a valid decimal amount value, without any thousand separators, using dot as the decimal separator. *