Skip to content

ТЗ — Валюты (cms/commerce-currencies)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: er (крипто-опыт) Статус: ТЗ к разработке

Назначение и возможности

Мультивалютность поверх модели цен: базовая валюта хранения фиксирована, отображение в валюте пользователя — конверсия только на выводе, не в хранении (см. /cms-v2/commerce-model).

  • Базовая валюта хранения (все amount_minor в БД — в ней, без пересчёта задним числом)
  • Справочник курсов валют с историей изменения курса
  • Обновление курсов модулем cms/cbr-rates (крючок, без прямой интеграции с ЦБ РФ внутри модуля)
  • Отображение цены в валюте пользователя — конверсия на выводе по актуальному курсу
  • Формат и округление отображения per-валюта (разряды, символ, позиция символа)
  • Переключатель валюты для пользователя (сохраняется в сессии/профиле)

Зависимости и выключение

requires: cms/commerce-pricing · suggests: cms/cbr-rates

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

Модель данных

ТаблицаКлючевые поляПримечание
commerce_currenciescode, title, symbol, decimal_places, symbol_position, rounding_modeсправочник валют и формата отображения; rounding_mode — PHP Enum, см. «Настройки»
commerce_currency_ratesid, currency_code, rate_to_base, effective_atистория курсов к базовой валюте (кросс-курс между двумя небазовыми валютами не хранится отдельной записью, см. «Крайние случаи», п. 2), append-only; FK currency_codeconstrained()->index(), индекс по effective_at
commerce_base_currency_changesid, old_code, new_code, changed_at, changed_by, recalculated_prices_countappend-only аудит смены базовой валюты (см. «Крайние случаи», п. 6)

ПДн-паспорт: справочник валют и курсов — не ПДн. Выбор валюты пользователем хранится в сессии/профиле как обычная настройка отображения, отдельного согласия не требует; при удалении пользователя (UserDeleted) выбор валюты удаляется вместе с профилем штатным механизмом ядра.

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
cms/cbr-rates (шина событий, rates-provider)currency_code, rate_to_base, effective_atкурс > 0, currency_code есть в справочнике; неизвестный код — в лог, не в историю
Ручное обновление курса (Filament/API)currency_code, rate_to_baseFormRequest-whitelist, курс > 0, Idempotency-Key (влияет на отображаемые цены)
Выбор валюты пользователем (виджет)currency_codeпубличный whitelisted эндпоинт, код сверяется со справочником commerce_currencies, неизвестный код → 422
Форма справочника валют (Filament)code, title, symbol, decimal_places, symbol_position, rounding_modeFormRequest, code — ISO 4217, уникальность
Смена базовой валюты (служебная команда, не форма настроек)new_codeдоступна только при отсутствии живых заказов, см. «Крайние случаи», п. 6

Выходы:

ПотребительДанныеФормат
Витрина (карточка/листинг/корзина)цена в валюте пользователяконверсия на выводе через фильтр отображения, хранимая сумма не меняется
GET /api/v1/commerce-currenciesсписок валют с текущим курсом200 + meta.degraded/meta.degraded_reason при устаревшем курсе (см. «Крайние случаи», п. 1)
Filament adminистория курсов, аудит смены базовой валютыkeyset-JSON
Подписчики событийCurrencyRateUpdatedpayload по таблице ниже
Заказ (снапшот при оформлении)валюта и курс на момент оформленияпишется в позицию заказа модулем cms/commerce-orders, не пересчитывается задним числом

Настройки (группа commerce-currencies)

КлючТипДефолтaffectsPageCacheОписание
commerce-currencies.base_currencystringRUBдаБазовая валюта хранения цен; смена — только служебной командой, см. «Крайние случаи», п. 6
commerce-currencies.enabledboolfalseдаВключение мультивалютности и переключателя
commerce-currencies.rate_sourcestringcbrнетИсточник обновления курсов (cbr/manual)
commerce-currencies.max_rate_age_hoursint24нетПорог устаревания курса (staleness), после которого включается деградация, см. «Крайние случаи», п. 1
commerce-currencies.rounding_modestringupнетПравило округления отображаемой цены: up (всегда в пользу магазина) — дефолт и зафиксированное решение, см. «Крайние случаи», п. 3
commerce-currencies.stale_rate_kill_switchboolfalseдаKill-switch: ручное принудительное скрытие переключателя валют (эффект как при превышении max_rate_age_hours), не дожидаясь автоматики

Лимиты и квоты: cbr-rates обновляет курсы не чаще раза в сутки по умолчанию (частота — на стороне провайдера, не в этом модуле); ручное обновление курса — без лимита по частоте, но каждое — отдельная append-only запись в историю, дашборд Filament показывает объём истории и предлагает архивацию записей старше конфигурируемого срока (не более 2 лет горячих данных).

