Проверка расчётов сумм в iOS: Decimal и правила округления

Проверка расчётов сумм в iOS: Decimal и правила округления

Проверка расчётов сумм в iOS: Decimal и правила округления

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

Сначала определите контракт денежных расчётов

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

Ставка скидки, цена за единицу и итоговая сумма — разные величины. После умножения цены на количество можно сохранить промежуточную точность, но итоговую сумму обязательно нужно пропустить через согласованную функцию расчёта. Не позволяйте каждому вызывающему участку кода самостоятельно форматировать сумму и преобразовывать её обратно в число.

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

Сначала выясните, что возвращает сервер: десятичную строку или целое число в минимальных денежных единицах. Если он возвращает число с плавающей точкой, сначала измените контракт API: клиент не может восстановить исходное значение после потери точности.

Создайте автономный пакет с правилами расчёта

Создайте библиотечный пакет Swift в пустом каталоге. Поместите правила в сгенерированный исходный файл, а тесты — в соответствующий каталог тестов. Выполните команды в каталоге проекта на облачном Mac; запускать симулятор не нужно.

mkdir MoneyRules
cd MoneyRules
swift package init --type library --name MoneyRules
swift --version

Замените содержимое Sources/MoneyRules/MoneyRules.swift следующей реализацией. Она задаёт фиксированную локаль для разбора, ограничивает длину ввода и отклоняет разделители групп разрядов, экспоненциальную запись, пробельные символы и лишние знаки после десятичного разделителя. Этот код обрабатывает данные API, а не локализованный пользовательский ввод.

import Foundation

public enum MoneyRules {
    public static func parse(_ text: String) -> Decimal? {
        guard text.utf8.count <= 20,
              text.range(
                  of: #"^-?[0-9]+(?:\.[0-9]{1,6})?\z"#,
                  options: .regularExpression
              ) != nil else {
            return nil
        }
        return Decimal(
            string: text,
            locale: Locale(identifier: "en_US_POSIX")
        )
    }

    public static func settle(_ value: Decimal) -> Decimal {
        var input = value
        var output = Decimal()
        NSDecimalRound(&output, &input, 2, .plain)
        return output
    }
}

Не используйте Decimal(Double(text)!). Даже если конечный результат имеет тип Decimal, промежуточное преобразование в двоичное число с плавающей точкой уже изменяет исходное числовое представление. Ограничение длины тоже должно соответствовать реальному диапазону сумм в вашей системе: нельзя произвольно обрезать сумму ради прохождения тестов.

Выявляйте расхождения на середине интервала и в порядке расчётов

Запишите следующий код в Tests/MoneyRulesTests/MoneyRulesTests.swift, заменив стандартные тесты. Сравнивайте значения Decimal точно, без допуска на погрешность: иначе тест может сам скрыть расхождение в одну копейку.

import XCTest
@testable import MoneyRules

final class MoneyRulesTests: XCTestCase {
    private func number(_ text: String) throws -> Decimal {
        try XCTUnwrap(MoneyRules.parse(text))
    }

    func testMidpoints() throws {
        XCTAssertEqual(
            MoneyRules.settle(try number("1.005")),
            try number("1.01")
        )
        XCTAssertEqual(
            MoneyRules.settle(try number("-1.005")),
            try number("-1.01")
        )
        XCTAssertEqual(
            MoneyRules.settle(try number("1.004")),
            try number("1.00")
        )
    }

    func testRoundingStage() throws {
        let item = try number("0.335")
        let perLine = MoneyRules.settle(item) * Decimal(3)
        let total = MoneyRules.settle(item * Decimal(3))
        XCTAssertEqual(perLine, try number("1.02"))
        XCTAssertEqual(total, try number("1.01"))
    }

    func testRejectedInputs() {
        for text in ["", "1,25", "1e2", "NaN", " 1.00",
                     "1.0000001", "1.00\n"] {
            XCTAssertNil(MoneyRules.parse(text))
        }
    }
}

Выполните swift test и убедитесь, что все три теста проходят. Тест порядка расчётов не рекомендует какой-либо способ округления: он показывает, что результаты действительно различаются. После выбора бизнес-правила добавьте тесты на уровне заказа, чтобы цепочка вызовов не могла незаметно перейти на другой способ.

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

Проверяйте передачу и отображение отдельно

Передача данных через API не должна зависеть от форматирования интерфейса. Для отображения можно добавлять разделители групп разрядов или выбирать десятичный разделитель в зависимости от региона, но для передачи по-прежнему должна использоваться согласованная десятичная строка. Следующая проверка подтверждает, что результат расчёта можно преобразовать в строку и прочитать обратно без потери точности. Она не гарантирует наличие ровно двух знаков после десятичного разделителя.

import Foundation

var settled = MoneyRules.settle(MoneyRules.parse("12.345")!)
let wire = NSDecimalString(
    &settled,
    Locale(identifier: "en_US_POSIX")
)
precondition(MoneyRules.parse(wire) == settled)

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

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

Включите регрессионную проверку в пайплайн и сохраняйте проверяемые результаты

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

Список проверок должен быть коротким и однозначным:

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

Часто задаваемые вопросы

Достаточно ли заменить Double на Decimal?

Нет. Преобразование через Double и преждевременное округление сохраняют ошибки. Создавайте Decimal из проверенной десятичной строки и явно задавайте момент округления.

Нужен ли симулятор iOS для этих тестов?

Чистые вычисления на Foundation можно проверять командой swift test в пакете Swift. Ввод в интерфейсе, локализованное отображение и код только для iOS проверяются отдельно.

Передайте очередь сборки выделенному облачному Mac

Выберите физический узел, модель Mac и период оплаты. Запускайте сборки Xcode и задачи автоматизации в собственной среде macOS. Актуальный статус доступности отображается в консоли.

Продолжите проверку среды сборки

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