Как Работать С Деньгами Или Суммами Денег В Бэкэнд-Разработке

Наша команда периодически пополняется новыми людьми, которые «приносят с собой» новые ошибки.

Поэтому мы регулярно проводим семинары по их обсуждению.

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

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



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

  
  
  
  
  
   

@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

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

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

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



Как работать с деньгами или суммами денег в бэкэнд-разработке

В противном случае, что должна делать машина, если в результате деления получается бесконечная дробь? Например, если в формуле 16/12 = 4/3 — рациональное число, для которого нет точной записи в десятичной системе.

Это бесконечная дробь; такая запись не может быть сделана.

Арифметическое исключение возникает в функции BigDecimal, где параметр масштаба не ограничен.

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? А как насчет кредитТерм? Что это за единица? В чек-листе разработчика есть правило: сумма всегда идет вместе с валютой.

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

Например, у нас есть правило добавлять день/час ко времени.

Важно: сумма не должна быть отрицательной.

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

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

Почему это? Во-первых, он «вытягивается» из стандартного ИСО 8583 , который используется в терминалах MS и Visa. А во-вторых, когда системы создавались, альтернативы не было: не было чисел с фиксированной точкой, невозможно было настроить точность и алгоритм округления.

Поэтому самое простое решение — хранить целиком, по копейкам.

Это казалось простым, но имело свои проблемы.

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

Кстати.

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



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

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

value0f — это стандартный шаблон, называемый облегченным.



// 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 в новом коде.

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

Теги: #программирование #вебинар #java #Kotlin

Вместе с данным постом часто просматривают: