Skip to content

ТЗ — Купоны/промо-кампании (cms/coupons)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal (notal/src/app/Services/) Статус: ТЗ к разработке

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

Промо-кампании поверх rule-engine скидок cms/commerce-promo: массовая генерация пулов купонов, контроль сроков и лимитов использования, промо-посадочные страницы с таймером обратного отсчёта и статистика применения по кампании.

  • Генерация пула купонов по кампании (уникальные коды, заданное количество, паттерн генерации)
  • Правило скидки кампании — ссылка на существующее правило cms/commerce-promo (не дублирование rule-engine)
  • Сроки действия и лимиты использования: на купон, на пользователя, суммарно по кампании
  • Промо-посадочные с таймером обратного отсчёта до конца акции (использует cms/landings)
  • Статистика применения: выдано / активировано / использовано, по кампании и по коду
  • Ручная деактивация кампании или отдельного купона
  • Один код на корзину одновременно (MVP-решение по стекингу — см. «Крайние случаи»)

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

requires: cms/commerce-promo · suggests: cms/landings (промо-посадочные с таймером)

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

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

ТаблицаКлючевые поляПримечание
cms_coupons_campaignsid, name, promo_rule_id, starts_at, ends_at (UTC), is_active, lock_version, external_idпромо-кампания, ссылка на правило cms/commerce-promo; lock_version — optimistic lock (§4 стандарта); external_id — legacy-импорт (§16)
cms_coupons_codesid, campaign_id, code, max_uses, used_count, max_uses_per_user, external_idсгенерированный код с лимитами использования
cms_coupons_redemptionsid, code_id, user_id, order_id, redeemed_at, external_idфакт применения купона к заказу; append-only, только чтение после записи

FK campaign_id, promo_rule_id, code_id, order_idconstrained() + index(); уникальный индекс code в cms_coupons_codes — коллизии кодов исключены на уровне БД; уникальный индекс (code_id, order_id) в cms_coupons_redemptions от повторного применения к одному заказу; составной индекс (campaign_id, is_active) в cms_coupons_codes — листинг и статистика кампании; BRIN по redeemed_at в cms_coupons_redemptions (журнал только растёт, обычный B-tree раздувается).

Таймзона сроков действия. starts_at/ends_at хранятся в UTC; интерпретация (показ, проверка «действует сейчас») — в таймзоне сайта (site.timezone из группы настроек ядра site), не в таймзоне клиента. Формулировка «до конца дня 31 декабря» фиксируется в ends_at как UTC-эквивалент 23:59:59 таймзоны сайта на момент создания/редактирования кампании — при последующей смене таймзоны сайта уже сохранённые кампании не пересчитываются задним числом.

ПДн-паспорт (матрица v2.2). Модуль не хранит ПДн напрямую — только user_id (ссылка на пользователя ядра) в cms_coupons_redemptions как факт применения кода. Срок хранения журнала применений — настройка coupons.redemptions_retention_days (дефолт 1095 дней/3 года, финансово-статистическая ценность); по истечении — плановая джоба (anonymize-redemptions, см. «Фоновая работа») обнуляет user_id, запись остаётся обезличенной для статистики кампании. 152-ФЗ: «выгрузить всё по субъекту» — выгружает список redemptions по user_id; «забыть по запросу» — обнуляет user_id в затронутых записях (сам заказ хранится и удаляется по правилам commerce-orders, не coupons).

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

Входы (whitelist-принцип §11: всё вне таблицы модуль обязан отвергать 422):

ИсточникПоляЧем валидируется
API POST /api/v1/coupons/apply (форма корзины)code (string)FormRequest whitelist: code обязателен, строка, формат алфавита генератора, rate-limit границы
Filament: конструктор кампанииname, promo_rule_id, starts_at, ends_at, is_activeформа Filament + правила: promo_rule_id существует в commerce-promo, starts_at < ends_at
Filament: генератор пулаcampaign_id, count, code_length (override), patternFormRequest: count ≤ coupons.max_pool_size_per_generate, кампания существует и активна
Filament: деактивация кода/кампанииcode_id/campaign_id, подтверждениеPolicy coupons.manage + явное подтверждение необратимой операции
Событие OrderPaid (cms/commerce-orders, канал 1)order_id, user_id, applied_codeслушатель: код существует и активен, идемпотентность по order_id (уникальный индекс)
CLI cms:coupons:import-legacy--source=<профиль> (файл/таблица донора)маппинг схемы, --dry-run-отчёт расхождений

