Описание проблемы

Достаточно часто в реализации логики есть необходимость оперировать денежными единицами.

В коде приходится сталкиваться с таким представлениями:

  • значение в типе String

  • значение числом типа -  int, float,double

  • значение числом BigDecimal с разными правилами округления

  • отсутствие валюты

  • валюта отдельным полем в String

  • значение и валюта одной строкой в String

Это приводит к:

  • потери точности после запятой

  • накопление погрешностей при операциях

  • ошибкам округления

  • невозможности конвертации валют

  • невозможности в принципе проводить вычислительные операции

Иногда в коде встречаются сразу несколько вариантов представления денежных значений.

Пример:

// Проблемный код
double price1 = 0.1;
double price2 = 0.2;
System.out.println(price1 + price2); // 0.30000000000000004 - Погрешность!

BigDecimal cost = new BigDecimal("100.00");
BigDecimal discount = new BigDecimal("30.00");
BigDecimal result = cost.divide(discount); // ArithmeticException: Non-terminating decimal expansion

Объекты Money и Currency

Для формализованной работы с деньгами в Java существует спецификация JSR-354 (Java Specification Request). Эта спецификация и библиотеки предоставляют:

  • интерфейс MonetaryAmount - для представления денежных единиц в валюте

  • интерфейс CurrencyUnit - для представления валюты

  • арифметические операции с деньгами

  • округления - несколько вариантов

  • конвертацию валют

  • формат представления денежных единиц с валютой с локализацией

Официальной реализацией (Reference Implementation) стандарта JSR-354 является библиотека Moneta.

Библиотека предоставляет две реализации:

  • Money: основан на BigDecimal. Обеспечивает высокую точность (до 2^63 десятичных знаков) и гибкость. Рекомендуется по умолчанию для большинства бизнес-приложений.

  • FastMoney: основан на long. Обеспечивает фиксированную точность (15 десятичных знаков) и работает в ~15 раз быстрее, чем Money. Потребляет меньше памяти. Идеален для высоконагруженных систем, где операции с деньгами являются узким местом, и где точности в 15 знаков достаточно

Все возможности реализации можете посмотреть в документации или в коде библиотеки. Здесь же я опишу, как применять объекты, выполнять сериализацию/десериализацию, конвертировать для хранения в БД, выполнять конвертацию валют, форматировать представление и выполнять тонкую настройку объектов.

Подключение и использование

Основные объекты типы с которыми придется работать в коде это:

  • MonetaryAmount

  • CurrencyUnit

Подключение библиотеки

<!-- https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-core -->
<dependency>
    <groupId>org.javamoney</groupId>
    <artifactId>moneta</artifactId>
    <type>pom</type>
</dependency>
<!-- https://mvnrepository.com/artifact/org.javamoney.moneta/moneta-core -->
<dependency>
    <groupId>org.javamoney.moneta</groupId>
    <artifactId>moneta-core</artifactId>
</dependency>

Настройка библиотеки

При запуске приложения будет выдаваться предупреждения, что не настроен MathContext и будет применен DefaultMathContext.

Контекст отвечает за настройки точности числового значения денежных единиц и способа округления.

Чтобы настроить контекст в Spring приложении, нужно добавить в ресурсы javamoney.properties файл. Минимальные параметры, которые нужно задать:

  • org.javamoney.moneta.Money.defaults.precision=DECIMAL128

  • org.javamoney.moneta.Money.defaults.roundingMode=HALF_EVEN

Эти настройки будут применяться ко всем создаваемым объектам MonetaryAmount. При необходимости можно переопределить конфигурацию в моменте создания экземпляра объекта.

Примеры создания

Валюта

CurrencyUnit currencyEUR = Monetary.getCurrency("EUR");

или с применением объекта Locale

CurrencyUnit currencyUSD = Monetary.getCurrency(Locale.US);

Так же можно создавать свои валюты и регистрировать их для применения. Например для работы с BitCoin или валютами непризнанных республик. Можете добавить их локализованные названия, числовые коды валют, буквенные представления и символьные глифы. Примеры смотрите в документации.

Денежная единица

Как уже говорилось выше, в библиотеки есть 2 реализации интерфейса MonetaryAmount. Это:

  • Money

  • FastMoney

Различия в реализациях и случаях применения читайте в документации. Вкратце скажу, что у них разная точность, занимаемый объем памяти и скорость работы.

Статическая фабрика для Money:

final var money = Money.of(200.20, "USD");

Статическая фабрика для FastMoney:

final var fastMoney = FastMoney.of(200.20, "USD");

Так же можете создать экземпляр с настроенным MathContext в месте:

