Перейти к содержимому
Поиск
Разработка

Brick\Money: точные суммы в PHP — мастерская и 9 практических опытов

Считаем суммы в PHP без потери копеек: девять настоящих опытов, интерактивная модель, округление, распределение, корзина и хранение в SQLite. Исходники для Windows и Linux.

Александр Некрасов
Brick Money — точные суммы в PHP, округление и распределение

Точные денежные суммы в PHP: строки вместо float, округление, распределение, корзина, валюты и SQLite. Девять PHP-опытов, интерактивная модель и исходники для Windows и Linux.

Что такое Brick\Money и какую проблему она решает

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

Brick\Money — PHP-библиотека для денежных значений. Вместе с суммой она хранит валюту, выполняет точную арифметику и требует явно выбрать округление, когда результат не помещается в нужное количество дробных знаков. Она помогает сделать вычисление воспроизводимым: одинаковые исходные строки и одинаковая политика дают одинаковый результат.

В этой статье вы соберёте CLI-корзину, увидите девять настоящих PHP-опытов и поиграете с отдельной интерактивной моделью. Разберём не только вызовы методов, но и путь суммы через форму, JSON, базу данных и интерфейс. Готовый проект работает без полного фреймворка; установка и проверки описаны для Windows и Linux.

Где применять: корзины, отчёты, распределение и интеграции

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

Отдельный сценарий — распределить один итог между участниками или позициями. Например, скидка заказа должна попасть в строки документа так, чтобы сумма скидок точно совпала с общей. Здесь нужно решить, кому достанутся остаточные копейки, и сохранить порядок. Это техническое правило обработки данных, которое лучше выразить одним понятным алгоритмом, чем несколькими случайными вызовами round().

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

Мини-мастерская: изменяйте значения и сравнивайте результаты

Верхняя часть мастерской содержит четыре редактируемых режима: сложение, округление, распределение и корзину. Поменяйте 2.345 на −2.345 и сравните Ceiling с Floor. Затем распределите 1.00 по весам 2, 3, 1 двумя алгоритмами. Результат можно скачать как JSON, а PHP-рецепт с выбранными параметрами — запустить из корня проекта.

Эта интерактивная часть написана на JavaScript BigInt и прямо помечена как учебная модель. PHP в браузере не исполняется. Ниже расположены девять записей настоящих запусков Brick\Money: у каждой есть задача, объяснение, код и скачиваемый результат. Команда composer verify повторяет PHP-вычисления и сравнивает их с записью, поэтому разделение модели и библиотеки можно проверить самостоятельно.

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

Почему float неудобен для денежных вычислений

Некоторые обычные десятичные дроби не имеют конечной записи в двоичной системе. Поэтому 0.1 + 0.2 в нашем PHP-опыте, выведенное с семнадцатью знаками, становится 0.30000000000000004. Это особенность представления числа, а не случайная поломка PHP. Официальный раздел о float описывает ограничения точности и сравнения таких значений.

printf('%.17f', 0.1 + 0.2);
// 0.30000000000000004

use Brick\Money\Money;
$total = Money::of('0.10', 'RUB')->plus('0.20');
echo $total; // RUB 0.30

Округлить красивый вывод недостаточно, если промежуточный результат уже участвовал в скидке, сравнении или распределении. Хорошая граница — принять каноническую десятичную строку, проверить её и сохранить точное представление на всём пути. Если сначала преобразовать ввод в float и лишь потом создать Money, исходные цифры могут быть уже потеряны.

Целые minor units тоже удобны, но требуют согласованной валюты, масштаба и диапазона. В PHP и базе есть ограничения целых типов, а в браузере точный диапазон Number ещё уже. Библиотека помогает работать с большими значениями, но сериализация и схема хранения всё равно остаются частью решения.

Версии, требования и установка через Composer

В проекте закреплены Brick\Money 0.15.2 и brick/math 1.0.0; библиотеке требуется PHP 8.2 или новее. Версия и зависимости проверены по Packagist, а код примеров — на установленной версии. Ветка 0.x может менять API при переходе к следующему 0.x, поэтому lock-файл здесь имеет практический смысл.

composer require brick/money:0.15.2
composer show brick/money
composer show brick/math
composer check-platform-reqs

