Наша команда периодически пополняется новыми людьми, которые «приносят с собой» новые ошибки. Поэтому мы регулярно проводим семинары с их разбором. Это хороший повод напомнить всем о правилах работы с суммами, объяснить новичкам зачем они нужны и, возможно, пополнить наш чек-лист с помощью которого мы проверяем код на типовые ошибки.

Вот один из примеров, который на таком семинаре разобрал наш рукводитель разработки сервисной цифровой платформы Сергей Богданов.

Что не так с этим кодом? 

@Component
class CreditTotalQtyCalculator {
    fun calculateWithInsurance(
        desiredAmount: BigDecimal,
        rateQty: BigDecimal,
        creditTerm: Int
    ): BigDecimal {
        val divisor = BigDecimal.ONE.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(
            BigDecimal(
                12
            )
        ))
        return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
    }
}

Почему он выдал ошибку на этапе прохождения тестирования перед выходом в пром?

import java.math.*;

public class Main {
    public static void main(String[] args) {
        var a = new BigDecimal(16);
        var b = new BigDecimal(12);
        var c = a.divide(b);
        System.out.println(c);
    }
}

Ошибка округления BigDecimal.Divide 

Проблема вот в чем: не указана точность округления при делении divide (это, кстати, есть в нашем чек-листе: «не используем суммы в виде чисел с плавающей точкой»).

Любые вычисления должны приводить также к точному числу, но в делении может образоваться бесконечная дробь.Поэтому когда используем BigDecimal, мы должны указать, что результат должен быть с фиксированной точностью.

Иначе что делать машине, если при делении получается бесконечная дробь. Например, если в формуле 16/12 = 4/3 – рациональное число для которого не существует точной записи в десятичной системе. Это бесконечная дробь, такую запись невозможно сделать. Происходит арифметическое исключение в функции BigDecimal, в которой параметр scale — неограниченный. 

В BigDecimal есть опасные методы, которые надо внимательно изучить, прежде чем использовать. Смотрим код дальше, что еще тут можно улучшить? Возник вопрос: зачем в этом коде делим на 12? Что это такое? Какая размерность? Зачем мы каждый раз это деление производим? Это вычисление можно вывести в переменную.

Решение: вводим переменную periodInMonth, описываем. Проверяем на условие положительности.

@Component
class CreditTotalQtyCalculator {
    fun calculateWithInsurance(
        desiredAmount: BigDecimal,
        rateQty: BigDecimal,
        creditTerm: Int
    ): BigDecimal {
        val periodInMonths = BigDecimal(12)
        val divisor = BigDecimal.ONE.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(periodInMonths))
        return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
    }
}

Размерность переменных

Важное правило при работе с денежными суммами: сумма — это не просто число, но и размерность. Рубли или копейки? Центы или доллары? Размерность должна включать валюту. Рядом с каждым значением должен быть указан тип и лучше «вэлью-объект», который содержит одновременно размерность суммы и валюты. И для каждой валюты используются своя размерность: для рублей — копейки, для биткоина — «сатоши». Она помогает понять, что делает метод, и не дает совершить ошибку (например, складывание рублей с долларами).

@Component
class CreditTotalQtyCalculator {
    fun calculateWithInsurance(
        desiredAmount: BigDecimal, // RUB
        rateQty: BigDecimal, // ???
        creditTerm: Int      // ???
    ): BigDecimal { // 1 - (rateQty * creditTerm) / periodInMonths
        val periodInMonth = BigDecimal(12)
        val one = BigDecimal.ONE // ???
        val divisor = one.minus(rateQty.multiply(BigDecimal(creditTerm)).divide(
            periodInMonth
        ))
        return desiredAmount.divide(divisor, 0, RoundingMode.HALF_UP)
    }
}

Здесь у 12 размерность — месяцы, у BigDecimal – рубли. А какая размерность у rateQty? А у creditTerm? Что за единица one?

В чек-листе разработчика есть правило: сумма всегда идет вместе с валютой. Для переменных можно завести класс или добавить к имени переменной слово, обозначающее единицы измерения. Например, у нас есть правило добавлять day/hour для времени.

Важно: сумма не должна быть отрицательной. Кредит на отрицательную сумму значит, что не банк выдает кредит клиенту, а наоборот. Поэтому сумма должна быть описана как положительная. 

Пример про размерность из практики: процессинг части клиентов, с которыми работает наш платежный хаб — в рублях, а другой части – в копейках. Почему так? Во-первых, это «тянется» из стандарта ISO 8583, который используется в терминалах MS и Visa. 

А во-вторых, когда системы создавались, не было альтернативы: не существовало чисел с фиксированной точкой, и нельзя было настроить точность и алгоритм округления. Поэтому самое простое решение — хранить в целых, в копейках

Вроде просто, но были и свои проблемы. К примеру, суммы больше 2^31 - 1 (больше 2 млрд копеек) не влезали в разрядность и приходилось создавать решение для округления.

Кстати. В нашем чек-листе есть правило, что все публичные классы и методы должны иметь комментарии, чтобы разобраться в коде мог любой. Сначала определяемся с задачей, описываем в комментариях как должен работать метод, а уже после этого корректируем реализацию.

Используем value0f для экономии