final var money = Monetary.getAmountFactory(Money.class)
     .setCurrencyUnit("CHF").setNumber(200)
     .setContext(MonetaryContextBuilder.of()
          .set(MathContext.DECIMAL128).build())
     .create();

И примеры использования:

final MonetaryAmount amount1 = Money.of(100, "USD");
final MonetaryAmount amount2 = Money.of(50, "USD");

// Сложение
final MonetaryAmount sum = amount1.add(amount2); // 150 USD

// Вычитание
final var diff = amount1.subtract(amount2); // 50 USD

// Умножение на скаляр
final var multiplied = amount1.multiply(2.5); // 250 USD

// Деление на скаляр
final var divided = amount1.divide(2); // 50 USD

// Сравнение
boolean isGreater = amount1.isGreaterThan(amount2); // true

Хранение в БД

В реализации библиотеки нет инструментов, для хранения типа MonetaryAmount в БД.

Попробуем сами разобраться. Глянем на статические фабрики - они требуют 2 параметра: значение и валюту или локаль. Потому и в БД хранить лучше 2 отдельных поля.

Общие принципы

Практически в 99% случаев лучшей стратегией является раздельное хранение точной суммы и кода валюты в отдельных полях. Это решает все проблемы с точностью, обеспечивает возможность формировать любые запросы и является наиболее понятным и поддерживаемым подходом. (Почему именно так можем обсудить в комментах или поговорите с ИИ)

Однако для поддержания необходимой точности, нужно использовать предназначенные для этого типы данных:

SQL (общее)

два отдельных поля

DECIMAL(19, 4) + CHAR(3)

PostgreSQL

два отдельных поля

NUMERIC(19, 4) + CHAR(3)

MongoDB

вложенный документ

amount: NumberDecimal(...), currency: string

Elasticsearch

вложенный объект

amount: scaled_float, currency: keyword

Приведу пример MongoDb. Для PostgreSql и ORM легко сделать по аналогии.

MongoDb

Идея очень простая:

  1. Делаем сам конвертер, которые раскладывает данные из MonetaryAmount в BSON документ. И в обратную сторону - из полей BSON документа создает статической фабрикой экземпляр MonetraryAmount

  2. Зарегистрировать класс конвертера в конфигурации MongoDb, чтобы Spring автоматом применял его.

Конвертер

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MonetaryAmountConversion {

    public static final String AMOUNT = "amount";
    public static final String CURRENCY = "currency";

    @ReadingConverter
    public enum ReadConverter implements Converter<Document, MonetaryAmount> {

        INSTANCE;

        @Nullable
        @Override
        public MonetaryAmount convert(@Nullable Document source) {
            if (source == null) {
                return null;
            }
            return Money.of(
                    requireNonNull(source.get(AMOUNT, Decimal128.class).bigDecimalValue()),
                    requireNonNull(source.getString(CURRENCY))
            );
        }
    }
   
  @WritingConverter
    public enum WriteConverter implements Converter<MonetaryAmount, Document> {

        INSTANCE;

        @Nullable
        @SneakyThrows
        @Override
        public Document convert(@Nullable MonetaryAmount source) {
            if (source == null) {
                return null;
            }
            final var document = new Document();
            document.put(AMOUNT, source.getNumber().numberValue(BigDecimal.class));
            document.put(CURRENCY, source.getCurrency().getCurrencyCode());
            return document;
        }
    }
  }

Регистрация конвертера

@Bean
MongoCustomConversions mongoCustomConversions() {
    return new MongoCustomConversions(
            List.of(
                    MonetaryAmountConversion.ReadConverter.INSTANCE,
                    MonetaryAmountConversion.WriteConverter.INSTANCE
            )
    );
}

Сериализация/Десериализация

Не менее важно обрабатывать и формировать транспорты для API.

С этим намного проще.

Подключение либы

За нас уже всё сделано и в хорошей реализации.

<!-- https://mvnrepository.com/artifact/org.zalando/jackson-datatype-money -->
 <dependency>
     <groupId>org.zalando</groupId>
     <artifactId>jackson-datatype-money</artifactId>
 </dependency>

Регистрация для Jackson

Достаточно зарегистрировать бин.

/**
  Регистрируем модуль сериализации для MonetaryAmount - JSR-354
 **/
@Bean
Module moneyModule() {
    return new MoneyModule();
}

В библиотеке есть множество конфигураций форматов сериализации. Единственная рекомендация - всегда разделять значение и валюту на отдельные поля.

Поддержка OpenApi