Выходы:

ПотребительДанныеФормат
Корзина/оформление заказа (ответ API apply)статус применения, ссылка на расчёт скидкиконверт ядра {data, meta} / {message, errors} (422/409)
cms/commerce-promo (requires, канал 4)code, campaign_id, promo_rule_idвызов публичного сервиса (не HTTP)
Шина событий (канал 1)CouponPoolGenerated, CouponRedeemed, CouponCampaignExpiredpayload см. «События и обмен»
Filament (статистика)выдано/активировано/использовано, по кампании и кодуагрегаты из used_count/redemptions
cms/landings (suggests, канал 4 опционально)ends_at, is_active кампании для таймеравызов сервиса кампании (не запрос из блока в БД)

Настройки (группа coupons)

КлючТипДефолтaffectsPageCacheОписание
coupons.apply_enabledbooltrueнетKill-switch: аварийная остановка применения купонов в корзине без выключения модуля — статистика, генерация пула и Filament продолжают работать
coupons.code_lengthint8нетДлина генерируемого кода купона; минимум 6 (валидация схемы настройки) — см. обоснование в «Безопасность»
coupons.default_max_uses_per_userint1нетДефолтный лимит использований на пользователя
coupons.max_pool_size_per_generateint100000нетМаксимум кодов за один вызов генерации; превышение — 422 с понятной ошибкой, для больших объёмов — несколько вызовов подряд
coupons.redemptions_retention_daysint1095нетСрок хранения user_id в журнале применений до обезличивания (152-ФЗ)

⚠️ Противоречие: черновая версия ТЗ называла этот kill-switch coupons.enabled, что неотличимо от состояния жизненного цикла модуля (enabled/disabled из §3 стандарта) — по факту это не «модуль включён», а «приём купонов в корзине разрешён». Разрешение: настройка переименована в coupons.apply_enabled, лифецикл модуля остаётся отдельным механизмом ядра.

API

МетодПутьДоступНазначение
POST/api/v1/coupons/applyauth/guest (rate-limit)Применение кода купона к текущей корзине
GET/api/v1/admin/coupons/campaignsadmin (coupons.view)Список кампаний со статистикой применения
POST/api/v1/admin/coupons/campaigns/{id}/generateadmin (coupons.manage)Генерация пула купонов для кампании
POST/api/v1/admin/coupons/codes/{id}/deactivateadmin (coupons.manage)Деактивация отдельного купона

Стекинг купонов: к корзине применяется ровно один код одновременно (MVP-решение, не открытый вопрос). Попытка применить второй код при уже применённом — 409 с текстом «уже применён другой купон, сначала снимите его», без молчаливой замены (замена меняет итог заказа без явного действия посетителя).

Компоненты

Filament: конструктор кампании (правило скидки, сроки, лимиты, lock_version), генератор пула кодов (лимит coupons.max_pool_size_per_generate, прогресс батча виден в реальном времени), статистика применения, массовые действия на списке кодов (деактивация выделенных, экспорт CSV пула). Пустые состояния — см. «UX-требования».

Команды (все с --json): cms:coupons:generate-pool, cms:coupons:expire-campaigns, cms:coupons:recount --campaign=<id> (пересчёт used_count/статистики из фактических redemptions, идемпотентна, поддерживает --dry-run), cms:coupons:import-legacy --source=<профиль> (см. «Донорский код»).

Фронтенд-бюджет: поле ввода купона в корзине — компонент темы без отдельного тяжёлого бандла модуля (ориентир ≤5 KB JS), резерв высоты под сообщение об ошибке заранее (без CLS), доступно с клавиатуры (label, aria-live на ошибке применения).

Демо-контент: CouponsDemoSeeder — демо-кампания с промо-посадочной (для playground cms/landings) и пулом демо-кодов для галереи блока/виджета таймера.

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

СобытиеКогдаPayload
CouponPoolGeneratedсгенерирован пул купонов кампанииcampaign_id, codes_count
CouponRedeemedкупон применён к заказуcode_id, order_id, user_id
CouponCampaignExpiredкампания завершена по срокуcampaign_id