Еще  можно добавить value0f — фабричный метод, который позволяет переиспользовать частое значение и экономить на этом память. value0f — это стандартный шаблон, который называется lightweight.

    // Cache of common small BigDecimal values.
    private static final BigDecimal ZERO_THROUGH_TEN[] = {
        new BigDecimal(BigInteger.ZERO,       0,  0, 1),
        new BigDecimal(BigInteger.ONE,        1,  0, 1),
        new BigDecimal(BigInteger.TWO,        2,  0, 1),
        new BigDecimal(BigInteger.valueOf(3), 3,  0, 1),
        new BigDecimal(BigInteger.valueOf(4), 4,  0, 1),
        new BigDecimal(BigInteger.valueOf(5), 5,  0, 1),
        new BigDecimal(BigInteger.valueOf(6), 6,  0, 1),
        new BigDecimal(BigInteger.valueOf(7), 7,  0, 1),
        new BigDecimal(BigInteger.valueOf(8), 8,  0, 1),
        new BigDecimal(BigInteger.valueOf(9), 9,  0, 1),
        new BigDecimal(BigInteger.TEN,        10, 0, 2),
    };

Точность – в настройки

Важный момент. Если вчера нам точность была не нужна, а сегодня вдруг понадобилась (было неопределенное значение, а теперь — 7), то можно предположить, что завтра значение может снова измениться. Поэтому надо заранее предусмотреть возможность такого изменения и вынести точность в настройки.

Потери округления — рефакторим формулы

Еще в этом коде есть два деления и каждое требует округления. Два округления – это потеря точности, чем округлений меньше, тем лучше. Надо преобразовать формулу так, чтобы округление было одно:

@Component
class CreditTotalQtyCalculator {
    fun calculateWithInsurance(
        desiredAmount: BigDecimal, // RUB
        rateQty: BigDecimal, // ???
        creditTerm: Int      // ???
    ): BigDecimal { // 1 - (rateQty * creditTerm) / periodInMonths
        val periodInMonths = BigDecimal(12)
        val precision = 0
        return desiredAmount
            .multiply(periodInMonths)
            .divide(periodInMonths.minus(rateQty.multiply(BigDecimal.valueOf(creditTerm.toLong()))), 
                precision, RoundingMode.HALF_UP)
    }
}

Нам не нужно округление до 7, т.к. возвращаемый параметр с округлением до 0 десятых, т.к. вычисляется день.

Добавим про roundingMode.HALF_UP/EVEN/DOWN: в разных ситуациях округление может быть в определенную сторону. Чтобы и 5,5, и 6,5 не округлялось до 6, необходим отдельный параметр, описывающий каким должно быть это округление и почему. 

Код валюты

В нашей стране есть еще легаси-код валюты — RUR (код 810) – обозначение нашей валюты до 29.02.2004. Именно до этого времени планировали полностью провести деноминацию и завершить работу со старой валютой. В начем чек-лите прописано, что код валюты должен быть RUB (643):

Тут важно правило: когда принимаете из другой системы старое обозначение, нужно принять его как есть, переработать и в новом коде выдать RUB. При этом надо быть готовым к тому, что в какой-то момент ту систему отключат, а ваш код должен работать и без нее.

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


  1. Programmer74
    07.04.2022 20:25
    +4

    Почему бы не использовать вместо сырого BigDecimal более удобную обертку, которая еще и хранит информацию о самой валюте - Moneta (JSR 354) - Baeldung


  1. DmitryMurinov
    07.04.2022 20:54
    +7

    Также, важно что наши банки очень часто используют сумму с 4 знаками после запятой. В частности, это можно встретить в курсах валют.

    Из этого следует как минимум 2 вещи: 1. хранить сумму в копейках не является универсальным решением на такой случай. 2. чаще всего для BigDecimal в решениях для финансовых организаций можно встретить округление до 2 или 4 знаков.


  1. maxzh83
    07.04.2022 22:46
    +1

    А почему не используете операторы котлина? Методы BigDecimal превращают в нечитаемую кашу даже самую формулу. Именно поэтому у вас в коде комментарии с формулой.


  1. SpiderEkb
    08.04.2022 08:39
    +1

    Все куда проще, если для написания бизнеслогики использовать языки, специально для этого предназначенные. В частности, имеющие нативные типы данных с фиксированной точкой (при описании переменной сразу указывается разрядность и количество знаков после запятой).

    А справочник валют обязательно содержит количество миноритарных единиц - например, японская йена, итальянская лира, испанская песета, португальский эскудо вообще не имеют миноритарных единиц. А оманский реал, тунисский и бахрейнские динары содержат по 1000 миноритарных единиц...

    Соответственно, расчеты ведутся в миноритарных единицах, а представление - в мажоритарных в соответствии с количеством миноритарных по справочнику валют.


  1. makar_crypt
    09.04.2022 17:25

    не ну это смешно , ожидал о самой большой проблемы , это консистентность данные при Lazy Taskax с фронта , а тут... копания в пикселях


  1. bagger
    09.04.2022 22:51

    Помню лет 10 назад, при написании биллинга в одном из небольших телеком провайдеров, решал задачу распределения суммы потребления услуги за каждый день, тарификация которой была помесячно. Если делить месячную сумму в лоб, то из-за округления могла быть ситуация, когда сумма за каждый день потребления услуги была меньше или больше на несколько копеек, чем стоимость тарифного плана. В результате решил задачу с помощью написания функции, которая эту ошибку равномерно "размазывала" по всем дням так, чтобы сумма за месяц всегда была одинаковая.