Skip to content

Commit

Permalink
Fix #657: Format Operation FormData Amount Attribute (#669)
Browse files Browse the repository at this point in the history
  • Loading branch information
banterCZ authored Feb 16, 2023
1 parent d70b6af commit 8799ffb
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 32 deletions.
5 changes: 5 additions & 0 deletions enrollment-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@
<artifactId>swagger-annotations</artifactId>
</dependency>

<dependency>
<groupId>org.javamoney.moneta</groupId>
<artifactId>moneta-core</artifactId>
</dependency>

<!-- For run at Apple M1 architecture -->
<dependency>
<groupId>io.netty</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -228,15 +226,22 @@ private static Optional<Attribute> buildAmountAttribute(final OperationTemplateP
if (amount.isEmpty()) {
return Optional.empty();
}
final String currency = fetchTemplateParamValue(templateParam, params, "currency").orElse(null);
final Optional<String> 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<Attribute> buildAmountConversionAttribute(final OperationTemplateParam templateParam, final Map<String, String> params) {
Expand All @@ -247,33 +252,46 @@ private static Optional<Attribute> 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<String> sourceCurrency = fetchTemplateParamValue(templateParam, params, "sourceCurrency");
final Optional<String> 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<Attribute> buildImageAttribute(final OperationTemplateParam templateParam, final Map<String, String> params) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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, [email protected]
*/
@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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -436,13 +438,14 @@ void testConvertAttributes() throws Exception {
" }\n" +
"]");

LocaleContextHolder.setLocale(new Locale("en"));
final Operation result = tested.convert(operationDetail, operationTemplate);

final List<Attribute> 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());
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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, [email protected]
*/
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);
}
}
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
<spring-statemachine.version>3.2.0</spring-statemachine.version>
<swagger-annotations.version>2.2.8</swagger-annotations.version>
<springdoc-openapi.version>1.6.14</springdoc-openapi.version>
<moneta.version>1.4.2</moneta.version>

<wultra-core.version>1.7.0-SNAPSHOT</wultra-core.version>
<powerauth-crypto.version>1.5.0-SNAPSHOT</powerauth-crypto.version>
Expand Down Expand Up @@ -194,6 +195,12 @@
<version>${powerauth-push.version}</version>
</dependency>

<dependency>
<groupId>org.javamoney.moneta</groupId>
<artifactId>moneta-core</artifactId>
<version>${moneta.version}</version>
</dependency>

<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
Expand Down

0 comments on commit 8799ffb

Please sign in to comment.