Skip to content

ТЗ — Скидки/промокоды (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_rulesid, 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_codesid, rule_id, code, type (single_use|multi_use|personal), user_id (nullable), usage_limit, used_countпромокоды как условие, type — PHP Enum
commerce_promo_applicationsid, order_id, rule_id, code_id (nullable), discount_amount, applied_atappend-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-codecode (body)формат — whitelist-regex через ReDoS-валидатор ядра; резолв только среди is_active=true в окне active_from/active_to на момент запроса
DELETE /api/v1/cart/promo-codeнет полей, только контекст корзиныSanctum-токен/сессия гостя
POST/PUT /api/v1/admin/promo-rulescondition (json по схеме ниже), action (json по схеме ниже), priority, stop_further, active_from, active_to, is_active, lock_versionFormRequest + JSON-схема условия/действия (whitelist типов), lock_version → 409 при конфликте, ReDoS-валидатор для строковых операндов
POST /api/v1/admin/promo-codesrule_id, type, user_id (только при type=personal), usage_limitFormRequest 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.typecart_total | group | has_coupon | category | qty | first_orderобязательно
condition.operatorgte | eq | in (по типу условия)зависит от type
condition.valueчисло / строка / массив id (по типу)обязательно
condition.logicand | or — комбинирование вложенных условийопционально, дефолт and
action.typepercent | 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.enabledbooltrueнетВключение системы скидок и промокодов
commerce-promo.max_rules_per_orderint10нетМаксимум одновременно применяемых правил на заказ
commerce-promo.stacking_with_loyaltystringdiscounts_firstнетПорядок применения скидок и бонусов (discounts_first/loyalty_first)
commerce-promo.stack_with_old_priceboolfalseнетРазрешить применение скидки rule-engine к ТП, у которого уже стоит тип цены old/промо-цена (по умолчанию запрещено — двойная уценка)
commerce-promo.kill_switchboolfalseнетKill-switch: аварийная остановка применения всех правил и промокодов без выключения модуля — виджет ввода кода остаётся видим, но скидки не считаются, apply-promo-code отвечает понятной причиной
commerce-promo.apply_attempts_rate_limitint20/миннетЛимит попыток применения кода на IP/сессию — защита от перебора персональных/многоразовых кодов
commerce-promo.personal_code_retention_daysint180нетРетеншн привязки user_id неактивного персонального промокода (152-ФЗ, см. ПДн-паспорт)

Достижение max_rules_per_order/apply_attempts_rate_limit — понятная ошибка и метрика, не 500 и не тихое обрезание списка правил.

API

МетодПутьДоступНазначение
POST/api/v1/cart/apply-promo-codepublic/authПрименение промокода к корзине (Idempotency-Key — денежный эффект)
DELETE/api/v1/cart/promo-codepublic/authСнятие применённого промокода
GET/api/v1/admin/promo-rulesadmin (commerce-promo.view)Список правил rule-engine (keyset-пагинация)
POST/api/v1/admin/promo-rulesadmin (commerce-promo.manage)Создание/правка правила
POST/api/v1/admin/promo-rules/{id}/previewadmin (commerce-promo.manage)Предпросмотр эффекта правила на тестовой корзине без сохранения (см. «UX-требования»)
GET/api/v1/admin/promo-codes/{id}/applicationsadmin (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/loyaltyFilterBus (канал 2), тот же конвейерcommerce-promo ↔ loyaltyпорядок применения скидок/бонусов по stacking_with_loyalty внутри одного пересчёта, не двумя независимыми событиями
cms/commerce-deliveryprovides-контракт действия «бесплатная доставка» (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/healthhealth-чек модуляcommerce-promo → cms/healthотставание очереди, всплеск неудачных попыток применения кода
Filament / admin APIREST, внешний канал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 --jsoncms: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
Правило сработало «не в ту сторону» после правки в Filamentdocs/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 показа vs active_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 запрещены

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