Для арифметики Brick\Math может использовать GMP или BCMath и имеет реализацию на PHP. Наш проект дополнительно требует intl для демонстрации локализации и PDO SQLite для хранения. Эти расширения не следует путать с обязательными возможностями каждой установки самой библиотеки. Подробности зависимостей и автозагрузки разобраны в статье Composer: команды и работа с PHP-проектом.

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

Опыт 1. Создание Money и неизменяемость объекта

Первый опыт складывает две строки: 0.10 и 0.20. Итог равен 0.30, а исходный объект по-прежнему содержит 0.10. Такая неизменяемость удобна в расчётах: переданная в другую функцию цена не меняется неожиданно из-за операции в соседнем участке кода.

$price = Money::of('19.90', 'RUB');
$withExtra = $price->plus('4.99');
echo $price;     // RUB 19.90
echo $withExtra; // RUB 24.89

$quantity = 3;
$lineTotal = $price->multipliedBy($quantity);

Практическая ошибка — вызвать plus(), проигнорировать возвращённое значение и ждать изменения price. В нашем коде каждый промежуточный итог получает отдельное имя: subtotal, discount, delivery, total. Эти имена помогают увидеть порядок расчёта при ревью и сопоставить числа с полями интерфейса.

В контроллере удобно создавать Money после проверки входных данных, а в доменную функцию передавать уже корректное значение. Обратное превращение в строку оставляют для границы API, БД или отображения. Так случайные арифметические операторы над сырым вводом появляются реже.

Опыт 2. Minor units и валюта с другим масштабом

Метод ofMinor() получает число минимальных единиц валюты. Для RUB значение 12345 означает 123.45, для JPY 1234 остаётся 1234, а для KWD 12345 превращается в 12.345. Поэтому универсальное деление любого числа на сто не является правильным восстановлением суммы для всех валют.

$rub = Money::ofMinor('12345', 'RUB'); // RUB 123.45
$jpy = Money::ofMinor('1234', 'JPY');  // JPY 1234
$kwd = Money::ofMinor('12345', 'KWD'); // KWD 12.345
$large = Money::ofMinor('9007199254740993', 'RUB');
echo $large->getAmount(); // 90071992547409.93

В записи опыта большое число передано строкой намеренно: оно уже выходит за безопасный целочисленный диапазон JavaScript Number. Если API отдаст его как JSON-число, обычный браузерный разбор способен изменить последнюю цифру ещё до показа. Для обмена выбирайте строковое поле amountMinor и отдельный код currency.

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

Опыт 3. HalfUp, HalfEven, Ceiling и Floor

В третьем опыте сумма 2.345 должна поместиться в два дробных знака. HalfUp даёт 2.35, HalfEven — 2.34. На отрицательном числе Ceiling даёт −2.34, а Floor — −2.35. Название «округление вверх» без уточнения направления оказывается недостаточным, когда появляются возвраты или корректировки.

use Brick\Math\RoundingMode;

$a = Money::of('2.345', 'RUB', roundingMode: RoundingMode::HalfUp);
$b = Money::of('2.345', 'RUB', roundingMode: RoundingMode::HalfEven);
$c = Money::of('-2.345', 'RUB', roundingMode: RoundingMode::Ceiling);
// 2.35, 2.34, -2.34

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

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

Unnecessary: когда отсутствие округления должно стать ошибкой

Если операция обязана иметь точный результат, полезно не добавлять округление «на всякий случай». Money::of с 2.345 RUB без выбранного режима выбрасывает RoundingNecessaryException. Это сигнал: входные данные или правило преобразования не согласованы с точностью значения.

use Brick\Math\Exception\RoundingNecessaryException;

try {
    $price = Money::of('2.345', 'RUB');
} catch (RoundingNecessaryException $e) {
    // Return a field validation error in your application.
    echo 'У цены больше допустимых дробных знаков';
}

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

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

Опыт 4. Сумма без валюты неполна

Четвёртая запись показывает успешное сложение двух RUB и отказ при смешении RUB с USD. Это не мешающее ограничение, а защита смысла значения: десять рублей и десять долларов — разные величины даже при одинаковом поле amount.

use Brick\Money\Exception\CurrencyMismatchException;

$a = Money::of('12.00', 'RUB');
$b = Money::of('8.00', 'USD');
try {
    $a->plus($b);
} catch (CurrencyMismatchException $e) {
    echo 'Нужна явная конвертация с выбранным курсом';
}

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