API

МетодПутьДоступНазначение
GET/api/v1/commerce-currenciespublicСписок доступных валют с текущим курсом; при staleness — деградированный ответ
POST/api/v1/commerce-currencies/selectpublicСохранение выбора валюты пользователем в сессии/профиле
POST/api/v1/admin/commerce-currencies/ratesadmin (commerce-currencies.manage)Ручное обновление курса валюты (Idempotency-Key)
POST/api/v1/admin/commerce-currencies/base-currencyadmin (commerce-currencies.manage, отдельное подтверждение)Служебная смена базовой валюты — только при отсутствии живых заказов, см. «Крайние случаи», п. 6

Компоненты

Блоки (BlockRegistry): переключатель валюты в шапке (personalized: false — валюта не персональный контент, безопасна для общего page-cache при city_id/currency в измерении ключа, см. «Производительность и кеш»). Filament: справочник валют, история курсов, экран аудита смены базовой валюты. Команды: cms:commerce-currencies:sync-rates --json --dry-run.

Демо-контент: CommerceCurrenciesDemoSeeder создаёт 2–3 валюты (в т.ч. базовую) с историей из 2 курсов каждая — галерея /_gallery показывает переключатель и конверсию цены демо-товара без ручного ввода.

Фронтенд-бюджет: переключатель — лёгкий island-компонент, конверсия цены считается на сервере и приходит готовым текстом (не пересчитывается на клиенте по сырому курсу, чтобы не плодить расхождений округления между сервером и браузером).

Эксплуатация (ранбук): метрики commerce_currencies_rate_age_seconds (свежесть курса по каждой валюте), commerce_currencies_degraded_responses_total; алерт — rate_age_seconds приближается к max_rate_age_hours (упреждающий сигнал до включения деградации).

СимптомЧто проверить / команда
Переключатель валют пропалcommerce-currencies.enabled, stale_rate_kill_switch, max_rate_age_hours против фактического возраста курса
Курс не обновляетсяcms:commerce-currencies:sync-rates --dry-run, доступность cms/cbr-rates (rates-provider), rate_source
Цена в валюте не сходится с базовой на глазrounding_mode конкретной валюты, decimal_places, дата курса в истории
Заказ показывает старую валюту после смены базовойожидаемое поведение — заказ снапшотит валюту/курс на момент оформления, не баг

Бэкап/рестор: commerce_currencies, commerce_currency_rates, commerce_base_currency_changes — в обычном бэкапе БД, история курсов append-only восстанавливается вместе с базой без дополнительных шагов.

События и обмен

СобытиеКогдаPayload
CurrencyRateUpdatedобновлён курс валюты (авто или вручную)currency_code, rate_to_base, effective_at, source (cbr|manual)
BaseCurrencyChangedзавершена служебная смена базовой валютыold_code, new_code, recalculated_prices_count

Слушает: событие обновления курсов из cms/cbr-rates (rates-provider).

Provides-контракты: не предоставляет из канонического реестра (потребляет rates-provider от cms/cbr-rates, если включён). FilterBus: конверсия цены на выводе — через фильтр отображения цены, применяемый после резолвера cms/commerce-pricing, без изменения хранимой суммы.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/cbr-ratesшина событий (1), контракт rates-providercbr-rates → commerce-currenciesновый курс → запись в историю, без прямого HTTP к ЦБ РФ из этого модуля
cms/commerce-pricingFilterBus (2)commerce-currencies → pricing/витринаконверсия итоговой цены на выводе после резолвера цены
cms/commerce-ordersпрямой сервис-вызов (4, потребитель снапшота)orders → commerce-currenciesчтение текущего курса в момент оформления для снапшота позиции заказа
Подписчики (аналитика, admin-дашборд)шина событий (1)commerce-currencies → подписчикиCurrencyRateUpdated, BaseCurrencyChanged — fire-and-forget
Очередь commerce-currenciesочередь (5)commerce-currencies → воркерsync-rates по расписанию

Фоновая работа

Очередь commerce-currencies: sync-rates по расписанию через ScheduleRegistrar (при rate_source=cbr) — забор курсов и запись новой строки истории, не перезапись прошлых. Служебная смена базовой валюты — отдельная управляемая команда (не очередная джоба по расписанию), запускается вручную при выполнении предусловий из «Крайние случаи», п. 6, и сама ставит задачу пересчёта commerce_prices в очередь батчами.

Производительность и кеш