Слушает: OrderPaid от cms/commerce-orders (канал 1) — фиксирует redemptions по факту оплаты, идемпотентно по order_id. Скидка считается вызовом публичного сервиса cms/commerce-promo (requires, канал 4) — модуль не дублирует rule-engine скидок. Промо-посадочные используют cms/landings (suggests).

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

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-promorequires, сервис-вызов (канал 4)coupons → commerce-promoприменение купона вызывает публичный сервис расчёта скидки по promo_rule_id; клип скидки по сумме корзины — ответственность commerce-promo, не coupons
cms/commerce-ordersсобытие OrderPaid (канал 1)commerce-orders → couponscoupons фиксирует redemptions после факта оплаты
cms/landingssuggests, сервис-вызов (канал 4, опционально)landings → couponsблок таймера промо-посадочной запрашивает ends_at/is_active активной кампании; модуль не установлен/выключен — посадочная рендерится без таймера
ядро (EventBus)канал 1coupons → все подписчикииздаёт CouponPoolGenerated/CouponRedeemed/CouponCampaignExpired
cms/attack-monitor (если включён)событие (канал 1)coupons → attack-monitorвсплеск неудачных apply (возможный перебор кодов) — сигнал для мониторинга атак
cms/notifications-bus (notification-channel, канал 3)provides-контрактcoupons → notification-channelопциональное уведомление админу о завершении генерации пула/истечении кампании

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

Очередь coupons: generate-pool — массовая генерация кодов кампании батчами (Bus::batch, не блокирует веб-поток при больших объёмах), expire-campaigns — плановое завершение кампаний по сроку через ScheduleRegistrar, anonymize-redemptions — плановое обезличивание записей redemptions старше coupons.redemptions_retention_days (ScheduleRegistrar).

Эксплуатация (ранбук, §15 стандарта). Метрики: coupons.apply_success_total, coupons.apply_failed_total{reason} (not_found/expired/limit_exceeded/ promo_unavailable), coupons.pool_generation_duration_seconds, coupons.expire_campaigns_lag. Алерт: всплеск apply_failed_total{reason=not_found} — возможный перебор кодов; отставание очереди coupons сверх настроенного порога.

СимптомЧто проверитьЧем чинить
Всплеск неудачных apply (возможный перебор)apply_failed_rate по IP/сессии, лог cms/attack-monitorrate-limit уже блокирует; при продолжении — временно coupons.apply_enabled=false (kill-switch)
Зависла генерация пулаqueue:failed, прогресс батча в Filamentcms:coupons:generate-pool --retry-failed --json — недостающие коды догенерируются без дублей
used_count разошёлся со статистикойсравнить агрегат Filament с count(redemptions)cms:coupons:recount --campaign=<id> --json
Просроченные кампании не деактивированысостояние scheduler/очереди couponscms:coupons:expire-campaigns --json вручную + проверка cms/health на отставание cron

Бэкап/рестор: в бэкап попадают все три таблицы модуля (кампании, коды, журнал применений) — финансово значимые данные, автоматически не пересоздаются. После рестора (особенно частичного, рассинхронного по времени с commerce-orders) — обязателен cms:coupons:recount --json для сверки агрегатов статистики.

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

  • Объёмы: пул кампании — от сотен до нескольких миллионов кодов (cms_coupons_codes); журнал применений (cms_coupons_redemptions) растёт до десятков миллионов строк на активных проектах — append-only.
  • Горячий путь POST /api/v1/coupons/apply: бюджет ≤3 запросов — (1) поиск кода по уникальному индексу code, (2) атомарный UPDATE cms_coupons_codes SET used_count = used_count + 1 WHERE id = ? AND used_count < max_uses (не read-then-write — см. «Крайние случаи»), (3) сервис-вызов commerce-promo (не запрос к БД coupons). Чтение группы настроек coupons — 0 запросов (кеш группы).
  • Критичные индексы: уникальный code (поиск O(1) по индексу); составной (campaign_id, is_active) в cms_coupons_codes — листинг/статистика кампании; BRIN по redeemed_at в cms_coupons_redemptions — журнал только растёт, обычный B-tree раздувается.
  • Генерация пулаBus::batch чанками (например 10k кодов/чанк), прогресс виден в Filament; на миллион кодов не держит одну транзакцию целиком и не блокирует веб-поток; инвалидация — по завершении батча, не построчно.
  • Теги кеша: coupons:campaign:{id} — читают блок/виджет таймера промо-посадочной (cms/landings: ends_at, is_active); инвалидация по CouponPoolGenerated, CouponCampaignExpired, afterSave() конструктора кампании в Filament. Сам эндпоинт apply персонализирован (корзина) и в page-cache не участвует.

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