В учебной корзине разрешены только RUB, USD и EUR. Это осознанное ограничение примера, а не список валют библиотеки. Такой небольшой набор упрощает объяснение двух дробных знаков; опыты с JPY и KWD отдельно показывают, почему ограничение необходимо проговаривать.

Опыт 5. Как распределить сумму и не потерять копейки

Возьмём 1.00 и веса 2, 3, 1. После обычного округления отдельных долей легко получить другой общий итог. Allocation сначала вычисляет доли, затем обрабатывает остаток выбранным алгоритмом. FloorToLargestRemainder в нашем опыте даёт 0.33, 0.50, 0.17; сумма снова равна 1.00.

use Brick\Money\AllocationMode;

$shares = Money::of('1.00', 'RUB')->allocate(
    [2, 3, 1],
    AllocationMode::FloorToLargestRemainder
);
foreach ($shares as $share) {
    echo $share . PHP_EOL;
}

FloorToFirst для того же набора даст 0.34, 0.50, 0.16: остаток идёт первым участникам. В версии 0.15.2 даже участник с нулевым весом может получить остаточную копейку в этом режиме. Для долей 0, 2, 1 результат составит 0.01, 0.66, 0.33. Это поведение явно описано в AllocationMode и проверено нашим тестом.

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

Равные части, отдельный остаток и контекст наличных

Для равных частей есть split(). В нашем опыте 1.00 делится на троих как 0.34, 0.33, 0.33 при SplitMode::ToFirst. В режиме Separate возвращаются три доли 0.33 и ещё один элемент 0.01. Этот дополнительный элемент — остаток, его нельзя бездумно показать как четвёртого участника.

use Brick\Money\SplitMode;
use Brick\Money\Context\CashContext;

$parts = Money::of('1.00', 'RUB')->split(3, SplitMode::ToFirst);
$cash = Money::of('1.03', 'CHF', new CashContext(5), RoundingMode::HalfUp);
echo $cash; // CHF 1.05

Контекст задаёт представимые значения. В небольшом дополнительном тесте CashContext с шагом пять минимальных единиц округляет 1.03 CHF до 1.05. Это иллюстрация работы шага; библиотека не решает, какое правило расчёта наличными применимо к вашему документу или юрисдикции.

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

Опыт 6. Полная корзина со скидкой и доставкой

В архиве есть две товарные строки: три единицы по 19.99 и две по 5.55. Подытог равен 71.07. Учебная скидка 750 базисных пунктов означает 7,5%; она вычисляется на всём подытоге и один раз округляется до 5.33. Затем добавляется доставка 4.90, и итог становится 70.64.

$subtotal = Money::of('19.99', 'RUB')->multipliedBy(3)
    ->plus(Money::of('5.55', 'RUB')->multipliedBy(2));
$discount = $subtotal->multipliedBy(750)
    ->dividedBy(10000, RoundingMode::HalfUp);
$total = $subtotal->minus($discount)->plus('4.90');
echo $total; // RUB 70.64

Базисные пункты здесь позволяют представить процент целым числом. Но это не делает все политики одинаковыми: скидка на каждую строку отдельно может отличаться от скидки на общий подытог. В коде Cart::calculate порядок записан явно, а в ответе есть поле roundingPolicy.

Для настоящего магазина присланная браузером цена не должна становиться доверенной. Клиент обычно передаёт идентификатор товара и количество, а сервер повторно получает цену, проверяет скидку и рассчитывает итог. Мастерская помогает увидеть арифметику; авторизация заказа, остатки и платёжный процесс требуют отдельной реализации.

Рабочий CLI: подайте JSON и получите проверяемый результат

Файл bin/cart.php читает корзину из аргумента командной строки, по умолчанию — fixtures/cart.json. Он ограничивает размер входа, проверяет структуру, вызывает Cart::calculate и печатает JSON. При ошибке сообщение идёт в stderr, процесс возвращает код 1; успешный результат идёт в stdout с кодом 0.

php bin/cart.php fixtures/cart.json
# Bash: save to a new file
php bin/cart.php fixtures/cart.json > result.json
# PowerShell
php .\bin\cart.php .\fixtures\cart.json | Set-Content -Encoding utf8 .\result.json

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