Ожидаемые объёмы: единицы–десятки валют, история курсов — тысячи записей в год при ежедневном обновлении. Горячий путь — конверсия цены на карточке/листинге: курс читается из кеша (не из БД на каждый рендер), конверсия — арифметика без запроса. Бюджет: 0 дополнительных запросов к БД на рендер карточки при тёплом кеше курса.

Тег commerce-currencies:rates. Инвалидация — событием CurrencyRateUpdated, точечная по валюте (не сброс всего тега при обновлении одной валюты из нескольких). Справочник валют кешируется отдельно, TTL не критичен (меняется редко). Если публичный page-cache сегментирован по валюте пользователя — currency_code обязателен в измерении ключа наравне с city_id (мультигород) и locale (мультиязычность), иначе цена одной валюты протечёт в кеш другой.

Безопасность

Ручное обновление курса — FormRequest-whitelist + Idempotency-Key (влияет на отображаемые цены); источник cbr-rates — только через requires/rates-provider, не прямой HTTP к внешнему API из этого модуля. Смена базовой валюты — отдельное право и обязательное подтверждение (необратимая по эффекту операция для новых цен), rate-limit на публичный выбор валюты — по сессии/IP (предотвращение накрутки метрик переключений). Права: commerce-currencies.view, commerce-currencies.manage, commerce-currencies.change-base (выделено из manage — см. «Крайние случаи», п. 6).

Матрица ролей:

ДействиеАдминистраторМенеджерБухгалтерStudio
Просмотр валют/курсов (view)
Ручное обновление курса (manage)
Справочник валют, rounding_mode, stale_rate_kill_switch
Смена базовой валюты (change-base)

UX-требования

Админ: пустое состояние справочника валют — «Валют нет — магазин работает в базовой валюте» со ссылкой «Добавить валюту»; форма ручного обновления курса показывает текущий и предыдущий курс рядом для сверки перед сохранением; человеческая ошибка при попытке сменить базовую валюту с живыми заказами — блокирующее сообщение с числом незавершённых заказов и ссылкой на их список, не общий «operation forbidden»; подтверждение (двойное — чекбокс + кнопка) перед необратимой сменой базовой валюты.

Покупатель: переключение валюты не требует перезагрузки страницы, отклик мгновенный (пересчёт уже загруженных цен на клиенте по готовому серверному курсу, без нового запроса за каждой ценой); при деградации (устаревший курс) переключатель либо скрыт, либо явно показывает «курс на {дата}», без немой/непонятной недоступности; выбор валюты сохраняется между визитами.

