finance-money-bundle — Money value objects и BCMath для Symfony fintech
Symfony-бандл для high-load финансовых систем: type-safe Money и Currency value objects на BCMath, реестр валют с enum-разделением Fiat/Crypto/Custom (хост-приложение собирает свой набор), порт exchange-rate провайдеров через tagged services и hot-path бюджетами, валидируемыми через phpbench.
Обзор
Finance Money Bundle — Symfony-бандл для high-load финансовых систем, где денежные расчёты идут через ext-bcmath и не допускают float-дрейфа. Бандл даёт иммутабельный Money value object с type-safe арифметикой, реестр валют с enum-разделением Fiat/Crypto/Custom (бандл не поставляет реестр по умолчанию — хост-приложение собирает свой набор через Currency::iso/crypto/custom), порт ExchangeRateProviderInterface для инжекта региональных провайдеров курсов и hot-path бюджеты под 100 µs, валидируемые через phpbench.
Бандл построен по той же модели, что и elriseio/dbal-bundle: framework-agnostic core + Symfony Bundle integration поверх. Core-слой не зависит от Symfony-контейнера и может быть переиспользован в PHP-приложениях без фреймворка; Bundle-слой подгружает services.php и настраивает ExchangeRateProviderPass compiler pass для выбора провайдера курсов из tagged services.
Документ ниже — справочник по самому бандлу: контракты, компоненты, hot-path бюджеты, quality gates. Архитектурная мотивация — почему BCMath-only и type-safe value objects для денег в PHP, а не int cents или float — в отдельной заметке (планируется в Wave 0+).
Быстрый старт
Минимальный сценарий: установить бандл, зарегистрировать, выполнить арифметику через Money::of.
composer require elriseio/finance-money-bundle
// config/bundles.php
return [
Elrise\Finance\Bundle\Money\ElriseFinanceMoneyBundle::class => ['all' => true],
];
use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;
$registry = CurrencyRegistry::mutable([
Currency::iso('USD', '840'),
Currency::iso('EUR', '978'),
Currency::crypto('BTC', 8),
Currency::crypto('ETH', 18),
Currency::custom('POINTS', 0),
]);
$usd = $registry->get('USD');
$price = Money::of('100.00', $usd);
$tax = $price->multipliedBy('0.20'); // 20.00 USD
$total = $price->plus($tax); // 120.00 USD
echo $total->format(); // "120.00 USD"
echo $total->canonicalString(); // "120.00"
echo $total->minorUnits(); // 12000
Бандл не поставляет реестр по умолчанию. Хост-приложение собирает реестр на boot (см. §Currency registry) и при необходимости устанавливает его через MoneyRegistry::setDefault($registry) — после этого Money::of('100.00', 'USD') начинает резолвить строковый код через этот реестр.
Полная конфигурация, cross-currency conversion, hot-path бюджеты и quality gates — ниже.
Что это и чем не является
Бандл делает:
- предоставляет
MoneyиCurrencyкакfinal readonlyvalue objects; каждая арифметическая операция (plus,minus,multipliedBy,dividedBy,allocate,compare) возвращает новый экземпляр — immutability guard на уровне типов; - хранит суммы как BCMath-precise decimal string через фасад
Decimal\Math; прямойbcdiv/bcmod/bcscaleза пределамиDecimal\блокируется custom PHPStan-правиломForbiddenBcFunctionInDecimalRule; - сопоставляет валюты на уровне типа: cross-currency
plus/minus— compile-time error (CurrencyMismatchException); cross-currency сравнение черезMoney::compare(other, ?provider)— opt-in с явным провайдером; - поставляет
CurrencyRegistryInterfaceс методамиhas/get/tryGet/all/byType/register/catalogueVersion/isMutable; тип валюты — enumCurrencyTypeс тремя caseFiat/Crypto/Custom. Бандл не поставляет реестр по умолчанию — хост-приложение собирает его на boot черезCurrencyRegistry::mutable([...])илиCurrencyRegistry::fromCatalogue(...)(см. §Currency registry); - экспонирует
ExchangeRateProviderInterfaceчерез DI как tagged servicefinance_money.exchange_rate_provider;ExchangeRateProviderPasscompiler pass выбирает кандидата с наивысшимpriority.InMemoryExchangeRateProviderзарегистрирован как провайдер по умолчанию (priority 0); конкретные провайдеры (CbrRateProvider,EcbRateProvider,BinanceRateProviderи т.п.) — не в ядре; хост-приложение реализует их самостоятельно на основеExchangeRateProviderInterface(см. §MoneyConverter service → Реализация кастомного провайдера); - поставляет
MoneyConverter(src/Service/MoneyConverter.php) — fluent-обёртку над(provider + registry + optional clock)для повторного использования провайдера в рамках запроса/handler’а; см. §MoneyConverter; - возвращает
ConversionResultDTO с публичнымиfrom()/to()/rate()/source()/at()для audit-трейла; opt-in черезMoney::convertWithMetadata(); упрощённыйMoney::convert()остаётся для базового сценария; - регистрирует Symfony Bundle, который грузит
services.php, autowire’итMoneyConverterи настраиваетExchangeRateProviderPasscompiler pass. Configuration class у бандла отсутствует — конфигурация провайдеров делается через tagged services, а не черезelrise_finance_money.*секцию; - кастомные валюты регистрируются через
CurrencyRegistry::mutable()илиCurrencyRegistry::fromCatalogue()на boot (см. §Реестр валют). Attribute-based discovery (#[AsCurrency]) упоминается в USAGE.md, но в текущей версии этот атрибут не опубликован в коде (src/Attribute/отсутствует); в Symfony-приложении реестр по умолчанию ставится через обычные tagged services + явныйMoneyRegistry::setDefault(...)на boot listener’е; - верифицирует hot-path бюджеты через
phpbench(см. §Hot-path бюджеты); регрессии по бюджетам падают CI; - эмитит структурированные исключения через единый
DomainException(родитель дляCurrencyMismatchException,UnknownCurrencyException,ExchangeRateUnavailableException,DivisionByZeroException,InvalidAmountException,MathScaleException,ImmutableRegistryException,FundConversionOverflowException,InvalidExternalMultiplierException,BcmMathUnavailableException); для интеграции с PSR-3 логгерами —ThrowableRenderer.
Бандл не делает:
- не предоставляет Doctrine Type для
Money— хост-приложение реализует его на своей стороне черезType::convertToPHPValue+convertToDatabaseValue, используяMoney::canonicalString()/fromCanonicalString()(илиminorUnits()для integer-колонок); - не поставляет exchange-rate провайдеров в core — только контракт
ExchangeRateProviderInterface. Реализация (CbrRateProvider,EcbRateProvider, …) делается хост-приложением (см. §MoneyConverter service → Реализация кастомного провайдера); - не поставляет HTTP-клиент для rate API — DI-провайдер получает HTTP-клиент из потребительского контейнера;
- не предоставляет UI-форматирование с учётом региональных стандартов —
Money::format()возвращает"<canonical> <ISO-code>". Для locale-aware символов хост вызываетMoney::beautify($canonical, $precision, $symbol)с per-locale symbol catalogue на UI edge; - не поддерживает PHP 8.2 — bundle использует
final readonly class,enum-cases и typed-constants; - не обещает абстракцию нулевой стоимости — каждый
Money::plusидёт черезbcaddна scale получателя. Это ~50 µs на операцию, что укладывается в hot-path бюджет, но не подходит для high-throughput hot path > 10k ops/sec (см. §Известные ограничения).
Совместимость
| Компонент | Версия |
|---|---|
| PHP | 8.3, 8.4 или 8.5 (strict types) |
ext-bcmath | обязательно |
| (нет) | ext-bcmath обязателен; ext-intl не требуется |
| Composer | 2.x |
| Symfony | 7.x или 8.x (для Bundle-слоя; core — framework-agnostic) |
PHP 8.2 не поддерживается. CI прогоняет матрицу PHP 8.3 / 8.4 / 8.5 × Symfony 7.x / 8.x.
Архитектура
Core-слой framework-agnostic: Money, Currency, Decimal\Math, CurrencyRegistry и контракты ExchangeRateProviderInterface / CurrencyRegistryInterface живут без зависимости на Symfony. Bundle-слой — отдельный Symfony\Component\DependencyInjection-конфиг + compiler passes + attribute-регистрация.
┌────────────────────────────────┐
│ elriseio/finance-money-bundle │
│ (framework-agnostic core + │
│ Symfony Bundle integration)│
└─────────────┬──────────────────┘
│
┌──────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌───────▼────────┐
│ Money / │ │ Decimal\Math │ │ CurrencyReg. │
│ Currency VO │ │ (BCMath-фасад)│ │ (ISO + custom)│
└───────┬────────┘ └────────┬────────┘ └───────┬────────┘
│ │ │
└──────────────────┼─────────────────────┘
│
┌─────────────▼──────────────────┐
│ Symfony DI / Bundle │
│ (services, compiler passes, │
│ config validation) │
└─────────────┬──────────────────┘
│
┌─────────────▼──────────────────┐
│ Host-side integrations │
│ (Doctrine Type, Symfony │
│ Serializer, Forms DataTrans- │
│ former, custom rate provider) │
└────────────────────────────────┘
Контракты
Money(final readonly) — BCMath-backed value object; статические фабрикиMoney::of(string|int, Currency|string), арифметика возвращает новый экземпляр;format(?string $locale = null)возвращает"<canonical> <ISO-code>"(параметр$localeобъявлен для будущего расширения, но сейчасunset($locale));beautify(string $value, int $precision, string $symbol): stringвозвращает"<symbol> <value>"дословно.Currency(final readonly) —(code, scale, type, symbol, name);type ∈ {Fiat, Crypto, Custom}.Decimal\Math— BCMath-фасад; прямойbcdiv/bcmod/bcscaleза пределамиDecimal\блокируетсяForbiddenBcFunctionInDecimalRule.CurrencyRegistryInterface—get(string): Currency(бросает исключение),tryGet(string): ?Currency(мягкий fail),register(Currency): void.ExchangeRateProviderInterface— DI-only контракт; реализации (CbrRateProvider,EcbRateProvider, …) делаются хост-приложением.ConversionResult— DTO(from, to, rate, source, at); opt-in черезMoney::convertWithMetadata().
Symfony Bundle
FinanceMoneyExtension::load()подгружает толькоsrc/Resources/config/services.php—Configurationclass отсутствует; никакаяelrise_finance_money.*секция конфига не валидируется на старте контейнера.MoneyConverterрегистрируется сautowire: true(src/Resources/config/services.php:35-37);ExchangeRateProviderInterfaceautowire’ится через alias, которыйExchangeRateProviderPassрезолвит на highest-priority tagged provider.- Attribute
#[AsCurrency]упоминается в USAGE.md как способ discovery кастомных валют, но не существует вsrc/текущей версии. Хост либо регистрирует валюты черезmutable()(см. §Реестр валют), либо ждёт публикации атрибута в будущей версии.
Установка
composer require elriseio/finance-money-bundle
Регистрация бандла в config/bundles.php:
return [
// ...
Elrise\Finance\Bundle\Money\FinanceMoneyBundle::class => ['all' => true],
];
Конфигурация у бандла в текущей версии отсутствует как Configuration class — FinanceMoneyExtension::load() подгружает только services.php. Управление провайдерами курсов делается через tagged services и compiler pass, а не через elrise_finance_money.* секцию.
DI-wiring для собственного провайдера курсов в services.yaml (override дефолтного InMemoryExchangeRateProvider через priority):
services:
App\ExchangeRate\EcbRateProvider:
arguments:
$httpClient: '@app.ecb_http_client'
tags:
- { name: 'finance_money.exchange_rate_provider', priority: 100 }
ExchangeRateProviderPass выберет кандидата с наивысшим priority при компиляции контейнера; InMemoryExchangeRateProvider остаётся запасным вариантом, если ни один собственный провайдер не зарегистрирован (priority 0).
#[AsCurrency] attribute registration (planned, not in current code)
USAGE.md упоминает #[AsCurrency] attribute как способ discovery кастомных валют:
// Ожидаемый (после публикации атрибута) синтаксис:
// #[AsCurrency(priority: 50)]
// final class LoyaltyPointsCurrency implements CurrencyInterface { ... }
В текущей версии src/Attribute/AsCurrency.php не существует, и compiler pass для discovery CurrencyInterface-имплементаций не зарегистрирован. Хост, которому нужна аналогичная функциональность, реализует её на своей стороне через:
# services.yaml — обычный tagged-service enumeration
services:
App\Currency\LoyaltyPointsCurrency:
tags: ['app.currency.custom']
# + boot listener (KernelEvents::REQUEST), который обходит tagged services
# и вызывает CurrencyRegistry::mutable()->register() / fromCatalogue() с распарсенными валютами.
Если атрибут будет опубликован — этот раздел обновится в той же сессии.
Money value object
Money — иммутабельный final readonly class. Каждая арифметическая операция возвращает новый экземпляр. Сумма хранится как BCMath-precise decimal string; bcdiv / bcmod / bcscale за пределами Decimal\Math заблокированы кастомным PHPStan-правилом.
Создание
use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;
$btc = Currency::crypto('BTC', 8);
$usd = Currency::iso('USD', '840');
$money = Money::of('100.00', $btc);
$money = Money::of('100.00', 'USD'); // строкой, если Money::setDefaultRegistry() вызван
$money = Money::fromMinor(1999, $usd); // из minor units (int)
$money = Money::zero($btc); // нулевое значение
$money = Money::tryFromAny($input, $btc); // best-effort, возвращает ?Money
Фабрики Money::zero($currency), Money::fromMinor(int $minorUnits, Currency $currency), Money::fromCanonicalString(string $value, Currency $currency) и Money::tryFromAny(mixed $input, Currency $currency) закрывают типовые сценарии инициализации из разных источников.
Арифметика
$deposit = Money::of('100.00', $usd);
$fee = Money::of('2.50', $usd);
$balance = $deposit->minus($fee); // 97.50 USD
$total = $deposit->plus($fee)->plus($fee); // 105.00 USD
Умножение и деление
$subtotal = Money::of('100.00', $usd);
$discount = $subtotal->multipliedBy('0.85'); // 85.00 USD
$perItem = $subtotal->dividedBy(4); // 25.00 USD
Множители и делители трактуются как value scalars на scale получателя (src/Money.php:181-202), так что Money('100.00', USD)->dividedBy('4') — это 25.00 USD, не 2500.0000 USD. Это закрывает классический float-style scale drift.
Модуло, отрицание, абсолют
$amount = Money::of('100.00', $usd);
$amount->mod(Money::of('7.00', $usd)); // 2.00 USD
$amount->negated(); // -100.00 USD
$amount->abs(); // 100.00 USD
Равенство
$a = Money::of('100.00', $usd);
$b = Money::of('100', $usd); // scale-нормализация к USD scale
$c = Money::of('100.00', $eur);
$a->equals($b); // true (одна валюта, квантизация совпадает)
$a->equals($c); // false (разные валюты)
Все арифметические методы возвращают новый экземпляр. Money::allocate(int $parts) распределяет сумму методом наибольшего остатка — сумма частей равна исходной с точностью до scale валюты.
Cross-currency операции
Cross-currency plus / minus / multipliedBy / equals — это исключение (CurrencyMismatchException) до того, как BCMath-код запустится. Это явно: в финансовом коде молчаливое plus USD к EUR — не баг-сюрприз, который надо отлаживать ночью.
Cross-currency convert() и compare() — opt-in с явным провайдером (см. §Cross-currency conversion):
$sign = $usdMoney->compare($eurMoney, $rateProvider);
// -1 / 0 / 1 — после конверсии в одну валюту через провайдер
Инспекция
$money->amount(); // Decimal (canonical)
$money->canonicalString(); // "100.00"
$money->minorUnits(); // int (canonical × 10^scale)
$money->currency(); // Currency
$money->isZero(); // bool
$money->isPositive(); // bool
$money->isNegative(); // bool
Форматирование
format() возвращает canonical value + пробел + ISO-4217 alpha-3 code. Бандл не использует \NumberFormatter и не зависит от ext-intl — параметр $locale объявлен для будущего расширения, но сейчас unset($locale):
$total = Money::of('120.50', $usd);
echo $total->format(); // "120.50 USD"
Символы валют ($, €, ₽) считаются presentation metadata на конкретном Currency классе, и format() их не использует. Для рендеринга с локализованным символом есть beautify():
$display = $total->beautify(
$total->canonicalString(), // string — pre-formatted value
2, // int — display precision (declared per spec, не используется)
'US$', // string — display symbol (дословно)
);
// "US$ 120.50"
beautify() намеренно дословно склеивает: возвращает "$symbol $value". Бандл не ищет символы внутри — хост отвечает за per-locale symbol catalogue на UI edge.
Money::format() и Money::beautify() НЕ покрываются MoneyInterface — это конкретные методы на Money. Реализация может измениться без поломки контракта.
JSON
Money имплементирует JsonSerializable. Дефолтная форма — {amount, currency}:
echo json_encode([
'price' => Money::of('120.50', $usd),
'total' => Money::of('241.00', $usd),
]);
// {"price":{"amount":"120.50","currency":"USD"},"total":{"amount":"241.00","currency":"USD"}}
Никаких symbol, scale или presentation metadata в JSON не уходит — это Money-уровень, не сериализатор. Symfony Serializer Normalizer / Denormalizer делается хост-приложением поверх jsonSerialize() / tryFromAny().
Реестр валют
CurrencyRegistryInterface — реестр с двумя режимами сборки: CurrencyRegistry::fromCatalogue(array $catalogue, int $catalogueVersion) для immutable реестра (rejects register с ImmutableRegistryException), и CurrencyRegistry::mutable(array $seed = [], int $catalogueVersion = 0) для mutable реестра, принимающего register(...).
Бандл не поставляет реестр по умолчанию. Хост-приложение собирает его на boot:
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Currency\CurrencyRegistry;
use Elrise\Finance\Bundle\Money\Currency\CurrencyType;
$registry = CurrencyRegistry::mutable([
Currency::iso('USD', '840'),
Currency::iso('EUR', '978'),
Currency::iso('JPY', '392'), // zero-decimal fiat, scale 0
Currency::crypto('BTC', 8),
Currency::crypto('ETH', 18), // wei-scale
Currency::custom('POINTS', 0), // loyalty points
]);
$registry->register(Currency::iso('GBP', '826')); // add later
Для immutable реестра со скомпилированным каталогом:
$catalog = CurrencyRegistry::fromCatalogue(
catalogue: [...], // list<Currency>
catalogueVersion: 42,
);
$catalog->isMutable(); // false — register() бросает исключение
$catalog->catalogueVersion(); // 42
Поиск
$usd = $registry->get('USD'); // бросает UnknownCurrencyException
$maybe = $registry->tryGet('XYZ'); // ?Currency, null on miss
$hasBtc = $registry->has('USD'); // bool
$all = $registry->all(); // list<Currency>
$crypto = $registry->byType(CurrencyType::Crypto); // map<string, Currency>
Фабрики Currency
Три статические фабрики закрывают основные сценарии построения. symbol и name — предоставляемые потребителем presentation metadata; бандл никогда не использует их сам, хост-приложение передаёт свои per-locale символы на UI edge.
Currency::iso('USD', '840'); // code + numeric (Fiat, scale=0)
Currency::crypto('ETH', 18); // code + scale
Currency::custom('POINTS', 0); // code + scale
// Опциональные symbol и name (по умолчанию = code):
Currency::iso('USD', '840', '$', 'US Dollar');
Currency::crypto('BTC', 8, '₿', 'Bitcoin');
Семантические инварианты:
Currency::iso()— alpha-3 код из трёх ASCII-букв + опциональный numeric-3; scale жёстко 0 для всех fiat (USD/EUR/JPY — в этой модели целочисленные единицы);Currency::crypto()— код длиной 3-12 ASCII-символов; scale 0-18 (типично BTC=8, ETH=18);Currency::custom()— то же, что crypto, но enum caseCustom(POINTS, бонусные баллы, in-game credits).
Все коды uppercase’ятся и проверяются на ASCII-alphanumeric на construction (src/Currency/Currency.php:159-174).
Установка реестра по умолчанию
Money::of(string, $currency) принимает либо Currency instance, либо строковый код. Строковый путь резолвится через process-wide реестр по умолчанию, который ставится через:
use Elrise\Finance\Bundle\Money\MoneyRegistry;
MoneyRegistry::setDefault($registry);
// или эквивалентный шорткат:
Money::setDefaultRegistry($registry);
// Теперь Money::of('100.00', 'USD') резолвит 'USD' через $registry.
В Symfony-приложении реестр по умолчанию ставится вручную через MoneyRegistry::setDefault($registry) на KernelEvents::REQUEST listener’е (или аналогичном boot hook’е) — атрибут #[AsCurrency], упомянутый в USAGE.md, не опубликован в текущей версии (см. §Symfony Bundle wiring).
type ∈ {Fiat, Crypto, Custom} — enum-разделение, доступное на уровне типа (src/Currency/CurrencyType.php). Реестр хранит состояние в hash-map и потокобезопасен для long-running workers (RoadRunner, Swoole, FrankenPHP) без дополнительной синхронизации.
Decimal engine и BCMath-границы
Все арифметические операции идут через Decimal\Math (src/Decimal/Math.php) — тонкий BCMath-фасад. Методы add, sub, mul, div, cmp, quantize принимают canonical numeric strings и int scale, никогда не возвращают PHP float.
Если ext-bcmath недоступен, Math::assertBcMath() бросает BcmMathUnavailableException на первом вызове. Проверка кэшируется в self::$bcmathAvailable — после первого failure последующие вызовы не валят на BC availability check повторно.
Режимы округления (RoundingMode)
src/Decimal/RoundingMode.php — enum с семью кейсами, которые покрывают типовые банковские и инженерные сценарии:
| Case | Семантика | Пример (2.5) |
|---|---|---|
HALF_UP | round half towards positive infinity (IEEE 754 “round half up”) | 3 |
HALF_EVEN | banker’s rounding, round half to even | 2 |
HALF_DOWN | round half towards zero | 2 |
DOWN | truncate toward zero | 2.9 → 2 |
UP | round away from zero | 2.1 → 3 |
CEILING | round toward positive infinity | -2.9 → -2 |
FLOOR | round toward negative infinity | -2.1 → -3 |
HALF_UP — режим по умолчанию в арифметике Money::* (multipliedBy / dividedBy / convert всегда quantize через HALF_UP).
ForbiddenBcFunctionInDecimalRule
Прямой вызов bcdiv, bcmod, bcscale за пределами Decimal\Math заблокирован custom PHPStan-правилом ForbiddenBcFunctionInDecimalRule (tests/PHPStan/Rules/ForbiddenBcFunctionInDecimalRule.php):
final class ForbiddenBcFunctionInDecimalRule implements Rule
{
private const FORBIDDEN = ['bcdiv', 'bcmod', 'bcscale'];
private const FACADE = 'Elrise\\Finance\\Bundle\\Money\\Decimal\\Math';
private const NAMESPACE_PREFIX = 'Elrise\\Finance\\Bundle\\Money\\Decimal';
// ... processNode() бросает RuleError при попытке прямого вызова
}
bcadd, bcsub, bcmul, bccomp не входят в forbidden set — они уже маршрутизируются через Math и держат область действия правила узкой на три функции. Сама фасадная Decimal\Math разрешена через белый список имён классов.
Это не “PHP 8.4 не поддерживает bcdiv” — это архитектурное решение: вся BCMath-семантика живёт в Decimal\Math, любая попытка вызвать BCMath напрямую из кода в namespace Elrise\Finance\Bundle\Money\Decimal\ — code-smell и блокируется на уровне анализатора.
Float в hot path
Custom PHPCS-sniff’ы (tests/Rules/Money/Sniffs/HotPath/) защищают от float в hot-path файлах:
NoFloatOnMoneyHotPathSniff— fail наfloatв сигнатуре метода, return type или property declaration вsrc/Money.php,src/Currency/,src/Decimal/.ImmutabilityGuardSniff— fail если value object в hot-path файлах неfinal, неreadonly, объявляет non-readonly property или публичный setter.
Float не принимается как Money::of() аргумент (сигнатура string|int); Money::of(100.50, $usd) это TypeError по дизайну. Хост должен конвертировать на границе через number_format():
$money = Money::of(number_format((float) $userInput, 2, '.', ''), $usd);
Это закрывает класс багов “передали float, он дрифтанул на арифметике”.
Cross-currency conversion
Cross-currency конверсия идёт через порт ExchangeRateProviderInterface. Бандл экспонирует два метода:
Money::convert(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): self— базовый, возвращает сконвертированныйMoney. БросаетCurrencyMismatchException, если$this->currencyи$target— одна и та же валюта;ExchangeRateUnavailableException, если провайдер вернулnullдля пары.Money::convertWithMetadata(Currency $target, ExchangeRateProviderInterface $provider, ?\DateTimeImmutable $at = null): ConversionResult— opt-in с metadata-аудитом. Обёртка надconvert(), которая дополнительно возвращаетConversionResultсrate,sourceиat.
ConversionResult — final readonly DTO с публичными геттерами from(), to(), rate(): string, source(): ?string, at(): ?DateTimeImmutable. source и at пробрасываются из provider->rateWithMetadata().
use Elrise\Finance\Bundle\Money\Money;
use Elrise\Finance\Bundle\Money\ValueObject\ConversionResult;
$total = Money::of('100.00', $usd);
/** @var ConversionResult $result */
$result = $total->convertWithMetadata(
$eur,
$rateProvider,
new \DateTimeImmutable('2026-07-27T12:00:00+00:00'),
);
// $result->from() === Money('100.00', USD)
// $result->to() === Money(95.85, EUR)
// $result->rate() === '0.9585'
// $result->source() === 'ecb' (от провайдера; null для InMemoryExchangeRateProvider)
// $result->at() === DateTimeImmutable(...)
Аудит конверсии — то, что финансовый регулятор хочет видеть в первую очередь: кто дал курс, в какой момент. source = null — допустимое состояние для провайдеров, которые не имеют собственного логического имени (например, InMemoryExchangeRateProvider).
MoneyConverter service
MoneyConverter (src/Service/MoneyConverter.php) — fluent-обёртка вокруг (provider + registry + optional clock), которая избавляет от повторного инжекта провайдера в каждом call site. Сервис не меняет контракт Money и не вводит собственный кэш — кэширование остаётся на стороне провайдера.
Конструктор
use Elrise\Finance\Bundle\Money\Service\MoneyConverter;
public function __construct(
private MoneyConverter $converter, // autowired через DI alias
) {}
Под капотом:
public function __construct(
private ExchangeRateProviderInterface $provider,
private CurrencyRegistryInterface $registry,
private ?\DateTimeImmutable $now = null,
) {}
ExchangeRateProviderInterface autowire’ится через alias, который ExchangeRateProviderPass резолвит на highest-priority tagged provider.
Конвертация
$eur = $this->converter->convert(
money: Money::of('100.00', $usd),
target: 'EUR', // строка через registry
at: $clockAt, // опционально, перекрывает injected clock
);
target принимает Currency|string; строковый путь резолвится через registry->get($target) ровно один раз. $at опционален и при null использует injected clock ($now), иначе — new DateTimeImmutable().
Только курс
$rate = $this->converter->rate($usd, 'EUR');
// Decimal — бросает ExchangeRateUnavailableException на miss
Bulk: basket reporting и сводка
$basket = [
Money::of('100.00', $usd),
Money::of('50.00', $usd),
Money::of('25.00', $usd),
];
$inEur = $this->converter->convertAll($basket, 'EUR'); // list<Money> в EUR
$totalEu = $this->converter->sum($basket, 'EUR'); // один Money в EUR
sum() бросает \InvalidArgumentException на пустой iterable — у zero-element basket нет определённой целевой суммы (это input validation, а не domain rule).
Override провайдера mid-request (тесты)
$scoped = $this->converter->with($stubProvider);
// новый MoneyConverter, registry и clock сохранены
Реализация кастомного провайдера
namespace App\ExchangeRate;
use DateTimeImmutable;
use Elrise\Finance\Bundle\Money\Contract\CurrencyRegistryInterface;
use Elrise\Finance\Bundle\Money\Contract\ExchangeRateProviderInterface;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Decimal\Decimal;
use Elrise\Finance\Bundle\Money\ValueObject\ConversionResult;
final readonly class EcbRateProvider implements ExchangeRateProviderInterface
{
public function __construct(
private \Symfony\Contracts\HttpClient\HttpClientInterface $http,
private CurrencyRegistryInterface $registry,
) {}
public function getRate(Currency $from, Currency $to, DateTimeImmutable $at): ?Decimal
{
// ...fetch rate; return null on miss/network failure...
}
public function rateWithMetadata(Currency $from, Currency $to, DateTimeImmutable $at): ConversionResult
{
return new ConversionResult(
from: Money::zero($from),
to: Money::zero($to),
rate: $this->getRate($from, $to, $at)?->canonicalString() ?? '0',
source: 'ecb',
at: $at,
);
}
}
Регистрация через tagged service в services.yaml:
services:
app.exchange_rate.ecb_provider:
class: App\ExchangeRate\EcbRateProvider
arguments:
- '@http_client'
- '@Elrise\Finance\Bundle\Money\Contract\CurrencyRegistryInterface'
tags:
- { name: 'finance_money.exchange_rate_provider', priority: 100 }
priority tag определяет, кто побеждает на compile time при наличии нескольких провайдеров. Чем выше число — тем выше приоритет.
Для тестов бандл поставляет InMemoryExchangeRateProvider:
use Elrise\Finance\Bundle\Money\ExchangeRate\InMemoryExchangeRateProvider;
$provider = new InMemoryExchangeRateProvider([
'EUR' => ['USD' => '1.0850'],
'USD' => ['EUR' => '0.9217'],
]);
Exchange rate providers
ExchangeRateProviderInterface — порт для провайдеров курсов. Реальная сигнатура (src/Contract/ExchangeRateProviderInterface.php):
namespace Elrise\Finance\Bundle\Money\Contract;
use DateTimeImmutable;
use Elrise\Finance\Bundle\Money\Currency\Currency;
use Elrise\Finance\Bundle\Money\Decimal\Decimal;
use Elrise\Finance\Bundle\Money\ValueObject\ConversionResult;
interface ExchangeRateProviderInterface
{
public function getRate(
Currency $from,
Currency $to,
DateTimeImmutable $at,
): ?Decimal;
public function rateWithMetadata(
Currency $from,
Currency $to,
DateTimeImmutable $at,
): ConversionResult;
}
getRate() возвращает Decimal|null — мультипликативный курс r, квантизированный к RATE_SCALE, или null если пара недоступна. Money::convert() транслирует null в ExchangeRateUnavailableException.
Бандл регистрирует InMemoryExchangeRateProvider как провайдер по умолчанию через tagged services (src/Resources/config/services.php):
$services->set(InMemoryExchangeRateProvider::class, InMemoryExchangeRateProvider::class)
->args([[]]) // rates map; host applications override via service decoration or a custom provider
->public()
->tag(ExchangeRateProviderPass::TAG_NAME, ['priority' => 0]);
InMemoryExchangeRateProvider принимает пустой rates map по умолчанию и используется как резервный вариант: ExchangeRateProviderPass compiler pass выбирает кандидата с наивысшим priority из всех tagged services. Хост-приложение регистрирует собственный провайдер через tags: [{ name: 'finance_money.exchange_rate_provider', priority: 100 }], и он перекрывает вариант по умолчанию.
Конкретные региональные провайдеры (CbrRateProvider, EcbRateProvider, BinanceRateProvider, …) делаются хост-приложением — отдельного companion-пакета в текущей версии нет. Реализации должны быть иммутабельны и не иметь скрытого I/O на monetary hot path (кэширование, агрегация и freshness-проверки — за пределами контракта).
Hot-path бюджеты
Бенчмаркинг устроен двухслойно — bench/ и itests/ — и оба НЕ заменяют таблицу бюджетов из README, потому что в bench/ лежит только DecimalBench::benchPlusScaleEight() (bench/DecimalBench.php), измеряющий throughput Decimal::plus на scale 8. Это рекомендательный harness — не принудительный gate: composer bench opt-in, в CI smoke (itests-smoke.yml) не входит. Throughput gate — запланирован на Wave 4 и в текущей версии ещё не активирован.
Реальное состояние hot-path покрытия:
bench/— синтетический micro-benchmark дляDecimal\Decimal. Измеряет per-call cost на hand-crafted shapes (Decimal::of('12345678.12345678', 8)->plus(...)× 3). Не измеряетMoney::*методы.itests/— integration load-testing под seeded multi-worker load через канонические scenarios (decimal_arithmetic,currency_conversion,fund_converter,exchange_rate_cache). CI smoke gate прогоняетitests/, неbench/.
Таблица бюджетов из README (Money::of < 100 µs, Money::plus < 50 µs, Money::multipliedBy < 100 µs и т.п.) — это target spec, а не измеренные цифры. До Wave 4 эти цифры не подтверждены бенчмарками и не принудительно проверяются в CI; регрессии в throughput на текущей версии пройдут незамеченными. Хосту, которому важны per-call задержки, рекомендуется запустить composer bench на своём окружении и замерить свой реальный hot path.
Quality gates и тестирование
Полный набор quality gates:
composer test # PHPUnit (Unit / Contract / Property-based)
composer stan # PHPStan level 8 + ergebnis rules
composer cs # PHPCS (PSR-12 + custom Money standard)
composer bench # phpbench микро-бенчмарки
composer guard # stan + cs + property (полный quality gate)
composer itests:envelope # itests envelope contract
Все четыре команды exit non-zero на failure. CI прогоняет матрицу на PHP 8.3, 8.4, 8.5 против Symfony 7.x и 8.x.
Кастомные правила
Money.HotPath.NoFloatOnMoneyHotPath(PHPCS) — fail наfloatв сигнатуре/return-type/property declaration вsrc/Money.php,src/Currency/,src/Decimal/.Money.HotPath.ImmutabilityGuard(PHPCS) — fail если value object в hot-path файлах неfinal, неreadonly, объявляет non-readonly property или публичный setter.ForbiddenBcFunctionInDecimalRule(PHPStan) — блокирует прямойbcdiv/bcmod/bcscaleвDecimal\за пределамиDecimal\Math.
Тестовые suite
| Suite | Расположение | Назначение |
|---|---|---|
| Unit | tests/Unit/ | Чистые unit-тесты, без Symfony-контейнера |
| Contract | tests/Contract/ | Public-interface инварианты; cross-implementation consistency |
| Property-based | tests/Property/ | Round-trip и invariant fuzzing через Eris |
| Integration | itests/ | End-to-end с реальным Symfony-контейнером, in-memory rate provider, scenario runners |
| Benchmark | bench/ | phpbench микро-бенчмарки для hot-path бюджетов |
| Failure-mode | itests/FailureMode/ | Cache-down, provider-down, BCMath-scale-overflow |
Property-based тесты проверяют алгебраические инварианты:
add(a, b) == add(b, a)multiply(a, 1) == aallocate(n)суммируется в оригиналfromCanonicalString(toCanonicalString(x)) == x(round-trip)
Потрогать руками
dummy-market-agent — reference Symfony-сервис, в котором elriseio/finance-money-bundle, elriseio/application-layer-bundle, elriseio/dbal-bundle и API Platform собраны в одном запускаемом приложении. В нём доступны DI-wiring, hot-path сценарии, exchange-rate port с тестовым провайдером и интеграция с persistence через собственные Doctrine Type / Serializer Normalizer.
Репозиторий запускается через Docker; команды прогонов и сценарии описаны в README репо.
Известные ограничения
- High-throughput hot path > 10k ops/sec.
Money::plusидёт черезbcadd(~30-50 µs). Если у вас hot-path с десятками тысяч операций в секунду на одном воркере, используйте нативныйint cents/int satoshiи конвертируйте вMoneyтолько на границах слоёв. Бандл для этого не предназначен. - PHP 8.2 не поддерживается. Bundle использует
final readonly class,enum-cases, typed-constants. Минимальная версия — 8.3. - Конкретные exchange-rate провайдеры не в core. Реализации
CbrRateProvider,EcbRateProvider,BinanceRateProviderделаются хост-приложением на основеExchangeRateProviderInterface(см. §MoneyConverter service → Реализация кастомного провайдера). - Locale-aware форматирование не встроено.
Money::format()возвращает"<canonical> <ISO-code>". Для locale-aware символов хост вызываетMoney::beautify($canonical, $precision, $symbol)с per-locale symbol catalogue. - Float в hot-path заблокирован на уровне анализатора. Если у вас legacy код, который передаёт float в
Money-сигнатуры напрямую, миграция требует явногоMoney::of(float, $currency)оборачивания. Это by design: float в арифметике денег — источник дрейфа, а не производительности. - Currency catalog — ответственность хоста. Бандл не поставляет bundled-каталог; хост-приложение собирает набор валют на boot (см. §Реестр валют). Если нужна каноническая ISO-карта, рекомендуется отдельный пакет с ISO-данными — на текущей версии бандла это вне scope.
Источники
- github.com/elriseio/finance-money-bundle — репозиторий, MIT.
- packagist.org/packages/elriseio/finance-money-bundle — Packagist.
- Документация PHP BCMath —
ext-bcmathreference. - Symfony Bundle Best Practices — структура Bundle-слоя.
- ISO 4217 — стандарт валют; бандл его не отслеживает, см. §Известные ограничения.
- Соседний проект — elriseio/dbal-bundle: DBAL-менеджер для high-load Symfony, на той же модели framework-agnostic core + Symfony Bundle integration.