Тема
ТЗ — Купоны/промо-кампании (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_campaigns | id, 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_codes | id, campaign_id, code, max_uses, used_count, max_uses_per_user, external_id | сгенерированный код с лимитами использования |
cms_coupons_redemptions | id, code_id, user_id, order_id, redeemed_at, external_id | факт применения купона к заказу; append-only, только чтение после записи |
FK campaign_id, promo_rule_id, code_id, order_id — constrained() + 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), pattern | FormRequest: 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, CouponCampaignExpired | payload см. «События и обмен» |
| Filament (статистика) | выдано/активировано/использовано, по кампании и коду | агрегаты из used_count/redemptions |
cms/landings (suggests, канал 4 опционально) | ends_at, is_active кампании для таймера | вызов сервиса кампании (не запрос из блока в БД) |
Настройки (группа coupons)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
coupons.apply_enabled | bool | true | нет | Kill-switch: аварийная остановка применения купонов в корзине без выключения модуля — статистика, генерация пула и Filament продолжают работать |
coupons.code_length | int | 8 | нет | Длина генерируемого кода купона; минимум 6 (валидация схемы настройки) — см. обоснование в «Безопасность» |
coupons.default_max_uses_per_user | int | 1 | нет | Дефолтный лимит использований на пользователя |
coupons.max_pool_size_per_generate | int | 100000 | нет | Максимум кодов за один вызов генерации; превышение — 422 с понятной ошибкой, для больших объёмов — несколько вызовов подряд |
coupons.redemptions_retention_days | int | 1095 | нет | Срок хранения user_id в журнале применений до обезличивания (152-ФЗ) |
⚠️ Противоречие: черновая версия ТЗ называла этот kill-switch coupons.enabled, что неотличимо от состояния жизненного цикла модуля (enabled/disabled из §3 стандарта) — по факту это не «модуль включён», а «приём купонов в корзине разрешён». Разрешение: настройка переименована в coupons.apply_enabled, лифецикл модуля остаётся отдельным механизмом ядра.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/coupons/apply | auth/guest (rate-limit) | Применение кода купона к текущей корзине |
| GET | /api/v1/admin/coupons/campaigns | admin (coupons.view) | Список кампаний со статистикой применения |
| POST | /api/v1/admin/coupons/campaigns/{id}/generate | admin (coupons.manage) | Генерация пула купонов для кампании |
| POST | /api/v1/admin/coupons/codes/{id}/deactivate | admin (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-promo | requires, сервис-вызов (канал 4) | coupons → commerce-promo | применение купона вызывает публичный сервис расчёта скидки по promo_rule_id; клип скидки по сумме корзины — ответственность commerce-promo, не coupons |
cms/commerce-orders | событие OrderPaid (канал 1) | commerce-orders → coupons | coupons фиксирует redemptions после факта оплаты |
cms/landings | suggests, сервис-вызов (канал 4, опционально) | landings → coupons | блок таймера промо-посадочной запрашивает ends_at/is_active активной кампании; модуль не установлен/выключен — посадочная рендерится без таймера |
ядро (EventBus) | канал 1 | coupons → все подписчики | издаёт 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-monitor | rate-limit уже блокирует; при продолжении — временно coupons.apply_enabled=false (kill-switch) |
| Зависла генерация пула | queue:failed, прогресс батча в Filament | cms:coupons:generate-pool --retry-failed --json — недостающие коды догенерируются без дублей |
used_count разошёлся со статистикой | сравнить агрегат Filament с count(redemptions) | cms:coupons:recount --campaign=<id> --json |
| Просроченные кампании не деактивированы | состояние scheduler/очереди coupons | cms: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_enabled | settings.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запрещён