Крайние случаи и типовые баги

  1. Курс не обновился вовремя (staleness) — превышен commerce-currencies.max_rate_age_hours: зафиксированное решение — kill-switch, не показ устаревшего курса как актуального. Переключатель валют скрывается, витрина показывает только базовую валюту, публичный GET /api/v1/commerce-currencies отвечает 200 с meta.degraded: true, meta.degraded_reason: "rate_stale" (конвенция деградации ядра, не 5xx). Альтернатива «показывать последний курс с пометкой даты» отклонена — риск оформления по заведомо неверной цене в конвертированной валюте перевешивает неудобство временного отключения переключателя.
  2. Кросс-курс валюта A → валюта B (минуя базовую) — зафиксированное решение: прямых кросс-курсов модуль не хранит и не считает; любая конверсия — цепочкой через базовую валюту (A → base → B, два умножения на rate_to_base). Отдельная таблица кросс-курсов не заводится — это исключает рассинхронизацию «A→B» с независимо обновляемыми «A→base»/«B→base».
  3. Округление отображаемой цены — зафиксированное решение: rounding_mode=up по умолчанию (округление всегда в пользу магазина, до decimal_places валюты) — исключает недосбор при конвертации. Банковское округление (round half to even) как альтернатива отклонена: модуль не хранит деньги в конвертированной валюте (хранение — только в базовой), округление влияет только на отображаемую/списываемую в заказе сумму, где округление в пользу продавца — стандартная розничная практика, зафиксированная per-валюта через rounding_mode.
  4. Оплата в валюте, отличной от базовой — оплата всегда проводится в валюте снапшота заказа (валюта на момент оформления), не в текущей валюте витрины на момент оплаты: если пользователь сменил валюту в шапке между оформлением и оплатой, это не меняет уже зафиксированную сумму заказа.
  5. Смена курса задним числом (ручная правка с effective_at в прошлом) — история append-only, новая запись не переписывает старые; резолвер конверсии всегда берёт ближайший курс не позже даты сравнения, но уже оформленные заказы (снапшот) это не затрагивает в любом случае.
  6. Смена базовой валюты хранения (base_currency) при живых заказах — зафиксированное решение: операция заблокирована на уровне API/Filament, пока в системе есть незавершённые (не в финальном статусе) заказы; при их отсутствии доступна только через отдельное право commerce-currencies.change-base и служебный эндпоинт, который батчами пересчитывает текущие commerce_prices в новую базовую валюту и пишет запись в commerce_base_currency_changes (аудит, append-only). Уже оформленные исторические заказы не страдают в любом случае — они снапшотят валюту и курс на момент оформления (commerce-model.md), пересчёту не подлежат. ⚠️ Противоречие: «часть заказов уже исторически неисполнима без правок» vs «нельзя менять базовую валюту, пока есть живые заказы» — разрешение: критерий блокировки — только незавершённые заказы (open/processing), исполненные/отменённые/возвращённые заказы полностью историчны и на блокировку не влияют.
  7. Пустой справочник валют при включённом модулеenabled=true, но валют кроме базовой не заведено: переключатель не рендерится (нечего переключать), это не ошибка конфигурации, а валидное переходное состояние до заведения хотя бы одной дополнительной валюты.
  8. Гонка обновления курсаsync-rates (авто) и ручное обновление курса одной валюты почти одновременно: обе пишут отдельные append-only записи с разным effective_at, конфликта нет по построению (не UPDATE одной строки); резолвер использует запись с максимальным effective_at.
  9. Отключение cms/cbr-rates при rate_source=cbrsuggests-зависимость пропала: sync-rates не имеет источника, курсы перестают обновляться, что штатно приводит к staleness и деградации по п. 1, а не к ошибке джобы; админ видит предупреждение сменить rate_source на manual или восстановить cbr-rates.
  10. Выключение модуля с historical-заказами в разных валютах — исторические заказы сохраняют свой снапшот валюты для отображения в админке (нельзя показать заказ в валюте, которой уже нет в справочнике) — справочник валют не удаляется физически при выключении модуля, только скрывается публичный переключатель.
  11. Огромная история курсов (годы ежедневных sync-rates) — чтение текущего курса — по индексу (currency_code, effective_at) с выборкой последней записи, не сканирование всей истории; архивация старых записей — отдельная опциональная команда, не блокирует горячий путь.
  12. Idempotency-Key повторной отправки ручного обновления курса — повторный запрос с тем же ключом не создаёт вторую запись истории с тем же effective_at, возвращает тот же результат первого выполнения (стандартная идемпотентность мутаций с денежным эффектом, см. core.md).

Донорский код

Что взятьПуть
Опыт работы с мультивалютностью и курсамиer (путь не выдан)

Legacy-импорт: cms:commerce-currencies:import-legacy --source=<профиль> — маппинг справочника валют и истории курсов прежней инсталляции на commerce_currencies/ commerce_currency_rates по ключу external_id; идемпотентен, --dry-run строит отчёт расхождений (новые валюты, дельта курсов) перед применением. Прогон на копии донорских данных — часть приёмки.

Тесты и приёмка

  • [ ] Контрактный тест: хранение цены всегда в базовой валюте, конверсия применяется только при выводе
  • [ ] Смена курса не пересчитывает исторические заказы (снапшот заказа хранит валюту и курс на момент оформления)
  • [ ] Округление отображаемой цены соответствует decimal_places и rounding_mode=up валюты (всегда в пользу магазина)
  • [ ] Кросс-конверсия A→B считается цепочкой через базовую валюту, отдельная таблица кросс-курсов отсутствует
  • [ ] Превышение max_rate_age_hours даёт 200 + meta.degraded: true + meta.degraded_reason: "rate_stale", не 5xx и не тихий показ старого курса как актуального
  • [ ] stale_rate_kill_switch мгновенно скрывает переключатель независимо от фактического возраста курса
  • [ ] Смена base_currency заблокирована при наличии незавершённых заказов; при их отсутствии — батчевый пересчёт commerce_prices с записью в commerce_base_currency_changes
  • [ ] Повторная отправка ручного обновления курса с тем же Idempotency-Key не дублирует запись истории
  • [ ] При выключении модуля все цены показываются в базовой валюте без переключателя
  • [ ] currency_code участвует в измерении ключа page-cache при сегментации по валюте — нет утечки цены одной валюты в кеш другой
  • [ ] Права commerce-currencies.view/.manage/.change-base разграничивают чтение, правку курсов и смену базовой валюты
  • [ ] cms:commerce-currencies:import-legacy --dry-run строит корректный отчёт расхождений на копии донора
  • [ ] Контрактный набор cms-testing и testbench-изоляция зелёные, feature-тест на каждый роут
  • [ ] Тестовая БД только commerce-currencies_test; migrate:fresh/refresh/reset запрещены

Внутренняя база знаний студии