Тема
ТЗ — Лояльность/бонусы (cms/loyalty)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal (
notal/src/app/Services/) Статус: ТЗ к разработке
Назначение и возможности
Транзакционный бонусный счёт пользователя как append-only журнал (сторно вместо удаления), rule-engine начисления по событиям, уровни лояльности с порогами и оплата баллами с лимитом от суммы чека. Модель согласована с доменной моделью коммерции — см. /cms-v2/commerce-model, раздел «Бонусы и скидки».
- Бонусный счёт как append-only журнал транзакций: баланс всегда
Σзаписей, не колонка - Начисление, списание, сгорание и сторно — только новой записью в журнале, без удаления истории
- Rule-engine начисления: событие → условие → размер начисления (% от заказа, фикс за действие, множители акций)
- Начисление по событию
OrderCompleted(после выкупа, не после оплаты) — идемпотентный job - Уровни лояльности: порог накопленной суммы покупок → уровень → тип цены (
cms/commerce-pricing) и/или повышенный % начисления - Оплата баллами при оформлении заказа с лимитом % от чека (конфиг), пересчёт по позициям заказа
- Сгорание неиспользованных баллов планировщиком (настраиваемый срок жизни начисления)
- Уведомление пользователя о скором сгорании баллов заранее (за N дней), не только факт сгорания постфактум
- История баланса и транзакций в личном кабинете пользователя
- Возврат/отмена заказа → автоматическое сторно связанных бонусных транзакций
- Объединение аккаунтов (гостевой + зарегистрированный) → перенос баланса и истории на итоговый
user_idбез потери журнала
Зависимости и выключение
requires: ядро, коммерция (заказы, cms/commerce-pricing) · suggests: cms/referral, cms/coupons
Поведение при выключении: начисление и списание баллов останавливается, накопленный баланс и история транзакций сохраняются без изменений — оплата баллами в корзине скрывается, оформление заказа за полную цену продолжает работать без ошибок.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_loyalty_transactions | id, user_id, type (accrual|redemption|expiration|reversal), amount, order_id, rule_id, reversed_transaction_id, external_id, meta (json), expires_at, lock_version, created_at | append-only журнал, баланс = сумма записей |
cms_loyalty_rules | id, code, trigger_event, condition (json), action (json), priority, is_active, lock_version | rule-engine: событие → условие → размер начисления |
cms_loyalty_levels | id, name, threshold_amount, price_type_id, bonus_multiplier | порог накопленных покупок → уровень → тип цены/множитель |
cms_loyalty_user_levels | user_id, level_id, achieved_at | текущий уровень пользователя |
FK constrained() + index(); type — PHP Enum; condition/action — JSONB; amount — integer minor units (баллы целыми, не float); cms_loyalty_transactions — append-only, кандидат на BRIN по created_at, индекс (user_id, created_at) под баланс. reversed_transaction_id — самоссылка на сторнируемую запись (nullable, только для type=reversal); external_id — nullable unique per источник, ключ идемпотентности доставки события и legacy-импорта; meta — контекст без бизнес-логики (исходный user_id при слиянии аккаунтов, профиль импорта). cms_loyalty_rules несёт lock_version — optimistic lock на редактирование правила (§4 стандарта).
Архитектурный инвариант (append-only, без исключений): ни одна строка cms_loyalty_transactions не изменяется и не удаляется после записи — UPDATE/DELETE на этой таблице запрещены на уровне сервиса и ревью кода; любое исправление (ошибка правила, фрод, отмена заказа) — новая запись type=reversal со ссылкой reversed_transaction_id. Колонки баланса не существует — баланс всегда Σ amount.
ПДн-паспорт. Баланс/история сами по себе не самостоятельные ПДн (обезличенные суммы), но привязаны к user_id и относятся к финансово-чувствительным данным — хранятся до закрытия аккаунта, далее по ретеншну ядра. 152-ФЗ: «выгрузить всё по субъекту» — JSON истории через хук ядра; «забыть по запросу» — не удаление (append-only, нужен для 54-ФЗ), а анонимизация: user_id заменяется псевдонимом после закрытия аккаунта, связь с профилем разрывается, суммы/даты остаются для бухгалтерской сверки.
Входные и выходные данные
Whitelist-принцип (§11 стандарта): любое поле/событие/источник вне этих таблиц модуль обязан отвергать, а не молча игнорировать.
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
| Форма оформления заказа (оплата баллами) | points_to_redeem (int, minor units) | FormRequest: integer|min:0, серверный пересчёт лимита от max_redemption_percent, клиентское значение не доверяется |
API POST /redeem-preview | cart_id, points_to_redeem | FormRequest, whitelist полей корзины |
Событие OrderCompleted (ядро/коммерция) | order_id, user_id, total_amount | внутренний доверенный источник (не пользовательский вход); условие правила валидируется схемой cms_loyalty_rules.condition при создании правила |
Событие OrderCancelled / OrderRefunded | order_id | внутренний источник |
| Filament: конструктор правил начисления | code, trigger_event, condition(json), action(json), priority, is_active | rules движка полей ядра, ReDoS-валидатор на пользовательские regex-условия |
API POST /admin/loyalty/rules | те же поля | FormRequest, loyalty.manage, lock_version для optimistic lock |
API POST /admin/loyalty/transactions/{id}/reverse | reason (string, required) | FormRequest, loyalty.manage |
Команда cms:loyalty:import-legacy --source= | external_id, donor_user_id → user_id, amount, created_at | маппинг-схема профиля источника, идемпотентность по external_id, --dry-run без записи |
Внутренний вызов слияния аккаунтов (cms:loyalty:merge-accounts / хук ядра) | primary_user_id, secondary_user_id | внутренний источник, не форма |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Личный кабинет (виджет баланса/истории) | баланс, уровень, keyset-история транзакций | JSON, {data, meta} через /api/v1/loyalty/* |
Корзина/оформление заказа (commerce-cart/commerce-pricing) | сумма списания, пересчитанная цена позиций | значение внутри пайплайна пересчёта корзины (FilterBus) |
cms/referral | результат LoyaltyService::accrue() | прямой вызов публичного сервиса (requires) |
EventBus | LoyaltyAccrued/LoyaltyRedeemed/LoyaltyExpired/LoyaltyLevelChanged | событие после коммита (afterCommit) |
cms/audit | ручное сторно, изменение правила | запись аудита |
| Хук ядра «выгрузить всё по субъекту» (152-ФЗ) | история транзакций пользователя | JSON-выгрузка |
cms:loyalty:import-legacy --dry-run | отчёт расхождений (создано/обновлено/пропущено) | консоль + --json |
Настройки (группа loyalty)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
loyalty.enabled | bool | true | нет | Включение бонусной программы |
loyalty.accrual_trigger | string | order_completed | нет | Событие начисления баллов |
loyalty.max_redemption_percent | int | 30 | нет | Максимальный % чека, оплачиваемый баллами |
loyalty.expiration_days | int | 365 | нет | Срок жизни начисленных баллов до сгорания |
loyalty.expiration_warning_days | int | 14 | нет | За сколько дней до сгорания отправить уведомление через NotificationDispatch |
loyalty.default_accrual_percent | float | 1.0 | нет | Дефолтный % начисления от суммы заказа |
loyalty.max_accrual_per_order | int | 50000 | нет | Лимит начисления баллов за один заказ (minor units) — защита от ошибки в правиле/фрода |
loyalty.kill_switch | bool | false | нет | Аварийная остановка начисления и списания баллов; чтение баланса/истории продолжает работать, модуль не выключается |
Достижение max_redemption_percent/max_accrual_per_order — 422 с понятной причиной и пересчитанным допустимым значением, не тихое обрезание суммы. loyalty.kill_switch — включается studio-ролью или ядром дистанционно (managed-режим, §6 стандарта) при подозрении на фрод/баг правила: redeem/accrue отвечают 503 с сообщением «программа лояльности временно приостановлена», блок оплаты баллами скрывается в корзине, баланс и история в кабинете остаются доступны для чтения.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/loyalty/balance | auth | Текущий баланс и уровень пользователя |
| GET | /api/v1/loyalty/transactions | auth | История транзакций (keyset-пагинация) |
| POST | /api/v1/loyalty/redeem-preview | auth | Предпросмотр списания баллов при оформлении заказа |
| GET | /api/v1/admin/loyalty/rules | admin (loyalty.view) | Список правил начисления |
| POST | /api/v1/admin/loyalty/rules | admin (loyalty.manage) | Создание/правка правила начисления |
| POST | /api/v1/admin/loyalty/transactions/{id}/reverse | admin (loyalty.manage) | Ручное сторно транзакции |
Компоненты
Виджеты: баланс и история в личном кабинете, блок оплаты баллами в корзине. Filament: конструктор правил начисления (rule-engine), справочник уровней лояльности, журнал транзакций с фильтром по типу (только чтение — правка строки журнала недоступна даже studio-роли, только сторно). Команды: cms:loyalty:accrue --json (обработка очереди начислений), cms:loyalty:expire --json (сгорание баллов по сроку), cms:loyalty:recalculate-levels --json, cms:loyalty:merge-accounts --from=<id> --to=<id> --json (перенос баланса/истории при слиянии аккаунтов, идемпотентна).
Фронтенд-бюджет: виджет баланса/истории и блок оплаты баллами — собственные ассеты темы; резерв высоты под сумму/строку списания исключает CLS; чекбокс/инпут списания доступны с клавиатуры (label, focus-порядок, ошибка недостатка баллов — скринридеру).
Демо-контент: сидер демо-баланса, истории транзакций (accrual/redemption/expiration) и 2–3 демо-правил начисления/уровней — блок и виджеты видны в /_gallery и playground без ручного оформления заказов.
Эксплуатация (метрики/алерты): длительность обработки очереди accrue, глубина очереди loyalty, число сторно в час, сумма списаний в час (аномальный всплеск). Алерты: очередь loyalty отстаёт > 15 мин; расхождение контрольной суммы баланса (кеш vs пересчёт по журналу) > 0; всплеск ручных сторно.
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| Баланс в кабинете расходится с журналом | кеш-тег loyalty:balance:{user_id} не инвалидирован | пересчёт по журналу — источник истины, кеш только читающий слой, форс-инвалидация тега |
Очередь accrue отстаёт | глубина очереди, failed jobs | php artisan queue:retry, при системном сбое — cms:loyalty:accrue --json догоняет вручную (идемпотентно) |
| Аномальный всплеск списаний | логи LoyaltyRedeemed, loyalty.kill_switch | включить loyalty.kill_switch, разобрать инцидент, снять после проверки |
| Уровни не пересчитались после акции | recalculate-levels не запускался | cms:loyalty:recalculate-levels --json (идемпотентна, безопасна на живом сайте) |
| Баллы сгорели без предупреждения | loyalty.expiration_warning_days, очередь уведомлений | проверить доставку NotificationDispatch, догнать cms:loyalty:expire --dry-run за отчётный период |
Бэкап/рестор: в бэкап — все таблицы модуля (журнал, правила, уровни) целиком, без частичной архивации (54-ФЗ, append-only). После рестора пересоздаётся только денормализованный кеш баланса (прогрев первым чтением) и уровни (cms:loyalty:recalculate-levels --json) — сам журнал восстанавливается как есть.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
LoyaltyAccrued | начислены баллы по правилу | user_id, amount, rule_id, order_id |
LoyaltyRedeemed | баллы списаны при оплате заказа | user_id, amount, order_id |
LoyaltyExpired | баллы сгорели по сроку | user_id, amount, transaction_id |
LoyaltyLevelChanged | пользователь достиг нового уровня | user_id, level_id, previous_level_id |
Слушает: OrderCompleted (начисление), OrderCancelled/OrderRefunded (сторно). Предоставляет балансовые транзакции как сервис для cms/referral/cms/coupons (начисление только через публичный сервис модуля, не напрямую).
Таблица взаимодействий:
| Сущность/модуль | Канал (/cms-v2/data-exchange) | Направление | Что происходит |
|---|---|---|---|
Заказы коммерции (commerce-orders) | событие OrderCompleted (канал 1) | заказы → loyalty | триггер начисления по правилу, идемпотентный job |
Заказы коммерции (commerce-orders) | событие OrderCancelled/OrderRefunded (канал 1) | заказы → loyalty | автосторно связанных транзакций |
Корзина/цены (commerce-cart, commerce-pricing) | FilterBus пересчёта корзины (канал 2) | loyalty → корзина | применение списания баллов к цене позиций наравне со скидками/купонами |
cms/referral (suggests-потребитель) | requires-сервис LoyaltyService::accrue() (канал 4) | referral → loyalty | начисление реферального вознаграждения балансовыми транзакциями, без прямой записи в таблицы |
cms/coupons | нет прямой связи — оба применяются в общем pricing-пайплайне корзины | косвенно, через commerce-pricing | купон и баллы комбинируются в одном заказе; порядок и предельные значения задаёт владелец пайплайна (commerce-pricing), не loyalty |
commerce-pricing | requires-сервис (типы цен) (канал 4) | loyalty → commerce-pricing | достижение уровня открывает тип цены пользователю |
Ядро: NotificationDispatch | сервис-контракт ядра (DI) | loyalty → ядро | уведомление о скором сгорании баллов, о смене уровня (лог-fallback, если модуль уведомлений выключен) |
cms/audit | событие (канал 1) | loyalty → audit | ручное сторно, изменение правила начисления |
cms/health | health-чек модуля | loyalty → ядро | отставание очереди loyalty, расхождение баланса |
Очередь loyalty | очередь (канал 5) | внутри модуля | accrue, expire, recalculate-levels |
Фоновая работа
Очередь loyalty: accrue (обработка начислений по правилам), expire (сгорание старше expiration_days), recalculate-levels (пересчёт уровней) — через ScheduleRegistrar. Начисление по OrderCompleted — идемпотентный job.
Производительность и кеш
Ожидаемые объёмы: cms_loyalty_transactions растёт на каждый заказ/сгорание/сторно активного клиента — на зрелом магазине журнал достигает десятков миллионов строк; таблица проектируется под партиционирование по времени (BRIN по created_at — задел на партиции без переписывания схемы при росте).
Горячие пути:
- расчёт баланса (кабинет,
redeem-preview, лимит при оформлении) — не full-scan журнала: тегloyalty:balance:{user_id}с TTL и точечной инвалидацией каждой новой транзакцией; холодный промах — агрегат по индексу(user_id, created_at), бюджет 0 запросов к журналу на горячем пути при тёплом кеше; - атомарное списание —
pg_advisory_xact_lock(hashtext(user_id))внутри транзакции БД перед пересчётом баланса и вставкойredemption: сериализует конкурентные списания одного пользователя (две вкладки/устройства), не блокирует других; отрицательный баланс невозможен архитектурно (см. «Крайние случаи»); - обработка
accrue/expire— батчами (Bus::batch) с прогрессом, не построчно.
Критичные индексы: (user_id, created_at) на cms_loyalty_transactions (баланс, история), external_id unique частичный (не null) для идемпотентности импорта/событий, reversed_transaction_id под поиск связанных сторно, (trigger_event, is_active) на cms_loyalty_rules для выборки применимых правил.
Теги кеша и инвалидация: loyalty:balance:{user_id} — инвалидируется каждой транзакцией пользователя (accrual/redemption/expiration/reversal); loyalty:rules — инвалидируется afterSave() правила; loyalty:levels:{user_id} — инвалидируется recalculate-levels и достижением нового уровня. На публичный page-cache модуль не влияет — баланс и история персональные, страницы с ними не попадают в общий page-cache (§10 стандарта).
Безопасность
Границы входа: оба публичных эндпоинта (balance, transactions, redeem-preview) — только под токеном auth, без исключений; points_to_redeem — FormRequest-whitelist, пересчёт лимита от max_redemption_percent и фактического баланса всегда на сервере, клиентское значение из формы не доверяется. Конструктор правил — rules движка полей ядра, пользовательские regex-условия — только через ReDoS-валидатор ядра. Прямое изменение колонки баланса невозможно архитектурно (колонки нет, только запись в append-only журнал, см. «Модель данных») — UPDATE/DELETEcms_loyalty_transactions запрещены на уровне сервиса и ревью кода; исправление — только сторно с обязательной причиной. Списание не может превышать max_redemption_percent от чека — проверяется на сервере в момент оформления заказа, не на этапе предпросмотра и не на клиенте.
Матрица ролей:
| Действие / permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр баланса/истории клиента (loyalty.view) | ✅ | ✅ | ✅ | ✅ |
Управление правилами начисления, уровнями (loyalty.manage) | ✅ | ✅ | — | ✅ |
| Ручное сторно транзакции | ✅ | — | — | ✅ |
Изменение loyalty.kill_switch | ✅ | — | — | ✅ |
Legacy-импорт (cms:loyalty:import-legacy) | — | — | — | ✅ |
Legacy-импорт — только studio (разовая операция при переезде клиента, не штатная функция админки). Права: loyalty.view, loyalty.manage. В логи и аналитику модуля не попадают ПДн (суммы и user_id обезличиваются в внешних метриках).
UX-требования
Админ: пустой журнал клиента — подсказка «баллы начисляются автоматически после первого выполненного заказа», не голая таблица; список правил/уровней — массовое включение/выключение (is_active) без захода в каждую запись; ошибки на человеческом языке («правило конфликтует с уже активным для того же события», не «SQLSTATE…»); ручное сторно — обязательное подтверждение с полем причины (запись видна клиенту в истории); конкурентное редактирование правила — 409 «изменено другим пользователем, обновите страницу», не молчаливая перезапись.
Посетитель: баланс/история — пометка «сгорит N баллов DD.MM.YYYY» при приближении срока, синхронно с уведомлением заранее (expiration_warning_days); списание баллами при оформлении — мгновенная обратная связь при недостатке баллов или превышении max_redemption_percent (пересчитанный максимум сразу, не после отправки формы); при отказе (гонка, kill-switch) — «баллы временно недоступны, заказ можно оформить по полной цене» без обрыва оформления.
Крайние случаи и типовые баги
- Списание баллов с двух вкладок/устройств одновременно → атомарная проверка остатка и вставка записи
redemption— внутри одной транзакции БД сpg_advisory_xact_lock(hashtext(user_id))(не read-then-write без лока); второй конкурентный запрос видит уже уменьшённый остаток и получает 409 «баллы уже использованы в другом заказе, обновите корзину». Отрицательный баланс запрещён архитектурно — вставкаredemptionотклоняется сервисом, еслиΣпосле неё < 0. - Ретрай начисления (повторная доставка
OrderCompleted, «хотя бы одна доставка» события) → идемпотентность по паре(order_id, rule_id)— перед вставкой accrual проверяется существование записи с тем жеorder_id/rule_id; повторный job не создаёт вторую запись. - Выключение модуля посреди оформления заказа с частичным списанием баллами → уже записанная в журнал транзакция
redemptionне откатывается автоматически выключением модуля (данные не удаляются, §3 стандарта); заказ либо штатно завершает оформление с уже применённым списанием, либо при последующей отмене создаётся сторно как обычно. Блок оплаты баллами скрывается только для новых оформлений. cms/referralвыключен или не установлен (suggests) → loyalty работает как самостоятельный бонусный счёт; вызовыLoyaltyService::accrue()от referral просто не поступают, отсутствие suggests-модуля не создаёт ошибок.- Сбой очереди/внешнего сервиса (
NotificationDispatchнедоступен, очередьloyaltyотстала) → начисление остаётся в очереди с ретраями backoff, баланс не показывает «начислено», пока job не выполнен; сбой уведомления не блокирует само сгорание (неблокирующий канал) — алертится черезcms/health, не молчит. - Пользователь без единой транзакции → баланс 0, история — пустое состояние с подсказкой (см. UX-требования), не ошибка и не пустой экран.
- Журнал на десятки миллионов строк → расчёт баланса не идёт full-scan (кеш-тег + индекс
(user_id, created_at)); массовые операции (expire,recalculate-levels) — батчами, не построчным циклом по всей таблице. - Мультисайт без явного скоупа баланса → баллы по умолчанию видны и тратятся на любом сайте инсталляции (
site_id— измерение правил, не самого баланса); раздельный баланс по сайтам — отдельная настройка, не побочный эффект отсутствияsite_id. max_redemption_percentменяется во время оформления заказа → окончательная проверка лимита — на сервере в момент создания заказа, не на этапеredeem-preview; если после смены настройки ранее показанная сумма списания превышает новый лимит — 422 с пересчитанным допустимым значением, не тихое обрезание суммы.- Конкурентное редактирование правила начисления (два админа правят одно правило) →
cms_loyalty_rules.lock_version— конфликт отдаёт 409 «правило изменено другим пользователем», не «последний победил» молча (§4 стандарта). - Объединение аккаунтов (гостевой + зарегистрированный сливаются в один) → все транзакции переносятся на итоговый
user_idодной операцией (cms:loyalty:merge-accounts), с пометкой источника вmeta— история не теряется и не сторнируется, баланс пересчитывается какΣпо объединённому набору. СобытиеUserMerged(primary, secondary)канонизировано ревизией ядра 14.07.2026 (п. 13): перенос выполняет слушатель события; командаcms:loyalty:merge-accountsостаётся для ручного ремонта и легаси-случаев.
Донорский код
| Что взять | Путь |
|---|---|
| Сервисы бонусного счёта, начисления и списания | notal/src/app/Services/ |
Legacy-импорт. cms:loyalty:import-legacy --source=<профиль> [--dry-run] — маппинг исторических балансов донора на новую схему: каждая строка источника становится стартовой транзакцией accrual с external_id = id записи источника — повторный прогон обновляет по external_id, не дублирует. --dry-run — отчёт расхождений (прочитано/создано/обновлено/пропущено и почему) без записи в журнал, построчные ошибки скачиваемы. Прогон на копии донорских данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: баланс всегда равен сумме записей журнала, прямое изменение баланса невозможно (
UPDATE/DELETEжурнала запрещены на уровне сервиса) - [ ] Начисление по
OrderCompletedидемпотентно (повторная доставка события не дублирует начисление, ключ(order_id, rule_id)) - [ ] Отмена/возврат заказа создаёт сторно-транзакцию, не удаляет исходную запись
- [ ] Списание баллами не превышает
max_redemption_percentот чека, пересчёт корректен по позициям (сверка с чеками 54-ФЗ,/cms-v2/commerce-model) - [ ] Сгорание баллов планировщиком не затрагивает баллы моложе
expiration_days; уведомление уходит заexpiration_warning_daysдо сгорания - [ ] Достижение порога
threshold_amountменяет уровень и открывает тип ценыcms/commerce-pricing - [ ] При выключении модуля баланс и история сохраняются, оплата баллами скрыта, заказ оформляется штатно
- [ ] Гонка: два параллельных списания одного пользователя — второе получает 409, отрицательный баланс не возникает (тест с блокировкой на уровне пользователя)
- [ ]
loyalty.kill_switchостанавливает начисление/списание, баланс и история остаются доступны для чтения - [ ] Конкурентное редактирование правила (
lock_version) отдаёт 409, не «последний победил» - [ ] Слияние аккаунтов переносит все транзакции на итоговый
user_id, история не теряется, баланс пересчитывается корректно - [ ] Legacy-импорт идемпотентен по
external_id,--dry-runне пишет в журнал и отдаёт отчёт расхождений - [ ] Хук «выгрузить всё по субъекту» отдаёт полную историю пользователя; хук «забыть по запросу» анонимизирует
user_id, не удаляет журнал - [ ] Права
loyalty.view/loyalty.manageразграничивают чтение истории и управление правилами/сторно; ручное сторно и kill-switch недоступны роли «редактор» - [ ] Нет N+1 при построении истории транзакций и расчёте баланса на больших объёмах
- [ ] Whitelist входов: поле вне FormRequest/условия правила отклоняется, не игнорируется молча
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
loyalty_test,migrate:freshзапрещён