Тема
ТЗ — Скидки/промокоды (rule-engine) (cms/commerce-promo)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal (
notal/src/app/Services/) Статус: ТЗ к разработке
Назначение и возможности
Rule-engine скидок: правило = условие → действие с приоритетом и обрывом цепочки. Промокод — один из типов условий. Модель согласована с доменной моделью коммерции, см. /cms-v2/commerce-model, раздел «Бонусы и скидки».
- Rule-engine: правило = условие (состав корзины, группа пользователя, город, промокод, сумма заказа) → действие (% / фикс / подарок / бесплатная доставка)
priorityиstop_furtherуправляют порядком и обрывом применения правил- Условия комбинируются логическими И/ИЛИ внутри одного правила
- Промокоды как условие: одноразовые, многоразовые, персональные (привязка к пользователю)
- Детерминированное применение правил к корзине (фиксированный порядок пересчёта, включая детерминированный tie-break при равном
priority— см. «Крайние случаи») - Журнал применений: какое правило сработало, на сколько изменило цену, по какому заказу
- Связка с бонусами (
cms/loyalty): порядок применения скидок и бонусов зафиксирован конфигом, итог отражается в ценах строк для чека
Зависимости и выключение
requires: ядро, cms/commerce-model (корзина/заказы) · suggests: cms/loyalty, cms/commerce-delivery (действие «бесплатная доставка»)
Поведение при выключении: корзина и оформление заказа работают по базовым ценам без применения правил — активные промокоды перестают приниматься, поле ввода кода скрывается.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_promo_rules | id, code, condition (json), action (json), priority, stop_further, active_from, active_to, is_active, lock_version | правило rule-engine, condition/action — JSONB + GIN, lock_version — optimistic lock редактирования в Filament |
commerce_promo_codes | id, rule_id, code, type (single_use|multi_use|personal), user_id (nullable), usage_limit, used_count | промокоды как условие, type — PHP Enum |
commerce_promo_applications | id, order_id, rule_id, code_id (nullable), discount_amount, applied_at | append-only журнал применений, BRIN по applied_at |
rule_id/order_id/code_id — FK constrained()->index(). discount_amount — integer minor units + currency_code. Уникальный индекс code в обеих таблицах; составной индекс (priority, id) на commerce_promo_rules под сортировку применения (см. tie-break).
ПДн-паспорт. Хранит: commerce_promo_codes.user_id — привязка персонального промокода к пользователю ядра (без дублирования ПДн, только ссылка). Срок хранения: пока код is_active и commerce-promo.personal_code_retention_days (дефолт 180) после истечения/ исчерпания лимита, затем плановая job обезличивает user_id (код деактивируется, история применения не трогается). commerce_promo_applications — append-only финансовый журнал, хранится бессрочно, ПДн не несёт (order_id — ссылка, не персональные данные напрямую). Участие в 152-ФЗ: хук ядра «выгрузить всё по субъекту» отдаёт список персональных кодов пользователя; хук «забыть по запросу» обнуляет user_id в commerce_promo_codes (код при этом деактивируется — персональный код без владельца применить нельзя), запись в commerce_promo_applications не удаляется (финансовая история, обезличивание невозможно без потери аудита оплат).
Входные и выходные данные
Whitelist-принцип (§11 стандарта): всё, что не перечислено во «входах», модуль обязан отвергать — неизвестные query-параметры, лишние поля правила, лишние ключи импорта.
Входы
| Источник | Поля | Чем валидируется |
|---|---|---|
POST /api/v1/cart/apply-promo-code | code (body) | формат — whitelist-regex через ReDoS-валидатор ядра; резолв только среди is_active=true в окне active_from/active_to на момент запроса |
DELETE /api/v1/cart/promo-code | нет полей, только контекст корзины | Sanctum-токен/сессия гостя |
POST/PUT /api/v1/admin/promo-rules | condition (json по схеме ниже), action (json по схеме ниже), priority, stop_further, active_from, active_to, is_active, lock_version | FormRequest + JSON-схема условия/действия (whitelist типов), lock_version → 409 при конфликте, ReDoS-валидатор для строковых операндов |
POST /api/v1/admin/promo-codes | rule_id, type, user_id (только при type=personal), usage_limit | FormRequest whitelist, user_id обязателен и валиден только для type=personal |
cms:commerce-promo:import-legacy --source=<профиль> | построчно: rule_external_id, code, condition, action, priority, usage_limit, used_count | схема профиля импортёра + JSON-схема условия/действия, --dry-run до применения |
JSON-схема condition/action (жёсткая валидация по схеме, не произвольный код — раздел «Безопасность»):
| Поле | Значения | Обязательность |
|---|---|---|
condition.type | cart_total | group | has_coupon | category | qty | first_order | обязательно |
condition.operator | gte | eq | in (по типу условия) | зависит от type |
condition.value | число / строка / массив id (по типу) | обязательно |
condition.logic | and | or — комбинирование вложенных условий | опционально, дефолт and |
action.type | percent | fixed | cheapest_free | free_shipping | обязательно |
action.value | число (для percent/fixed), отсутствует для cheapest_free/free_shipping | зависит от type |
Неизвестный type/operator/лишнее поле в condition/action → 422 при сохранении правила, не молчаливый игнор и не выполнение как код.
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Корзина (FilterBus, владелец потока cms/commerce-model) | пересчитанные строки корзины со скидками, итоговая сумма | конверт {data, meta}, data.applied_rules[] (rule_id, discount_amount) |
| Шина событий | PromoRuleApplied, PromoCodeRedeemed, PromoRuleStackedWithLoyalty | канал 1, payload — см. «События и обмен» |
| Filament-журнал | какое правило сработало, сумма, заказ | keyset-таблица + CSV-экспорт |
cms/health | отставание очереди commerce-promo, расход лимита попыток подбора кода | health-чек / Pulse |
Чек 54-ФЗ (через cms/commerce-model) | скидка учтена в цене позиций (не «иным способом») | снапшот позиции заказа |
Настройки (группа commerce-promo)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-promo.enabled | bool | true | нет | Включение системы скидок и промокодов |
commerce-promo.max_rules_per_order | int | 10 | нет | Максимум одновременно применяемых правил на заказ |
commerce-promo.stacking_with_loyalty | string | discounts_first | нет | Порядок применения скидок и бонусов (discounts_first/loyalty_first) |
commerce-promo.stack_with_old_price | bool | false | нет | Разрешить применение скидки rule-engine к ТП, у которого уже стоит тип цены old/промо-цена (по умолчанию запрещено — двойная уценка) |
commerce-promo.kill_switch | bool | false | нет | Kill-switch: аварийная остановка применения всех правил и промокодов без выключения модуля — виджет ввода кода остаётся видим, но скидки не считаются, apply-promo-code отвечает понятной причиной |
commerce-promo.apply_attempts_rate_limit | int | 20/мин | нет | Лимит попыток применения кода на IP/сессию — защита от перебора персональных/многоразовых кодов |
commerce-promo.personal_code_retention_days | int | 180 | нет | Ретеншн привязки user_id неактивного персонального промокода (152-ФЗ, см. ПДн-паспорт) |
Достижение max_rules_per_order/apply_attempts_rate_limit — понятная ошибка и метрика, не 500 и не тихое обрезание списка правил.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/cart/apply-promo-code | public/auth | Применение промокода к корзине (Idempotency-Key — денежный эффект) |
| DELETE | /api/v1/cart/promo-code | public/auth | Снятие применённого промокода |
| GET | /api/v1/admin/promo-rules | admin (commerce-promo.view) | Список правил rule-engine (keyset-пагинация) |
| POST | /api/v1/admin/promo-rules | admin (commerce-promo.manage) | Создание/правка правила |
| POST | /api/v1/admin/promo-rules/{id}/preview | admin (commerce-promo.manage) | Предпросмотр эффекта правила на тестовой корзине без сохранения (см. «UX-требования») |
| GET | /api/v1/admin/promo-codes/{id}/applications | admin (commerce-promo.view) | Журнал применений конкретного кода |
Ошибки применения кода — конверт {message, code, errors} с обязательным code (ревизия ядра 14.07.2026): promo_code_not_found, promo_code_expired, promo_code_usage_limit_reached, promo_code_not_yours (персональный код чужого пользователя) — клиент ветвится по code, не по тексту message.
Компоненты
Блоки (BlockRegistry): поле ввода промокода в корзине, отображение применённых скидок. Filament: конструктор правил rule-engine (условие/действие/приоритет) с предпросмотром эффекта на тестовой корзине перед сохранением, справочник промокодов с лимитами использования, журнал применений. Команды: cms:commerce-promo:expire-codes --json, cms:commerce-promo:recount --json (сверка used_count с журналом применений), cms:commerce-promo:import-legacy --source=<профиль> --dry-run --json.
Демо-контент: сидер создаёт демо-правила (процентная скидка, фикс, бесплатная доставка) и демо-промокоды (одноразовый/многоразовый/персональный) — блок ввода кода виден в /_gallery и playground без ручного создания правил.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
PromoRuleApplied | правило применено к заказу/корзине | order_id, rule_id, discount_amount |
PromoCodeRedeemed | промокод использован | code_id, order_id, user_id |
PromoRuleStackedWithLoyalty | скидка и бонусы применены совместно к заказу | order_id, rule_id, loyalty_amount |
Слушает: — (правила применяются в момент пересчёта корзины, не по внешним фактам). FilterBus: модуль — узел конвейера пересчёта корзины/заказа (скидки → бонусы cms/loyalty → стоимость доставки → итог), порядок и обрыв цепочки задаются priority/stop_further.
Таблица взаимодействий (пять каналов — обмен данными):
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-model (корзина) | FilterBus (канал 2) | commerce-model → commerce-promo → commerce-model | владелец потока пересчёта вызывает узел скидок, promo возвращает пересчитанные строки |
cms/loyalty | FilterBus (канал 2), тот же конвейер | commerce-promo ↔ loyalty | порядок применения скидок/бонусов по stacking_with_loyalty внутри одного пересчёта, не двумя независимыми событиями |
cms/commerce-delivery | provides-контракт действия «бесплатная доставка» (suggests) | commerce-promo → commerce-delivery | действие free_shipping резолвится через сервис доставки; при выключенном cms/commerce-delivery — действие деградирует до «нет доставки для расчёта», правило не падает |
| Шина событий | событие (канал 1) | commerce-promo → все подписчики | PromoRuleApplied/PromoCodeRedeemed/PromoRuleStackedWithLoyalty — аудит, аналитика, cms/loyalty |
Очередь commerce-promo | очередь (канал 5) | commerce-promo → commerce-promo (async) | expire-codes по расписанию |
cms/health | health-чек модуля | commerce-promo → cms/health | отставание очереди, всплеск неудачных попыток применения кода |
| Filament / admin API | REST, внешний канал | admin → commerce-promo | конструктор правил, журнал применений, предпросмотр |
| Корзина/чек (публичный API) | REST /api/v1/cart, внешний канал | commerce-promo → покупатель | применённые скидки в ответе корзины |
Фоновая работа
Именованная очередь commerce-promo: cms:commerce-promo:expire-codes по расписанию (деактивация истёкших промокодов). Само применение правил к корзине — синхронно (часть пересчёта на запрос пользователя), без внешних вызовов.
Эксплуатация (ранбук). Метрики: commerce-promo.rules_active_count, commerce-promo.apply_failure_rate (доля неудачных попыток применения кода — индикатор перебора), отставание очереди commerce-promo (Pulse). Алерты: apply_failure_rate > 30% за 10 минут (подозрение на брутфорс кодов, несмотря на rate-limit), отставание очереди > 15 мин, расхождение used_count с журналом применений при recount --dry-run.
| Симптом | Что проверить | Чем чинить |
|---|---|---|
| Скидка не применяется, хотя условие выполнено | commerce-promo.kill_switch (Filament/настройки) | выключить kill-switch либо дождаться исправления проблемного правила |
used_count промокода разошёлся с журналом применений | cms:commerce-promo:recount --dry-run --json | cms:commerce-promo:recount --json — идемпотентная сверка по commerce_promo_applications |
| Массовый всплеск попыток применения кода | Pulse: apply_failure_rate, распределение по IP | временно снизить apply_attempts_rate_limit, при системной атаке — commerce-promo.kill_switch=true до разбора |
| Истёкшие промокоды продолжают приниматься | лог cms:commerce-promo:expire-codes | ручной прогон cms:commerce-promo:expire-codes --json, проверить расписание в ScheduleRegistrar |
| Правило сработало «не в ту сторону» после правки в Filament | docs/module.md — история semver правила, журнал применений до/после правки | откат правила к прежней версии условия/действия или деактивация (is_active=false) — не удаление (аудит) |
Бэкап/рестор. В бэкап — все 3 таблицы целиком (commerce_promo_applications — финансово значимая история, обязательна). После рестора пересоздаётся командой: cms:commerce-promo:recount --json (агрегаты used_count). Рестор без её прогона не считается завершённым.
Производительность и кеш
Ожидаемые объёмы: сотни–тысячи активных правил на крупный магазин, тысячи–десятки тысяч промокодов (в основном персональных/одноразовых), журнал применений растёт с каждым заказом со скидкой (append-only, партиционирование по applied_at — кандидат при росте).
Горячий путь — пересчёт корзины на каждое изменение состава (добавили/убрали товар, применили код): правила читаются из кеша группы commerce-promo:rules, 0 запросов к БД на этом пути (стандарт §10); резолв конкретного кода — короткий индексный запрос по code (уникальный индекс), не полное сканирование таблицы. Бюджет запросов пересчёта корзины: 1 запрос на резолв кода (если код указан) + 0 запросов на чтение правил (кеш) + 1 insert в commerce_promo_applications при финализации заказа.
Критичные индексы: unique code в обеих таблицах; составной (priority, id) на commerce_promo_rules под детерминированную сортировку применения; (rule_id, applied_at) на commerce_promo_applications; BRIN по applied_at (append-heavy журнал).
Теги кеша commerce-promo:rules, commerce-promo:code:<code>. Инвалидация — правкой правила в Filament, событием PromoCodeRedeemed (обновление used_count). Корзина с применённой скидкой — персональные/коммерческие данные (сумма скидки зависит от кода/ группы конкретного покупателя): страница корзины/чекаута не попадает в общий page-cache, сегментация — механизмом ядра (флаг personalized у блока корзины, ревизия контрактов), самодельная сегментация в модуле запрещена.
Безопасность
Применение промокода — rate-limit на попытки подбора (публичный эндпоинт без авторизации для гостя), настройка apply_attempts_rate_limit. Персональный промокод проверяется на привязку user_id — пользователь не может применить чужой персональный код (IDOR: код не должен быть узнаваем перебором — формат кода не содержит предсказуемого user_id/инкремента, ошибка promo_code_not_yours не раскрывает, кому код принадлежит). Правка правил rule-engine — под commerce-promo.manage, произвольный JSON условия/действия проходит валидацию схемы (см. «Входные и выходные данные»), интерпретируется декларативно движком правил — не выполняется как код (никаких eval/ динамических вызовов по значениям из JSON).
Подмена суммы скидки на клиенте. Скидка пересчитывается на сервере при каждом запросе корзины/оформления заказа — сумма скидки, переданная с фронтенда, никогда не используется как источник истины: сервер знает только применённый code/набор правил, пересчитывает discount_amount заново от исходных цен позиций (см. «Крайние случаи» — пересчёт при изменении корзины).
Разграничение прав: commerce-promo.view, commerce-promo.manage.
Матрица ролей (permission × роль, дефолт):
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
commerce-promo.view (журнал, справочник кодов) | ✅ | ✅ | — | ✅ |
commerce-promo.manage (конструктор правил, kill-switch) | ✅ | — | — | ✅ |
Конструктор правил и kill-switch — риск прямого влияния на выручку (ошибка в правиле уценивает весь каталог), поэтому только повышенные роли; менеджер видит журнал и справочник кодов, но не редактирует правила.
UX-требования
Админ. Конструктор правил: пустое состояние «правил ещё нет» с кнопкой создать из шаблона (процент/фикс/бесплатная доставка); предпросмотр эффекта правила на тестовой корзине перед сохранением (POST .../preview — показывает, какие позиции затронет и на сколько изменится сумма, без записи в БД) — исключает публикацию правила «наугад». Массовые действия: деактивация пачкой выбранных промокодов/правил. Человеческие ошибки: «правило конфликтует с уже существующим по этому промокоду» вместо голого 422; «kill-switch включён — скидки временно не считаются» баннером на странице конструктора, пока флаг активен. Подтверждение необратимых операций: деактивация правила с активными применениями — предупреждение «уже применено к N заказам», не блокирует, но требует подтверждения.
Посетитель. Понятная ошибка невалидного/истёкшего промокода — не «ошибка», а причина по code из конверта: «код не найден», «срок действия истёк», «лимит использований исчерпан», «этот код не для вас» (без раскрытия, чей он); поле ввода сохраняет введённое значение при ошибке, не сбрасывается. Применённая скидка видна сразу в строках корзины (не только в итоге) — прозрачность «за что скидка». Снятие кода — без перезагрузки страницы, воспринимаемый отклик быстрый (пересчёт из кеша правил).
Крайние случаи и типовые баги
- Одинаковый
priorityу двух правил → детерминированный tie-break: сортировка примененияORDER BY priority ASC, id ASC(более раннее по созданию правило применяется первым) — не случайный порядок БД-выборки; контрактный тест гоняет одну и ту же корзину многократно и проверяет идентичность результата. - Скидка на товар с уже применённым типом цены
old/промо → политика стекинга: по умолчанию (stack_with_old_price=false) правило не применяется поверх уже уценённой позиции — позиция исключается из расчёта этого правила, ошибки нет, просто скидка не ложится на уже сниженную цену; приstack_with_old_price=trueприменяется поверх явно (клиент студии осознанно разрешил двойную уценку). - Промокод истёк между показом корзины и оформлением заказа (
applied_atпоказа vsactive_toна момент финализации) → на шаге оформления заказа код проверяется заново: истёк — отказ с понятной причиной (promo_code_expired), пересчёт корзины без скидки, заказ не оформляется молча со старой (просроченной) суммой; тихое применение просроченного правила — баг. - Скидка больше суммы позиции/заказа → floor на 0: итоговая цена позиции/заказа никогда не уходит в отрицательное значение (
fixed/percent-действие ограничивается сверху суммой позиции), избыток скидки не переносится на другие позиции без явного правила «cheapest_free». - Изменение состава корзины после применения скидки (добавили/убрали товар) → правила пересчитываются заново от исходных базовых цен всех позиций, не накопительно поверх предыдущего результата пересчёта — иначе двойное применение скидки к уже уценённым строкам;
discount_amountв ответе — итог свежего пересчёта, не дельта к прошлому. - Двойной сабмит
apply-promo-code(двойной клик/повтор запроса) →Idempotency-Keyна эндпоинте: повторный запрос с тем же ключом не увеличиваетused_countдважды и не создаёт вторую запись применения. stop_further=trueу правила с более низким приоритетом, чем ожидал админ → контрактный тест конструктора: предпросмотр (preview) обязан наглядно показывать, какие правила ниже по приоритету не сработали из-за обрыва цепочки — не только итоговую сумму.cms/loyaltyвыключен,stacking_with_loyalty=loyalty_first→ конфигурация бессмысленна безcms/loyalty(нечего применять первым): конвейер деградирует до «только скидки»,PromoRuleStackedWithLoyaltyне издаётся,PromoRuleApplied— как обычно; не 500 и не падение пересчёта корзины.cms/commerce-deliveryвыключен, правило с действиемfree_shipping→ действие деградирует: скидка на доставку не считается (нечего обнулять), остальные действия правила (если условие комбинированное) применяются штатно,PromoRuleAppliedиздаётся без учёта доставки — не ошибка правила.- Пустые/огромные данные: 0 активных правил — корзина считается по базовым ценам без ошибок, поле ввода кода видимо и просто ничего не находит; тысячи правил на одну корзину —
max_rules_per_orderобрезает применение с понятной меткой в журнале (limit_reached), не тихим отбрасыванием лишних без следа. - Ловушка измерения
city_id: условиеgroup/cart_totalне зависит от города, ноcondition.valueтипаcategoryссылается на раздел каталога — раздел мультигородской инсталляции разрешается в контексте текущегоRequestContext.city_id, правило работает одинаково в мультигороде и без него (правило измерений §4 стандарта). - ⚠️ Противоречие: подмена суммы с клиента vs скорость воспринимаемого отклика. UX-требование «пересчёт без перезагрузки страницы» подразумевает клиентский предпоказ суммы скидки, а раздел «Безопасность» требует не доверять клиентскому значению. Разрешение в этом ТЗ: клиент может оптимистично показать предполагаемую сумму сразу после ввода (для отклика), но окончательная сумма всегда приходит ответом сервера того же запроса
apply-promo-code/пересчёта корзины и замещает клиентский предпоказ — оптимистичное значение никогда не участвует в оформлении заказа.
Донорский код
| Что взять | Путь |
|---|---|
| Rule-engine скидок и промокодов | notal/src/app/Services/ |
Миграция legacy-данных. cms:commerce-promo:import-legacy --source=<профиль> — маппинг донорских таблиц (правила и промокоды notal) на commerce_promo_rules/ commerce_promo_codes; condition/action донора конвертируются в каноническую JSON-схему модуля (см. «Входные и выходные данные»), несовместимые старые типы условий — в отчёт расхождений, не молчаливый пропуск. Идемпотентность — по nullable unique external_id на обеих таблицах: повторный прогон обновляет существующие записи, не дублирует. --dry-run — отчёт (сколько создано/обновлено/пропущено и почему) без записи. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: применение правил к одной и той же корзине детерминировано (одинаковый вход → одинаковый результат), включая tie-break при равном
priority(ORDER BY priority ASC, id ASC) - [ ]
stop_further=trueобрывает цепочку правил ниже по приоритету - [ ] Одноразовый промокод не применяется повторно к новому заказу того же пользователя
- [ ] Персональный промокод не может применить пользователь, к которому он не привязан (IDOR: перебор кодов не раскрывает существование/владельца)
- [ ] Порядок применения скидок и бонусов соответствует
stacking_with_loyalty, итог корректно отражается в ценах строк чека - [ ] Стекинг со «старой ценой»:
stack_with_old_price=falseисключает уценённую позицию из применения правила,true— применяет поверх - [ ] Промокод, истёкший между показом корзины и оформлением, отклоняется на финализации с
code=promo_code_expired, заказ пересчитывается без скидки - [ ] Скидка не уводит цену позиции/заказа в отрицательное значение (floor на 0)
- [ ] Пересчёт при изменении состава корзины — от исходных цен, без накопительного повторного применения
- [ ] Двойной сабмит
apply-promo-codeс однимIdempotency-Keyне дублирует применение/used_count - [ ] Конкурентная правка одного правила двумя админами → 409 по
lock_version, не «последний победил» - [ ] Сумма скидки, переданная с клиента, игнорируется — сервер пересчитывает заново
- [ ]
kill_switch=trueостанавливает применение правил без выключения модуля и без потери возможности ввести код (понятная причина в ответе) - [ ] Журнал
commerce_promo_applicationsфиксирует применённое правило и сумму на каждый заказ (аудит) - [ ]
cms:commerce-promo:recountидемпотентно сверяетused_countс журналом применений - [ ] ПДн-хук «забыть по запросу» обнуляет
user_idперсональных кодов пользователя, не трогаяcommerce_promo_applications - [ ] Права
commerce-promo.view/commerce-promo.manageразграничивают журнал/справочник и конструктор правил по матрице ролей - [ ]
cms:commerce-promo:import-legacy --dry-runидемпотентен, конвертируетcondition/actionдонора в каноническую схему, отчёт расхождений корректен - [ ] При выключении модуля корзина считается по базовым ценам, ввод промокода недоступен, ошибок оформления нет
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API
- [ ] Тестовая БД — только
commerce-promo_test;migrate:fresh/refresh/resetзапрещены