Учебный контракт разрешает 1–50 строк, целое количество от 1 до 1000, скидку от 0 до 10000 bps и неотрицательные цены до миллиона. Эти пределы принадлежат примеру. В реальном сервисе их подбирают по допустимым документам, ожидаемому объёму и правилам проверки входа.

Опыт 7. RationalMoney и отложенное округление

Разделим 10.00 на три и сразу округлим до 3.33. После умножения на три получится 9.99. Если промежуточную дробь сохранить точно и округлить только в конце, результат вернётся к 10.00. Оба поведения воспроизводимы; важно выбрать нужный момент округления.

use Brick\Money\Context\DefaultContext;

$result = Money::of('10.00', 'RUB')->toRational()
    ->dividedBy(3)
    ->multipliedBy(3)
    ->toContext(new DefaultContext(), RoundingMode::HalfUp);
echo $result; // RUB 10.00

RationalMoney удобно использовать внутри цепочки коэффициентов и долей, пока не наступила договорённая граница. В актуальном API преобразование в обычное денежное значение выполняется через toContext(). Старый пример с другим названием метода не стоит копировать без проверки версии.

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

Опыт 8. Конвертация и направление курса

Восьмой опыт преобразует USD 100.00 в RUB с коэффициентом 91.2345 RUB за один USD. Библиотека получает строковый коэффициент и возвращает RUB 9123.45. Число фиксировано в исходниках для повторяемости: это учебный пример, он не загружает и не заявляет актуальный рыночный курс.

$converted = Money::of('100.00', 'USD')->convertedTo(
    'RUB',
    '91.2345',
    roundingMode: RoundingMode::HalfUp
);
echo $converted; // RUB 9123.45

Частая интеграционная ошибка — перепутать направление коэффициента. Название rate не говорит, сколько единиц какой валюты оно содержит. Полезно хранить sourceCurrency, targetCurrency, rate, источник и время получения, а также выбранное округление. Повторный показ старого документа не должен незаметно пересчитывать его по новому значению.

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

Опыт 9. Форматирование для человека и формат для API

Для человека удобны группировка разрядов, запятая в русской локали и понятное обозначение валюты. Для машинного обмена нужны предсказуемые поля без локальных разделителей. Девятый опыт показывает ru_RU и en_US для одного Money, сохраняя amount и amountMinor строками.

use Brick\Money\CurrencyDisplay;

$money = Money::of('1234.50', 'RUB');
echo $money->formatToLocale('ru_RU', CurrencyDisplay::Code);
$payload = [
    'currency' => $money->getCurrency()->getCurrencyCode(),
    'amount' => (string) $money->getAmount(),
    'amountMinor' => (string) $money->getMinorAmount(),
];

Для formatToLocale нужен ext-intl. Версия ICU может менять оформление, в том числе вид пробелов. В переносимой записи нашей лаборатории NBSP и narrow NBSP заменяются обычным пробелом; сами денежные цифры не меняются. Полный исходный механизм можно изучить в MoneyLocaleFormatter.

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

Ввод из формы: запятая, пробелы и неоднозначные числа

В нашей модели канонический формат — строка с точкой без группировки, экспоненты и пробелов. Значения 19,99, 1e3 и JSON-число 19.99 в PHP-корзине отклоняются. Это делает контракт простым и проверяемым, но интерфейс для реального покупателя может требовать более дружелюбного локального ввода.

Если разрешаете запятую, нормализуйте её отдельным понятным шагом до создания Money. Нельзя просто удалить все точки и запятые: строка 1,234 имеет разный смысл в разных локалях. Поле должно иметь выбранную локаль, подсказку и однозначную проверку формата.

// Canonical API contract: decimal string, not JSON number.
$input = ['currency' => 'RUB', 'unitPrice' => '19.99'];
$validated = \Aen\MoneyWorkshop\Cart::decimal($input['unitPrice']);
$price = Money::of($validated, $input['currency']);

Дополнительные ограничения — отрицательность, максимум цены, разрешённые валюты и количество — принадлежат вашему приложению. Их удобно оформить отдельным валидатором; на сайте есть практикум Symfony Validator. Библиотечная корректность числа и допустимость конкретной цены — разные проверки.

JSON: как не потерять точность между PHP и JavaScript

Даже точное значение из PHP можно испортить сериализацией. В нашем опыте minor units равны 9007199254740993. Если браузер прочитает их как Number, последняя цифра перестанет быть надёжной. Поэтому API отдаёт строки, а клиент либо показывает их, либо использует BigInt/десятичную библиотеку в согласованном формате.

