user@elrise.ru:~
· [активен]· 1.0.0тегиphpsymfonybcmathmoneycurrencyvalue-objectbundlefintech

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.

→ repository

Обзор

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 readonly value 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; тип валюты — enum CurrencyType с тремя case Fiat / Crypto / Custom. Бандл не поставляет реестр по умолчанию — хост-приложение собирает его на boot через CurrencyRegistry::mutable([...]) или CurrencyRegistry::fromCatalogue(...) (см. §Currency registry);
  • экспонирует ExchangeRateProviderInterface через DI как tagged service finance_money.exchange_rate_provider; ExchangeRateProviderPass compiler pass выбирает кандидата с наивысшим priority. InMemoryExchangeRateProvider зарегистрирован как провайдер по умолчанию (priority 0); конкретные провайдеры (CbrRateProvider, EcbRateProvider, BinanceRateProvider и т.п.) — не в ядре; хост-приложение реализует их самостоятельно на основе ExchangeRateProviderInterface (см. §MoneyConverter service → Реализация кастомного провайдера);
  • поставляет MoneyConverter (src/Service/MoneyConverter.php) — fluent-обёртку над (provider + registry + optional clock) для повторного использования провайдера в рамках запроса/handler’а; см. §MoneyConverter;
  • возвращает ConversionResult DTO с публичными from() / to() / rate() / source() / at() для audit-трейла; opt-in через Money::convertWithMetadata(); упрощённый Money::convert() остаётся для базового сценария;
  • регистрирует Symfony Bundle, который грузит services.php, autowire’ит MoneyConverter и настраивает ExchangeRateProviderPass compiler 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 (см. §Известные ограничения).

Совместимость

КомпонентВерсия
PHP8.3, 8.4 или 8.5 (strict types)
ext-bcmathобязательно
(нет)ext-bcmath обязателен; ext-intl не требуется
Composer2.x
Symfony7.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.
  • CurrencyRegistryInterfaceget(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.phpConfiguration class отсутствует; никакая elrise_finance_money.* секция конфига не валидируется на старте контейнера.
  • MoneyConverter регистрируется с autowire: true (src/Resources/config/services.php:35-37); ExchangeRateProviderInterface autowire’ится через 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 case Custom (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_UPround half towards positive infinity (IEEE 754 “round half up”)3
HALF_EVENbanker’s rounding, round half to even2
HALF_DOWNround half towards zero2
DOWNtruncate toward zero2.9 → 2
UPround away from zero2.1 → 3
CEILINGround toward positive infinity-2.9 → -2
FLOORround 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.

ConversionResultfinal 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РасположениеНазначение
Unittests/Unit/Чистые unit-тесты, без Symfony-контейнера
Contracttests/Contract/Public-interface инварианты; cross-implementation consistency
Property-basedtests/Property/Round-trip и invariant fuzzing через Eris
Integrationitests/End-to-end с реальным Symfony-контейнером, in-memory rate provider, scenario runners
Benchmarkbench/phpbench микро-бенчмарки для hot-path бюджетов
Failure-modeitests/FailureMode/Cache-down, provider-down, BCMath-scale-overflow

Property-based тесты проверяют алгебраические инварианты:

  • add(a, b) == add(b, a)
  • multiply(a, 1) == a
  • allocate(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.

Источники

Обсуждение

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

Пока никто не комментировал.