Граница входа: /api/v1/coupons/apply — единственная публичная точка входа, FormRequest-whitelist (только поле code); остальные мутации — под Filament/движком полей. Rich-text, файлы, вебхуки в модуле не участвуют — соответствующие барьеры не задействуются.

Энтропия кода против перебора. Алфавит генератора — 32 символа (Crockford-подобный, без неоднозначных 0/O, 1/I/L). При code_length=8 (дефолт) keyspace ≈ 2⁴⁰ (≈1,1×10¹²). При пуле в 1 млн активных кодов вероятность угадать код одним запросом — ≈9×10⁻⁷; при rate-limit 20 запросов/мин на IP+сессию (429 + Retry-After) ожидаемое число запросов до одного успешного подбора — ≈1,1×10⁶ (≈38 суток непрерывного перебора с одного адреса, к тому же cms/attack-monitor вскроет всплеск 422/404 много раньше). Поэтому минимальная длина кода в схеме настройки — 6 (жёсткий пол валидации): при code_length=6 (2³⁰ ≈10⁹) и том же пуле в 1 млн вероятность угадывания ≈10⁻³ — на порядки опаснее, использовать только для коротких промо-акций с малым пулом и явным пониманием риска.

Конкурентный инкремент. Проверка срока действия и лимитов — строго на сервере; повтор сверх max_uses/max_uses_per_user отклоняется 422. Инкремент used_count выполняется атомарным UPDATE ... WHERE used_count < max_uses (не read-then-write) — исключает гонку двух одновременных заявок на последний доступный код одноразового купона (max_uses=1).

Матрица ролей (permission × роль, §11 стандарта):

ДействиеPermissionАдминМенеджерРедакторStudio
Просмотр кампаний/статистикиcoupons.view
Создание кампании, генерация пула кодовcoupons.manage
Деактивация отдельного кодаcoupons.manage
Деактивация кампании целиком (необратимо, подтверждение)coupons.manage✅ (с подтверждением)
Kill-switch coupons.apply_enabledsettings.manage (ядро)
Legacy-импорт (cms:coupons:import-legacy)CLI, studio-роль

Права: coupons.view, coupons.manage. В логи и метрики не попадают ПДн — только обезличенные code_id/campaign_id; user_id — только внутри redemptions.

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

Админ:

  • Пустое состояние списка кампаний — подсказка «Создать первую кампанию» со ссылкой на конструктор; пустой пул кампании (0 сгенерировано) — подсказка «Сгенерировать пул» с примером количества.
  • Массовые действия на списке кодов: деактивация выделенных, экспорт CSV пула.
  • Человеческие ошибки вместо технических: «Код уже существует в другой активной кампании» вместо ошибки уникального индекса; «Правило скидки не найдено в commerce-promo» при удалённом/неактуальном promo_rule_id.
  • Подтверждение необратимых операций: деактивация кампании целиком — модальное окно с явным подтверждением; генерация пула сверх ориентира (например >100k) — предупреждение об ожидаемом времени выполнения.

