Тема
ТЗ — Валюты (cms/commerce-currencies)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: er (крипто-опыт) Статус: ТЗ к разработке
Назначение и возможности
Мультивалютность поверх модели цен: базовая валюта хранения фиксирована, отображение в валюте пользователя — конверсия только на выводе, не в хранении (см. /cms-v2/commerce-model).
- Базовая валюта хранения (все
amount_minorв БД — в ней, без пересчёта задним числом) - Справочник курсов валют с историей изменения курса
- Обновление курсов модулем
cms/cbr-rates(крючок, без прямой интеграции с ЦБ РФ внутри модуля) - Отображение цены в валюте пользователя — конверсия на выводе по актуальному курсу
- Формат и округление отображения per-валюта (разряды, символ, позиция символа)
- Переключатель валюты для пользователя (сохраняется в сессии/профиле)
Зависимости и выключение
requires: cms/commerce-pricing · suggests: cms/cbr-rates
Поведение при выключении: витрина показывает цены только в базовой валюте хранения без конверсии и переключателя — деградация до одновалютного магазина, оформление заказа не ломается.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_currencies | code, title, symbol, decimal_places, symbol_position, rounding_mode | справочник валют и формата отображения; rounding_mode — PHP Enum, см. «Настройки» |
commerce_currency_rates | id, currency_code, rate_to_base, effective_at | история курсов к базовой валюте (кросс-курс между двумя небазовыми валютами не хранится отдельной записью, см. «Крайние случаи», п. 2), append-only; FK currency_code — constrained()->index(), индекс по effective_at |
commerce_base_currency_changes | id, old_code, new_code, changed_at, changed_by, recalculated_prices_count | append-only аудит смены базовой валюты (см. «Крайние случаи», п. 6) |
ПДн-паспорт: справочник валют и курсов — не ПДн. Выбор валюты пользователем хранится в сессии/профиле как обычная настройка отображения, отдельного согласия не требует; при удалении пользователя (UserDeleted) выбор валюты удаляется вместе с профилем штатным механизмом ядра.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
cms/cbr-rates (шина событий, rates-provider) | currency_code, rate_to_base, effective_at | курс > 0, currency_code есть в справочнике; неизвестный код — в лог, не в историю |
| Ручное обновление курса (Filament/API) | currency_code, rate_to_base | FormRequest-whitelist, курс > 0, Idempotency-Key (влияет на отображаемые цены) |
| Выбор валюты пользователем (виджет) | currency_code | публичный whitelisted эндпоинт, код сверяется со справочником commerce_currencies, неизвестный код → 422 |
| Форма справочника валют (Filament) | code, title, symbol, decimal_places, symbol_position, rounding_mode | FormRequest, code — ISO 4217, уникальность |
| Смена базовой валюты (служебная команда, не форма настроек) | new_code | доступна только при отсутствии живых заказов, см. «Крайние случаи», п. 6 |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Витрина (карточка/листинг/корзина) | цена в валюте пользователя | конверсия на выводе через фильтр отображения, хранимая сумма не меняется |
GET /api/v1/commerce-currencies | список валют с текущим курсом | 200 + meta.degraded/meta.degraded_reason при устаревшем курсе (см. «Крайние случаи», п. 1) |
| Filament admin | история курсов, аудит смены базовой валюты | keyset-JSON |
| Подписчики событий | CurrencyRateUpdated | payload по таблице ниже |
| Заказ (снапшот при оформлении) | валюта и курс на момент оформления | пишется в позицию заказа модулем cms/commerce-orders, не пересчитывается задним числом |
Настройки (группа commerce-currencies)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-currencies.base_currency | string | RUB | да | Базовая валюта хранения цен; смена — только служебной командой, см. «Крайние случаи», п. 6 |
commerce-currencies.enabled | bool | false | да | Включение мультивалютности и переключателя |
commerce-currencies.rate_source | string | cbr | нет | Источник обновления курсов (cbr/manual) |
commerce-currencies.max_rate_age_hours | int | 24 | нет | Порог устаревания курса (staleness), после которого включается деградация, см. «Крайние случаи», п. 1 |
commerce-currencies.rounding_mode | string | up | нет | Правило округления отображаемой цены: up (всегда в пользу магазина) — дефолт и зафиксированное решение, см. «Крайние случаи», п. 3 |
commerce-currencies.stale_rate_kill_switch | bool | false | да | Kill-switch: ручное принудительное скрытие переключателя валют (эффект как при превышении max_rate_age_hours), не дожидаясь автоматики |
Лимиты и квоты: cbr-rates обновляет курсы не чаще раза в сутки по умолчанию (частота — на стороне провайдера, не в этом модуле); ручное обновление курса — без лимита по частоте, но каждое — отдельная append-only запись в историю, дашборд Filament показывает объём истории и предлагает архивацию записей старше конфигурируемого срока (не более 2 лет горячих данных).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/commerce-currencies | public | Список доступных валют с текущим курсом; при staleness — деградированный ответ |
| POST | /api/v1/commerce-currencies/select | public | Сохранение выбора валюты пользователем в сессии/профиле |
| POST | /api/v1/admin/commerce-currencies/rates | admin (commerce-currencies.manage) | Ручное обновление курса валюты (Idempotency-Key) |
| POST | /api/v1/admin/commerce-currencies/base-currency | admin (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-provider | cbr-rates → commerce-currencies | новый курс → запись в историю, без прямого HTTP к ЦБ РФ из этого модуля |
cms/commerce-pricing | FilterBus (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»; подтверждение (двойное — чекбокс + кнопка) перед необратимой сменой базовой валюты.
Покупатель: переключение валюты не требует перезагрузки страницы, отклик мгновенный (пересчёт уже загруженных цен на клиенте по готовому серверному курсу, без нового запроса за каждой ценой); при деградации (устаревший курс) переключатель либо скрыт, либо явно показывает «курс на {дата}», без немой/непонятной недоступности; выбор валюты сохраняется между визитами.
Крайние случаи и типовые баги
- Курс не обновился вовремя (staleness) — превышен
commerce-currencies.max_rate_age_hours: зафиксированное решение — kill-switch, не показ устаревшего курса как актуального. Переключатель валют скрывается, витрина показывает только базовую валюту, публичныйGET /api/v1/commerce-currenciesотвечает200сmeta.degraded: true,meta.degraded_reason: "rate_stale"(конвенция деградации ядра, не 5xx). Альтернатива «показывать последний курс с пометкой даты» отклонена — риск оформления по заведомо неверной цене в конвертированной валюте перевешивает неудобство временного отключения переключателя. - Кросс-курс валюта A → валюта B (минуя базовую) — зафиксированное решение: прямых кросс-курсов модуль не хранит и не считает; любая конверсия — цепочкой через базовую валюту (
A → base → B, два умножения наrate_to_base). Отдельная таблица кросс-курсов не заводится — это исключает рассинхронизацию «A→B» с независимо обновляемыми «A→base»/«B→base». - Округление отображаемой цены — зафиксированное решение:
rounding_mode=upпо умолчанию (округление всегда в пользу магазина, доdecimal_placesвалюты) — исключает недосбор при конвертации. Банковское округление (round half to even) как альтернатива отклонена: модуль не хранит деньги в конвертированной валюте (хранение — только в базовой), округление влияет только на отображаемую/списываемую в заказе сумму, где округление в пользу продавца — стандартная розничная практика, зафиксированная per-валюта черезrounding_mode. - Оплата в валюте, отличной от базовой — оплата всегда проводится в валюте снапшота заказа (валюта на момент оформления), не в текущей валюте витрины на момент оплаты: если пользователь сменил валюту в шапке между оформлением и оплатой, это не меняет уже зафиксированную сумму заказа.
- Смена курса задним числом (ручная правка с
effective_atв прошлом) — история append-only, новая запись не переписывает старые; резолвер конверсии всегда берёт ближайший курс не позже даты сравнения, но уже оформленные заказы (снапшот) это не затрагивает в любом случае. - Смена базовой валюты хранения (
base_currency) при живых заказах — зафиксированное решение: операция заблокирована на уровне API/Filament, пока в системе есть незавершённые (не в финальном статусе) заказы; при их отсутствии доступна только через отдельное правоcommerce-currencies.change-baseи служебный эндпоинт, который батчами пересчитывает текущиеcommerce_pricesв новую базовую валюту и пишет запись вcommerce_base_currency_changes(аудит, append-only). Уже оформленные исторические заказы не страдают в любом случае — они снапшотят валюту и курс на момент оформления (commerce-model.md), пересчёту не подлежат. ⚠️ Противоречие: «часть заказов уже исторически неисполнима без правок» vs «нельзя менять базовую валюту, пока есть живые заказы» — разрешение: критерий блокировки — только незавершённые заказы (open/processing), исполненные/отменённые/возвращённые заказы полностью историчны и на блокировку не влияют. - Пустой справочник валют при включённом модуле —
enabled=true, но валют кроме базовой не заведено: переключатель не рендерится (нечего переключать), это не ошибка конфигурации, а валидное переходное состояние до заведения хотя бы одной дополнительной валюты. - Гонка обновления курса —
sync-rates(авто) и ручное обновление курса одной валюты почти одновременно: обе пишут отдельные append-only записи с разнымeffective_at, конфликта нет по построению (неUPDATEодной строки); резолвер использует запись с максимальнымeffective_at. - Отключение
cms/cbr-ratesприrate_source=cbr—suggests-зависимость пропала:sync-ratesне имеет источника, курсы перестают обновляться, что штатно приводит к staleness и деградации по п. 1, а не к ошибке джобы; админ видит предупреждение сменитьrate_sourceнаmanualили восстановитьcbr-rates. - Выключение модуля с historical-заказами в разных валютах — исторические заказы сохраняют свой снапшот валюты для отображения в админке (нельзя показать заказ в валюте, которой уже нет в справочнике) — справочник валют не удаляется физически при выключении модуля, только скрывается публичный переключатель.
- Огромная история курсов (годы ежедневных
sync-rates) — чтение текущего курса — по индексу(currency_code, effective_at)с выборкой последней записи, не сканирование всей истории; архивация старых записей — отдельная опциональная команда, не блокирует горячий путь. 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запрещены