diff --git a/enrollment-server/pom.xml b/enrollment-server/pom.xml index fe460c1df..aef0d1d48 100644 --- a/enrollment-server/pom.xml +++ b/enrollment-server/pom.xml @@ -145,6 +145,11 @@ swagger-annotations + + org.javamoney.moneta + moneta-core + + io.netty diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java index 6b7201e63..3f4a7f7a5 100644 --- a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverter.java @@ -31,15 +31,13 @@ import org.apache.commons.text.StringEscapeUtils; import org.apache.commons.text.StringSubstitutor; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import static java.util.stream.Collectors.toMap; @@ -228,15 +226,22 @@ private static Optional buildAmountAttribute(final OperationTemplateP if (amount.isEmpty()) { return Optional.empty(); } - final String currency = fetchTemplateParamValue(templateParam, params, "currency").orElse(null); + final Optional currency = fetchTemplateParamValue(templateParam, params, "currency"); + if (currency.isEmpty()) { + return Optional.empty(); + } + final BigDecimal amountRaw; try { - final BigDecimal amountValue = new BigDecimal(amount.get()); - // TODO (racansky, 2023-02-07, #657) implement formatting based on locale - return Optional.of(new AmountAttribute(id, text, amountValue, currency, amount.get(), currency)); + amountRaw = new BigDecimal(amount.get()); } catch (NumberFormatException ex) { logger.warn("Invalid number format: {}, skipping the AMOUNT attribute!", amount); return Optional.empty(); } + final Locale locale = LocaleContextHolder.getLocale(); + final String currencyRaw = currency.get(); + final String currencyFormatted = MonetaryConverter.formatCurrency(currencyRaw, locale); + final String amountFormatted = MonetaryConverter.formatAmount(amountRaw, currencyRaw, locale); + return Optional.of(new AmountAttribute(id, text, amountRaw, currencyRaw, amountFormatted, currencyFormatted)); } private static Optional buildAmountConversionAttribute(final OperationTemplateParam templateParam, final Map params) { @@ -247,33 +252,46 @@ private static Optional buildAmountConversionAttribute(final Operatio if (sourceAmount.isEmpty() || targetAmount.isEmpty()) { return Optional.empty(); } - final String sourceCurrency = fetchTemplateParamValue(templateParam, params, "sourceCurrency").orElse(null); - final String targetCurrency = fetchTemplateParamValue(templateParam, params, "targetCurrency").orElse(null); + final Optional sourceCurrency = fetchTemplateParamValue(templateParam, params, "sourceCurrency"); + final Optional targetCurrency = fetchTemplateParamValue(templateParam, params, "targetCurrency"); + if (sourceCurrency.isEmpty() || targetCurrency.isEmpty()) { + return Optional.empty(); + } + final boolean dynamic = fetchTemplateParamValue(templateParam, params, "dynamic") .map(Boolean::parseBoolean) .orElse(false); + final BigDecimal sourceAmountRaw; + final BigDecimal targetAmountRaw; try { - final BigDecimal sourceAmountValue = new BigDecimal(sourceAmount.get()); - final BigDecimal targetAmountValue = new BigDecimal(targetAmount.get()); - return Optional.of(AmountConversionAttribute.builder() - .id(id) - .label(text) - .dynamic(dynamic) - .sourceAmount(sourceAmountValue) - // TODO (racansky, 2023-02-07, #657) implement formatting based on locale - .sourceAmountFormatted(sourceAmount.get()) - .sourceCurrency(sourceCurrency) - .sourceCurrencyFormatted(sourceCurrency) - .targetAmount(targetAmountValue) - .targetAmountFormatted(targetAmount.get()) - .targetCurrency(targetCurrency) - .targetCurrencyFormatted(targetCurrency) - .build()); + sourceAmountRaw = new BigDecimal(sourceAmount.get()); + targetAmountRaw = new BigDecimal(targetAmount.get()); } catch (NumberFormatException ex) { logger.warn("Invalid number format: {}, skipping the AMOUNT_CONVERSION attribute!", sourceAmount); return Optional.empty(); } + + final Locale locale = LocaleContextHolder.getLocale(); + final String sourceCurrencyRaw = sourceCurrency.get(); + final String targetCurrencyRaw = targetCurrency.get(); + final String sourceCurrencyFormatted = MonetaryConverter.formatCurrency(sourceCurrencyRaw, locale); + final String targetCurrencyFormatted = MonetaryConverter.formatCurrency(targetCurrencyRaw, locale); + final String sourceAmountFormatted = MonetaryConverter.formatAmount(sourceAmountRaw, sourceCurrencyRaw, locale); + final String targetAmountFormatted = MonetaryConverter.formatAmount(targetAmountRaw, targetCurrencyRaw, locale); + return Optional.of(AmountConversionAttribute.builder() + .id(id) + .label(text) + .dynamic(dynamic) + .sourceAmount(sourceAmountRaw) + .sourceAmountFormatted(sourceAmountFormatted) + .sourceCurrency(sourceCurrencyRaw) + .sourceCurrencyFormatted(sourceCurrencyFormatted) + .targetAmount(targetAmountRaw) + .targetAmountFormatted(targetAmountFormatted) + .targetCurrency(targetCurrencyRaw) + .targetCurrencyFormatted(targetCurrencyFormatted) + .build()); } private static Optional buildImageAttribute(final OperationTemplateParam templateParam, final Map params) { diff --git a/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java new file mode 100644 index 000000000..74990bb45 --- /dev/null +++ b/enrollment-server/src/main/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverter.java @@ -0,0 +1,95 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.enrollmentserver.impl.service.converter; + +import lombok.extern.slf4j.Slf4j; + +import javax.money.CurrencyUnit; +import javax.money.Monetary; +import javax.money.UnknownCurrencyException; +import java.math.RoundingMode; +import java.text.NumberFormat; +import java.util.Currency; +import java.util.Locale; + +/** + * Convert currency and amount. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +@Slf4j +class MonetaryConverter { + + private static final int DEFAULT_MINIMAL_FRACTION_DIGITS = 2; + private static final int MAXIMAL_FRACTION_DIGITS = 18; + + + private MonetaryConverter() { + // hidden constructor + } + + /** + * Convert the given currency code and locale. + * If there is no specific representation for the given currency and locale, original {@code code} is returned. + * + * @param code code to format + * @param locale locale to be used for the conversion + * @return localized currency or original code if there is no mapping available + */ + static String formatCurrency(final String code, final Locale locale) { + try { + // TODO (racansky, 2023-02-16) we should rely on javax.money.CurrencyUnit instead of java.util.Currency, but there is no support for display name yet + // https://github.com/JavaMoney/jsr354-api/issues/58 + return Currency.getInstance(code).getSymbol(locale); + } catch (final IllegalArgumentException e) { + logger.debug("No currency mapping for code={}, locale={}", code, locale); + logger.trace("No currency mapping for code={}, locale={}", code, locale, e); + return code; + } + } + + /** + * Convert the given amount according to the given code and locale. + * Amount is rounded down if limit of maximum fraction digits reached. + * + * @param amount amount to format + * @param code currency code + * @param locale locale to be used for the conversion + * @return formatted amount + */ + static String formatAmount(final Number amount, final String code, final Locale locale) { + final int fractionDigits = getFractionDigits(code); + + final NumberFormat format = NumberFormat.getInstance(locale); + format.setMinimumFractionDigits(fractionDigits); + format.setMaximumFractionDigits(MAXIMAL_FRACTION_DIGITS); + format.setRoundingMode(RoundingMode.DOWN); + return format.format(amount); + } + + private static int getFractionDigits(String code) { + try { + final CurrencyUnit currencyUnit = Monetary.getCurrency(code); + return currencyUnit.getDefaultFractionDigits(); + } catch (UnknownCurrencyException e) { + logger.debug("No currency mapping for code={}, most probably not FIAT", code); + logger.trace("No currency mapping for code={}", code, e); + return DEFAULT_MINIMAL_FRACTION_DIGITS; + } + } +} diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java index d7266f7b4..e0d675c94 100644 --- a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MobileTokenConverterTest.java @@ -26,9 +26,11 @@ import com.wultra.security.powerauth.lib.mtoken.model.entity.*; import com.wultra.security.powerauth.lib.mtoken.model.entity.attributes.*; import org.junit.jupiter.api.Test; +import org.springframework.context.i18n.LocaleContextHolder; import java.math.BigDecimal; import java.util.List; +import java.util.Locale; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -360,7 +362,7 @@ void testConvertAttributes() throws Exception { .put("thumbnailUrl", "https://example.com/123_thumb.jpeg") .put("originalUrl", "https://example.com/123.jpeg") .put("sourceAmount", "1.26") - .put("sourceCurrency", "ETC") + .put("sourceCurrency", "ETH") .put("targetAmount", "1710.98") .put("targetCurrency", "USD") .put("dynamic", "true") @@ -436,13 +438,14 @@ void testConvertAttributes() throws Exception { " }\n" + "]"); + LocaleContextHolder.setLocale(new Locale("en")); final Operation result = tested.convert(operationDetail, operationTemplate); final List attributes = result.getFormData().getAttributes(); assertEquals(7, attributes.size()); final var atributesIterator = attributes.iterator(); - assertEquals(new AmountAttribute("operation.amount", "Amount", new BigDecimal("13.7"), "EUR", "13.7", "EUR"), atributesIterator.next()); + assertEquals(new AmountAttribute("operation.amount", "Amount", new BigDecimal("13.7"), "EUR", "13.70", "€"), atributesIterator.next()); assertEquals(new KeyValueAttribute("operation.account", "To Account", "AT483200000012345864"), atributesIterator.next()); assertEquals(new NoteAttribute("operation.note", "Note", "Remember me"), atributesIterator.next()); assertEquals(new HeadingAttribute("operation.heading", "Heading"), atributesIterator.next()); @@ -453,12 +456,12 @@ void testConvertAttributes() throws Exception { .dynamic(true) .sourceAmount(new BigDecimal("1.26")) .sourceAmountFormatted("1.26") - .sourceCurrency("ETC") - .sourceCurrencyFormatted("ETC") + .sourceCurrency("ETH") + .sourceCurrencyFormatted("ETH") .targetAmount(new BigDecimal("1710.98")) - .targetAmountFormatted("1710.98") + .targetAmountFormatted("1,710.98") .targetCurrency("USD") - .targetCurrencyFormatted("USD") + .targetCurrencyFormatted("$") .build(), atributesIterator.next()); assertEquals(new PartyAttribute("operation.partyInfo", "Party Info", PartyInfo.builder() .logoUrl("https://example.com/img/logo/logo.svg") diff --git a/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java new file mode 100644 index 000000000..18b1e49dc --- /dev/null +++ b/enrollment-server/src/test/java/com/wultra/app/enrollmentserver/impl/service/converter/MonetaryConverterTest.java @@ -0,0 +1,77 @@ +/* + * PowerAuth Enrollment Server + * Copyright (C) 2023 Wultra s.r.o. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.wultra.app.enrollmentserver.impl.service.converter; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.math.BigDecimal; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Test for {@link MonetaryConverter}. + * + * @author Lubos Racansky, lubos.racansky@wultra.com + */ +class MonetaryConverterTest { + + @ParameterizedTest + @CsvSource({ + "CZK, en, CZK", + "CZK, cs, Kč", + "USD, en, $", + "USD, cs, US$", + "UYU, cs, UYU", + "UYU, en, UYU", + "UYU, uy, UYU", + "CAD, cs, CA$", + "CAD, ca, CAD", + "CAD, en, CA$", + "NZD, en, NZ$", + "NZD, cs, NZ$", + "NZD, nz, NZ$", + "BTC, cs, BTC" + }) + void testFormatCurrency(final String source, final String locale, final String expected) { + final String result = MonetaryConverter.formatCurrency(source, new Locale(locale)); + assertEquals(expected, result); + } + + @ParameterizedTest + @CsvSource({ + "1710.9817, CZK, en, '1,710.9817'", + "1710.9817, CZK, cs, '1 710,9817'", + "1710, CZK, cs, '1 710,00'", + "1710, USD, en, '1,710.00'", + "1710.1, CZK, cs, '1 710,10'", + "1710.1, USD, en, '1,710.10'", + "1710, JPY, jp, '1,710'", + "1710, JPY, en, '1,710'", + "1710, JPY, cs, '1 710'", + "1, BTC, en, '1.00'", + "1.1, BTC, en, '1.10'", + "0.123456789, BTC, en, '0.123456789'", + "0.567567567567567567567, BTC, en, '0.567567567567567567'" + }) + void testFormatAmount(final String amount, final String code, final String locale, final String expected) { + final String result = MonetaryConverter.formatAmount(new BigDecimal(amount), code, new Locale(locale)); + assertEquals(expected, result); + } +} diff --git a/pom.xml b/pom.xml index ced5f8da2..dbb371c80 100644 --- a/pom.xml +++ b/pom.xml @@ -99,6 +99,7 @@ 3.2.0 2.2.8 1.6.14 + 1.4.2 1.7.0-SNAPSHOT 1.5.0-SNAPSHOT @@ -194,6 +195,12 @@ ${powerauth-push.version} + + org.javamoney.moneta + moneta-core + ${moneta.version} + + io.swagger.core.v3 swagger-annotations