Посетитель:

  • Поле ввода купона в корзине: понятная причина ошибки вместо общей «ошибка» — «Код не найден», «Срок действия истёк», «Лимит использований исчерпан», «Уже применён другой купон».
  • Состояние корзины (товары, введённые данные) сохраняется при ошибке применения — ошибка не откатывает и не очищает корзину.
  • Применённый купон виден в корзине с возможностью снять его перед попыткой применить другой (см. решение по стекингу в «API»).

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

  • Двойной сабмит применения (два клика на одноразовый код в одной корзине) → второй запрос получает тот же результат «уже применён», не повторный инкремент used_count (идемпотентность по (code_id, order/cart)).
  • Гонка за последний доступный код (max_uses=1, used_count=0→1) → атомарный UPDATE ... WHERE used_count < max_uses, не SELECT + отдельный UPDATE; проигравший получает «лимит исчерпан», не 500 и не двойное списание.
  • Ретрай сети на apply (клиент не получил ответ, повторил запрос) → идемпотентность по паре «код + корзина/заказ»: повторный запрос не увеличивает used_count дважды.
  • Модуль выключен посреди неоформленного заказа с уже применённым купоном → корзина живёт без скидки (деградация §3 стандарта), сообщение «скидка временно недоступна»; включение модуля восстанавливает применение только по новой попытке, не задним числом к уже оформленному заказу.
  • cms/landings (suggests) выключен/не установлен → кампания без промо-посадочной: таймер не рендерится, ссылка ведёт на страницу с формой ввода.
  • Сбой/недоступность cms/commerce-promo (requires) → порядок операций: сначала успешный расчёт скидки, затем атомарный инкремент used_count; при сбое расчёта — понятная ошибка «расчёт скидки временно недоступен», used_count не растёт и redemption не создаётся — купон не считается использованным.
  • Пустой пул (генерация не запущена/упала) → apply отдаёт «код не найден»; список кампании в Filament — пустое состояние с подсказкой «Сгенерировать пул».
  • Огромный объём генерации (пул на миллион кодов за один вызов) → coupons.max_pool_size_per_generate отклоняет запрос выше порога; допустимые объёмы — Bus::batch чанками, без блокировки веб-потока.
  • Ловушка таймзоны: «до конца дня 31 декабря» — граница суток вычисляется по таймзоне сайта на момент создания кампании (см. «Модель данных»), не пересчитывается при последующей смене таймзоны сайта.
  • Ловушка измерений locale/city/site: кампания без site_id/city_id (мультисайт/мультигород выключены) → купон работает глобально, не отдельной веткой кода; при включении мультисайта позже — трактуется как «все сайты».
  • Противоречивые настройки: coupons.apply_enabled=false при активной кампании с открытой посадочной → страница и таймер рендерятся как обычно, но apply отклоняется понятной ошибкой «приём купонов временно приостановлен» — не 500 и не молчаливый игнор.
  • Конкурентное редактирование кампании (например оба админа меняют ends_at) → lock_version: второй PUT получает 409 «кампания изменена другим пользователем, обновите страницу».

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

Что взятьПуть
Сервисы генерации и применения промо-кодовnotal/src/app/Services/

Legacy-импорт (§16 стандарта): cms:coupons:import-legacy --source=<профиль> — маппинг таблиц промо-кодов и истории применений донора (notal) на cms_coupons_campaigns/codes/redemptions; идемпотентность по external_id (нескрытая nullable-колонка на каждой из трёх таблиц, не участвует в бизнес-логике); --dry-run строит отчёт расхождений (сколько кампаний/кодов/применений будет создано/обновлено/пропущено и почему — например promo_rule_id донора не мигрирован). История применений (redemptions) переносится с сохранением redeemed_at; user_id мапится через таблицу соответствия пользователей ядра (если миграция пользователей уже прошла), иначе остаётся null и применение переносится обезличенным. Прогон на копии донорских данных — часть приёмки модуля.

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

  • [ ] Контрактный тест: применение купона проверяет срок действия и лимиты использования
  • [ ] Повторное применение сверх max_uses/max_uses_per_user отклоняется с 422
  • [ ] Атомарный инкремент used_count: из двух параллельных заявок на последний код ровно одна успешна, вторая — понятная ошибка (не 500, не двойное списание)
  • [ ] Скидка считается через cms/commerce-promo, не дублирует rule-engine; клип по сумме корзины — ответственность commerce-promo, не coupons
  • [ ] Сбой commerce-promo (мок недоступности) не инкрементирует used_count и не создаёт redemption
  • [ ] При выключении модуля купон не применяется, заказ оформляется по обычной цене
  • [ ] При выключенном/отсутствующем cms/landings кампания работает без таймера, страница не падает
  • [ ] Kill-switch coupons.apply_enabled=false блокирует apply понятной ошибкой, не влияя на статистику и генерацию пула
  • [ ] Whitelist входов: поле вне таблицы «Входные и выходные данные» отклоняется 422
  • [ ] Права coupons.view/coupons.manage разграничивают доступ по матрице ролей (админ/менеджер/редактор/studio)
  • [ ] Генерация пула не создаёт коллизий кодов при большом объёме; превышение max_pool_size_per_generate отклоняется понятной ошибкой
  • [ ] Конкурентное редактирование кампании (lock_version) — второй PUT получает 409, не «последний победил»
  • [ ] Граница суток starts_at/ends_at — таймзона сайта, хранение в UTC — тест на границе смены суток
  • [ ] cms:coupons:import-legacy --dry-run на копии донорских данных — идемпотентность по external_id, отчёт расхождений
  • [ ] «Выгрузить всё»/«забыть по субъекту» по user_id в redemptions — обезличивание, факт применения сохраняется
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API; тестовая БД только coupons_test, migrate:fresh запрещён

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