$money = Money::ofMinor('9007199254740993', 'RUB');
echo json_encode([
    'currency' => 'RUB',
    'amountMinor' => (string) $money->getMinorAmount(),
], JSON_THROW_ON_ERROR);
// {"currency":"RUB","amountMinor":"9007199254740993"}

Не включайте автоматическое преобразование числовых строк в JSON-числа для такого контракта. На стороне JavaScript нельзя сделать Number(amountMinor), а затем ожидать, что BigInt восстановит исходник. Преобразование должно идти непосредственно из строки.

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

Хранение в SQLite и восстановление через PDO

Скрипт bin/storage.php создаёт отдельную демонстрационную SQLite в runtime и записывает minor units в TEXT вместе с currency. Параметр PDO передаётся как строка. Чтение возвращает те же цифры, после чего ofMinor() восстанавливает денежное значение. Повторный запуск обновляет один известный учебный SKU.

$stmt = $pdo->prepare(
    'INSERT INTO demo_prices(sku,currency,amount_minor) VALUES(:sku,:currency,:amount)'
);
$stmt->bindValue(':sku', 'large-demo', PDO::PARAM_STR);
$stmt->bindValue(':currency', 'RUB', PDO::PARAM_STR);
$stmt->bindValue(':amount', '9007199254740993', PDO::PARAM_STR);
$stmt->execute();
// Restore from database strings:
$money = Money::ofMinor($row['amount_minor'], $row['currency']);

TEXT сохраняет произвольную последовательность цифр, но не даёт автоматической точной числовой арифметики SQL. Сортировка будет лексикографической, а SUM или приведение к REAL способны вернуть другую модель чисел. Для отчётов и диапазонов проектируют отдельную стратегию хранения; ограничения типов описаны в документации SQLite.

Начать с таблиц, параметров и подготовленных запросов можно в разделе нашего PHP-мануала про SQL и PDO. Для практики связей, транзакций и индексов есть отдельная мастерская SQLite с PHP. Денежная арифметика дополняет эти знания, а не заменяет схему данных.

Как встроить расчёт в PHP-приложение

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

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

require __DIR__ . '/vendor/autoload.php';

$input = json_decode(file_get_contents('fixtures/cart.json'), true, 512, JSON_THROW_ON_ERROR);
$result = \Aen\MoneyWorkshop\Cart::calculate($input);
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

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

Windows: от распаковки до своего расчёта

На Windows сначала проверьте, какой PHP запускается из PowerShell и какой php.ini он читает. В системе может одновременно находиться несколько сборок: одна обслуживает локальный веб-сервер, другая стоит в PATH. Изменение конфигурации первой не исправит ошибку расширения во второй.

Expand-Archive -LiteralPath .\money-project.zip -DestinationPath .\money-demo
Set-Location .\money-demo\money-project
php --version
php --ini
php -m
composer install --no-interaction --no-plugins --no-scripts --prefer-dist
composer check-platform-reqs
composer test
composer verify
php .\bin\cart.php .\fixtures\cart.json
php .\bin\storage.php

Нужны PHP 8.2+, Composer 2, intl и PDO SQLite. BCMath или GMP полезны для арифметики, но не являются единственным способом её выполнить. Установка PHP описана на официальном сайте Windows-сборок, а Composer — в официальной инструкции и нашей статье.

После успешных тестов измените копию fixtures/cart.json: цену оставьте в кавычках, количество — целым числом. Запустите CLI и сравните subtotal, discount и total. Только после этого переносите класс в свой проект: так проще отличить проблему окружения от изменения правил расчёта.

Linux и VPS: тот же код в рабочем окружении

На Ubuntu 24.04 можно использовать штатный PHP 8.3 CLI с intl и SQLite. На другом дистрибутиве уточните имена пакетов и поддержку версии. Не переключайте PHP действующего сайта только ради примера: для первой проверки достаточно отдельного каталога и CLI-процесса.

sudo apt update
sudo apt install php8.3-cli php8.3-intl php8.3-sqlite3 php8.3-bcmath unzip
unzip money-project.zip -d money-demo
cd money-demo/money-project
composer install --no-interaction --no-plugins --no-scripts --prefer-dist
composer check-platform-reqs
composer test
composer verify
php bin/cart.php fixtures/cart.json
php bin/storage.php