Для тех, кто генерирует OpenAPI документацию на основе контроллеров, важно описать трансляцию типа MonetaryAmount swagger схему.

Для этого нужно сконфигурировать описание для OpenApi

@Configuration
@SuppressWarnings("unchecked")
public class OpenApiConfiguration {

    static {
        // Представление MonetaryAmount в документации
        SpringDocUtils.getConfig().replaceWithSchema(MonetaryAmount.class, new ObjectSchema()
                .addProperty("amount", new NumberSchema()
                        .description("Сумма, выраженная в виде десятичного числа основных денежных единиц")
                        .format("decimal")
                        .example(99.96)
                )
                .addProperty("currency", new StringSchema()
                        .description("Трехбуквенный код валюты в соответствии с       ISO-4217")
                        .format("ISO-4217")
                        .example("USD")
                )
                .required(List.of("amount", "currency"))
                .description("Денежная единица")
        );
    }
}

Конвертация валют

Наверное наиболее полезная и обширная тема в применении реализации JSR-354 это конвертация валют на основе курсов за указанные даты.

SPI и Провайдеры

Money API предоставляет несколько SPI интерфейсов, реализовав которые, вы можете подключить любой источник курсов валют (например ЦБ РФ или Нацбанк РК). Так же в библиотеке уже присутствует несколько реализаций, которые могут стать хорошим примером для своих расширений. (Что такое SPI и как регистрировать реализации здесь рассказывать не буду).

Общий алгоритм идеи примерно такой:

  • Реализация LoaderService обновляет данные курсов валют с конкретного банка с заданным периодом. Нужно написать парсер данных и способ их кэширования.

  • Реализация ExchangeRateProvider регистрируется как SPI реализация и вызывается при использовании конкретного источника курсов валют. Описываем как по входящим параметрам получить данные из кэша и выполнить конвертацию

Такой подход позволяет бесшовно выполнять конвертации валют в рантайме приложения без сетевого доступа к источнику курсов валют. Обновление курсов валют происходит в фоновом режиме по настроенному вами расписанию.

Пример использования:

// Получаем провайдер курсов (например, от ECB)
ExchangeRateProvider ecbRateProvider = MonetaryConversions.getExchangeRateProvider("ECB");

// Создаем суммы для конвертации
MonetaryAmount amountInEur = Money.of(100, "EUR");
MonetaryAmount amountInUsd = Money.of(100, "USD");

try {
    // Конвертируем EUR -> USD
    MonetaryAmount convertedAmount = amountInEur.with(ecbRateProvider.getExchangeRate("EUR", "USD"));
    System.out.println(convertedAmount); // e.g., 110.05 USD

    // Сравниваем суммы в разных валютах
    boolean isEqual = amountInEur.isEqualTo(convertedAmount); // false
    boolean isEquivalent = amountInEur.equals(convertedAmount); // false (разная валюта)

} catch (CurrencyConversionException e) {
    // Обработка ситуации, когда курс для валютной пары не найден
    System.err.println("Курс конвертации не доступен: " + e.getMessage());
}

Стратегии обработки CurrencyConversionException

Данное исключение выбрасывается когда не найден курс по входящим параметрам: из валюты 1 в валюту 2 на дату d для конкретного провайдера.

Для корректной работы сервиса необходимо корректно обрабатывать данную ситуацию. Выбранная стратегия зависит от конкретных требований к вашему сервису и услуги, которую он предоставляет. Вот некоторый список стратегий:

Стратегия

Надежность

Сложность

Сценарий

Fail Fast

Высокая

Низкая

Критичные финансовые транзакции. Прерываем сразу

Fallback-провайдер

Очень высокая

Средняя

Высокие требования к доступности. Используем несколько источников данных

Кэширование

Высокая

Средняя/Высокая

Допустима работа на устаревших данных (предрасчеты, дашборды). Последний известный курс

Через кросс-валюту

Средняя/Высокая

Высокая

Работа с экзотическими валютами, отсутствие прямых пар

Default или Null-object

Низкая

Низкая

Некритичные, справочные операции

Заключение

Использование JSR-354 и библиотеки Moneta позволяет избавиться от целого класса ошибок, связанных с деньгами, стандартизировать код, упростить арифметические операции и конвертацию валют. Это современный и надежный подход для Java-приложений.

Помимо этого библиотека предоставляет расширения с уже готовыми финансовыми операциями такими как: расчет процента, расчет сложного процента, аннуитетные платежи и многое другое.

Ссылки

P.S.

Буду рад собрать готовые провайдеры для используемых банков в одном месте и оформить в библиотеку.

Всем больше ООП и меньше велосипедов!

Комментарии (0)