Skip to content

ТЗ — Лояльность/бонусы (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_transactionsid, user_id, type (accrual|redemption|expiration|reversal), amount, order_id, rule_id, reversed_transaction_id, external_id, meta (json), expires_at, lock_version, created_atappend-only журнал, баланс = сумма записей
cms_loyalty_rulesid, code, trigger_event, condition (json), action (json), priority, is_active, lock_versionrule-engine: событие → условие → размер начисления
cms_loyalty_levelsid, name, threshold_amount, price_type_id, bonus_multiplierпорог накопленных покупок → уровень → тип цены/множитель
cms_loyalty_user_levelsuser_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-previewcart_id, points_to_redeemFormRequest, whitelist полей корзины
Событие OrderCompleted (ядро/коммерция)order_id, user_id, total_amountвнутренний доверенный источник (не пользовательский вход); условие правила валидируется схемой cms_loyalty_rules.condition при создании правила
Событие OrderCancelled / OrderRefundedorder_idвнутренний источник
Filament: конструктор правил начисленияcode, trigger_event, condition(json), action(json), priority, is_activerules движка полей ядра, ReDoS-валидатор на пользовательские regex-условия
API POST /admin/loyalty/rulesте же поляFormRequest, loyalty.manage, lock_version для optimistic lock
API POST /admin/loyalty/transactions/{id}/reversereason (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)
EventBusLoyaltyAccrued/LoyaltyRedeemed/LoyaltyExpired/LoyaltyLevelChangedсобытие после коммита (afterCommit)
cms/auditручное сторно, изменение правилазапись аудита
Хук ядра «выгрузить всё по субъекту» (152-ФЗ)история транзакций пользователяJSON-выгрузка
cms:loyalty:import-legacy --dry-runотчёт расхождений (создано/обновлено/пропущено)консоль + --json

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

КлючТипДефолтaffectsPageCacheОписание
loyalty.enabledbooltrueнетВключение бонусной программы
loyalty.accrual_triggerstringorder_completedнетСобытие начисления баллов
loyalty.max_redemption_percentint30нетМаксимальный % чека, оплачиваемый баллами
loyalty.expiration_daysint365нетСрок жизни начисленных баллов до сгорания
loyalty.expiration_warning_daysint14нетЗа сколько дней до сгорания отправить уведомление через NotificationDispatch
loyalty.default_accrual_percentfloat1.0нетДефолтный % начисления от суммы заказа
loyalty.max_accrual_per_orderint50000нетЛимит начисления баллов за один заказ (minor units) — защита от ошибки в правиле/фрода
loyalty.kill_switchboolfalseнетАварийная остановка начисления и списания баллов; чтение баланса/истории продолжает работать, модуль не выключается

Достижение max_redemption_percent/max_accrual_per_order — 422 с понятной причиной и пересчитанным допустимым значением, не тихое обрезание суммы. loyalty.kill_switch — включается studio-ролью или ядром дистанционно (managed-режим, §6 стандарта) при подозрении на фрод/баг правила: redeem/accrue отвечают 503 с сообщением «программа лояльности временно приостановлена», блок оплаты баллами скрывается в корзине, баланс и история в кабинете остаются доступны для чтения.

API

МетодПутьДоступНазначение
GET/api/v1/loyalty/balanceauthТекущий баланс и уровень пользователя
GET/api/v1/loyalty/transactionsauthИстория транзакций (keyset-пагинация)
POST/api/v1/loyalty/redeem-previewauthПредпросмотр списания баллов при оформлении заказа
GET/api/v1/admin/loyalty/rulesadmin (loyalty.view)Список правил начисления
POST/api/v1/admin/loyalty/rulesadmin (loyalty.manage)Создание/правка правила начисления
POST/api/v1/admin/loyalty/transactions/{id}/reverseadmin (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 jobsphp 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-pricingrequires-сервис (типы цен) (канал 4)loyalty → commerce-pricingдостижение уровня открывает тип цены пользователю
Ядро: NotificationDispatchсервис-контракт ядра (DI)loyalty → ядроуведомление о скором сгорании баллов, о смене уровня (лог-fallback, если модуль уведомлений выключен)
cms/auditсобытие (канал 1)loyalty → auditручное сторно, изменение правила начисления
cms/healthhealth-чек модуля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 запрещён

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