Composer 2 устанавливается отдельно по официальной инструкции с проверкой подписи установщика и запускается обычным пользователем проекта. На VPS исходники и vendor держат вне document root, а доступ на запись дают нужному runtime. Сборка для разработки не должна получать право менять файлы другого сайта.

Для переноса расчёта в FPM проверьте расширения именно веб-SAPI: php -m относится к CLI. Совпадение версии командной строки и работающий composer test ещё не подтверждают конфигурацию FPM. Сначала выполните небольшой контролируемый запрос в своём приложении и сравните ответ с эталонным CLI-результатом.

Запуск мастерской локально и просмотр через SSH

В ZIP уже есть готовая статика мастерской. Node нужен для локального сервера, тестов модели и восстановления Bootstrap CSS из lock-файла. PHP-расчёт при этом остаётся отдельной программой. Если нужен только просмотр готовой страницы, её можно раздать встроенным PHP-сервером в режиме локальной разработки.

npm ci --ignore-scripts
npm run build
npm test
npm run serve
# Open http://127.0.0.1:8182/
# Alternative local preview without Node:
php -S 127.0.0.1:8182 -t public

При работе на удалённом Linux localhost относится к серверу, а не к вашему компьютеру. Откройте SSH-туннель и оставьте демонстрационный сервер слушать только 127.0.0.1. Для постоянного публичного размещения используйте обычный HTTPS-сервер со статикой из public, сохраняя исходники PHP и runtime вне доступной посетителям папки.

ssh -L 8182:127.0.0.1:8182 user@example.com

Локальная ссылка на полный ZIP может быть недоступна, потому что сервер специально не раздаёт файлы выше public. Это ожидаемо: архив уже распакован на вашем компьютере. На странице сайта полный архив размещён отдельно рядом с мастерской.

Что проверяют PHP-тесты и тесты модели

В проекте 46 PHP-проверок: точные суммы и minor units, положительное и отрицательное округление, все режимы allocation, сохранение общего итога, корзина, исключения и строковое хранение через PDO. Локализованные записи сравниваются после нормализации двух видов неразрывного пробела, чтобы различия оформления ICU не смешивались с арифметикой.

composer test
composer verify
npm test
composer audit
npm audit --audit-level=low

Двенадцать Node-тестов отдельно проверяют BigInt-модель, её совпадение с записанными PHP-результатами, невалидный ввод и генерацию рецепта. Проверка распределения проходит по разным суммам и весам, включая нулевой вес. Это полезнее, чем тестировать только один красивый пример из интерфейса.

Если вы меняете правило скидки, сначала задайте новый ожидаемый результат, затем измените код и обновите запись осознанно через composer build. Не убирайте несовпадение из теста только ради успешного вывода. Проверки подтверждают поведение этого учебного проекта; они не заменяют приёмку вашей системы, нагрузочные измерения или проверку внешнего платёжного API.

Обработка ошибок и журналирование расчёта

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

Для воспроизведения полезны идентификатор операции, версия политики, валюта и безопасный набор исходных параметров. Не нужно записывать платёжные секреты или полный объект пользователя ради проверки копеек. Уровни и контекст журнала разобраны в статье Monolog: логирование в PHP.

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

Типичные ошибки, которые стоит поймать до интеграции

Первая ошибка — превратить строку в float до создания Money. Вторая — потерять валюту при сохранении. Третья — округлять на каждом шаге без согласованной политики. Четвёртая — распределить остаток в одном порядке, а при повторном расчёте отсортировать участников иначе. Все они возникают вне красивого вызова библиотеки и требуют проверки всего пути данных.

Ещё одна ловушка — смешать форматирование и хранение. Строка «1 234,50 RUB» удобна человеку, но не должна становиться канонической суммой API. JSON-число большой величины и SQL-колонка REAL тоже могут вернуть двоичную арифметику в систему, где PHP уже считает точно.

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

Исходники и материалы для продолжения

В полном архиве находятся PHP-классы, девять опытов, CLI-корзина, пример SQLite, fixtures, lock-файлы, готовая мастерская, тесты и инструкции. Собственный код распространяется под MIT, лицензии зависимостей приложены отдельно. Vendor и node_modules восстанавливаются пакетными менеджерами; личных данных и рабочих платёжных ключей в примерах нет.

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