# ADR-0007. Правила акций как данные и снапшоты

- Статус: принято; реализовано на этапах 3a (правила программы и движок) и 3b (акции и промокоды);
  уточнение от 2026-10-04 — эффекты вместо формул
- Дата: 2026-09-30

## Решение

- Условия акций хранятся JSON-деревом в формате JsonLogic: без исполнения произвольного кода,
  легко строятся конструктором в интерфейсе.
- ~~Формулы эффектов — ограниченные выражения (Symfony ExpressionLanguage) с явным набором переменных.~~
  **Уточнение (этап 3b):** эффекты — закрытый набор типов с параметрами (скидка процентом, суммой, спеццена,
  баллы процентом, фиксом, множитель). Формулы не нужны ни одной механике из исследования, а язык выражений
  не переносится в другой язык: «золотые» векторы перестали бы быть переносимыми. Новый эффект — новый тип
  с векторами.
- Публикация кампании создаёт неизменяемую версию. Версии активных кампаний собираются в снапшот набора
  правил программы; чек всегда считается по снапшоту, активному в момент покупки.
- В чеке хранятся идентификатор снапшота и построчные (по единицам товара) эффекты. Возвраты и отмены
  откатывают сохранённые эффекты и никогда не пересчитывают чек по текущим правилам.
- Порядок расчёта фиксирован и одинаков для кассы, сайта и dry-run.
- У каждой версии кампании есть «золотые» тест-векторы «снапшот + чек → эффекты»; они же будут проверять
  реализацию движка на другом языке, если горячий путь вынесут из монолита.

## Реализация (этап 3a)

**Публикация.** Мерчант правит черновик (`program_rules`, один на программу). `ProgramRules::publish()` под
advisory-блокировкой программы собирает базовые правила — черновик плюс условия типа баллов (точность,
стоимость) и часовой пояс программы — и публикует снапшот (`SnapshotPublisher`, см. 3b). Время публикации
растёт вместе с версией даже при откате часов, поэтому `RuleSets::effective(program, at)` — это последняя
версия, опубликованная не позже `at`: чек, догруженный офлайн-кассой, считается по правилам момента покупки.

**Движок.** `Calculator::calculate(RuleSet, Basket, MemberSnapshot, CampaignState)` — чистая функция без
БД. Строки чека приходят уже с ограничениями каталога (`Catalog::resolve`). Порядок:

1. Проверка чека (1–1000 строк, уникальные id, количество в тысячных, суммы до 1 млрд ₽ на строку) и
   исключения: ограниченный товар не участвует ни в чём; «не списывать», «не начислять» и «не участвует
   в акциях» — по отдельности.
2. Выбор и применение скидок акций (3b).
3. Списание. Нижняя граница строки — max(⌈остаток на единицу × количество⌉, ⌈МРЦ × количество⌉); ёмкость
   строки — сумма к оплате минус граница. Предел в деньгах — min(Σ ёмкостей, доля от суммы строк,
   принимающих баллы); ниже минимальной суммы чека (считается по всему чеку) списания нет. Предел в баллах —
   min(баллы на эти деньги, доступные баллы участника), приведённый к наименьшему числу баллов для той же
   суммы в копейках. Запрос сверх предела уменьшается с объяснением. Деньги распределяются по ёмкостям,
   баллы — по деньгам строк (наибольший остаток).
4. Базовое начисление: процент от оплаченного деньгами по строкам, получающим баллы, округление вниз один
   раз на чек, распределение по строкам пропорционально базе.
5. Баллы акций (3b).

Без участника списание недоступно, а результат показывает, сколько баллов покупатель получил бы.

## Реализация (этап 3b)

**Акция** (`Campaign`): заголовок для чека, приоритет, «только участникам», «нужен промокод»,
эксклюзивность или группа совмещения, расписание (период, дни недели, часы в поясе программы), каналы,
точки, условие на чек, условие выбора строк, эффект, лимит на участника за период, бюджет.

**Условия** — строгое подмножество JsonLogic (`Condition`): операторы `and`, `or`, `!`, `===`, `!==`, `<`,
`<=` (в том числе «между»), `>`, `>=`, `in`, `some`, `all`, `none`, `var`; равенство только строгое
(`==` отклоняется), сравнения только «число с числом» или «строка со строкой», числа только целые;
переменные — из белого списка (`receipt.*`, `purchase.*`, `member.*` для чека, `line.*` для строк);
глубина и размер ограничены. Сумма и состав чека в условиях считаются только по строкам, участвующим в
акциях, — ограниченный товар не помогает дотянуть до порога. Условия проверяются при публикации и
компилируются в замыкания один раз на снапшот (`LogicCompiler`).

**Выбор акций.** Акции перебираются по убыванию приоритета (при равенстве — по id). Не действующие здесь и
сейчас (расписание, канал, точка, нет промокода) пропускаются молча; остальные могут быть отклонены с
причиной (`campaign.skipped`: `member_required`, `member_limit`, `budget_exhausted`, `condition`,
`no_target_lines`, `no_effect`, `accrual_unavailable`). Если применима эксклюзивная акция — остаётся только
она; в группе совмещения остаётся одна акция: первая по приоритету или дающая наибольшую выгоду
(баллы оцениваются в деньгах). Вытесненные — `campaign.outranked`.

**Эффекты.** Скидки применяются по приоритету, каждая к сумме после предыдущих, и никогда не опускают
строку ниже её нижней границы: процент округляется один раз на акцию, и то, что строка не может принять,
теряется; фиксированная сумма на чек переходит на другие строки (`Allocator::cappedLargestRemainder`);
суммы на единицу и спеццена считаются по строке. Баллы акций начисляются после базового начисления на
оплаченное деньгами по строкам, получающим баллы; множитель даёт дополнительную часть базового процента.
Бюджет урезает эффект пропорционально по строкам. Результат (`AppliedCampaign`) хранит эффект по строкам —
для учёта бюджета, лимитов и возвратов.

**Снапшоты.** Снапшот программы — манифест: базовые правила и id версий активных акций; `RuleSets`
собирает из него `RuleSet`. Хэш — SHA-256 канонического JSON (ключи отсортированы на всех уровнях).
Каждое изменение, влияющее на чеки (публикация базовых правил, публикация активной акции, активация, пауза,
архивирование), идёт через `SnapshotPublisher` под блокировкой программы и сначала собирает полный набор
правил: несовместимые акции (например, разные режимы одной группы) не попадут в снапшот.

**Бюджеты и лимиты** — вход движка (`CampaignState`), а не его забота: `CampaignUsage::state` читает остаток
бюджета и использования участника за период; подтверждение чека атомарно тратит бюджет
(`budget_spent + x <= budget`) и считает использование (upsert с условием), при гонке — ошибка 409 и
перерасчёт; отмена и возврат отдают обратно. Акция с бюджетом без известного остатка не применяется.

**Промокоды** (`PromoCodes`): общие (лимит использований, лимит на участника), уникальные одноразовые
(партии, алфавит без 0/1/I/O), персональные. Перед расчётом `check` раскладывает коды на открывающие
акции и отклонённые с причиной (`not_found`, `disabled`, `not_started`, `expired`, `campaign_inactive`,
`member_required`, `other_member`, `used_up`, `member_limit`, `duplicate`, `too_many`); при удержании чека
`reserve` проверяет их снова под блокировками, `confirm` и `release` следуют за чеком. Счётчик `taken`
ведётся только у кодов с лимитом, чтобы популярный безлимитный код не стал «горячей» строкой.

**Объяснение** — коды с данными: `line.restricted`, `line.no_accrual`, `line.no_redemption`,
`line.no_promotions`, `promo_code.rejected`, `campaign.skipped`, `campaign.outranked`, `campaign.applied`,
`redemption.unavailable`, `redemption.below_min_receipt`, `redemption.limit`, `redemption.reduced`,
`redemption.applied`, `accrual.base`, `accrual.unavailable`. Коды стабильны, тексты для людей строят
интерфейсы.

**Тесты и скорость.** 18 «золотых» векторов с ручным расчётом в `app-modules/rules/tests/golden` (формат —
в README каталога); property-тесты на случайных чеках и акциях: нижние границы строк, бюджеты, лимиты,
промокоды, группы, эксклюзивность, согласованность построчных эффектов, детерминированность.
`php artisan rules:benchmark`: 50 строк и 20 акций — p50 5 мс, p95 7,7 мс на Windows без OPcache. Для этого
`IntMath` и `Allocator` считают на нативных целых, когда произведение помещается в 64 бита (результат
совпадает с точной арифметикой — проверено тестами), и переходят на brick/math только при переполнении.

## Реализация (этап 3c): уровни

**Настройка — часть правил.** Уровни (`TierRules`: список `TierLevel`, окно квалификации, срок удержания)
хранятся в черновике правил программы и попадают в снапшот вместе с базовыми правилами: чек считается по
уровням момента покупки, изменение уровней — новая версия правил. Первый уровень — начальный, без
порогов; каждый следующий требует больше (сумма оплат деньгами и число чеков, нужны оба порога).

**Расчёт.** Движку уровень участника приходит во входе (`MemberSnapshot::tier`), как и раньше, — движок
остаётся чистым. `RuleSet::tierOf` даёт уровень, условия которого действуют: свой, или начальный, пока
участник не квалифицирован (или его уровня больше нет в правилах). Процент уровня заменяет базовый процент
начисления, множитель акции умножает именно его; в объяснении `accrual.base` указан уровень. В условиях
акций `member.tier` — тот же действующий уровень.

**Состояние — отдельный модуль `tiers`.** Статистика покупок — суммы по участнику и местному дню программы
(`member_activity_days`): любое окно — сумма по диапазону дней, возврат вычитается из дня покупки, суммы
не уходят ниже нуля. Покупки считаются и в программе без уровней, поэтому включённые позже уровни сразу
видят историю. Состояние (`member_tiers`) — уровень, момент квалификации, срок удержания
(`retained_until`) и ручная фиксация. Касса после подтверждения чека сообщает покупку (`recordPurchase`
с правилами чека), при возврате и аннулировании — возврат (`recordReturn`).

**Переходы.** Повышение — сразу при подтверждении покупки, в том числе через ступень. Пока покупки
квалифицируют текущий уровень, каждая оценка продлевает удержание. Понижение делает только пересмотр
(`tiers:review` ежечасно находит системной ролью уровни с истёкшим удержанием и оценивает их в тенанте):
на одну ступень, с новым сроком удержания — участник, переставший покупать, спускается по ступени за
срок. Возврат уровень не понижает: это решает пересмотр по окончании удержания. Фиксация вручную
(`fix`) задаёт уровень до даты с причиной; до этой даты ни покупки, ни пересмотр его не меняют, после —
обычные правила.

**Почему так.** Правила уровней нужны движку в момент покупки и должны версионироваться вместе с процентами
начисления, поэтому они в снапшоте. Состояние меняется от чеков и времени, поэтому оно вне правил, а
дневные суммы позволяют менять окно квалификации без пересчёта истории.

## Реализация (этап 3c): бонусы

**Настройка — часть правил.** Welcome-бонус, бонус ко дню рождения и «приведи друга» (`BonusRules`) хранятся
в черновике правил и в каждом снапшоте: бонус начисляется по правилам, опубликованным в момент, когда он
заработан. У каждого бонуса свои задержка активации и срок жизни (`LifecycleRules`).

**Награды — модуль `bonuses`.** Награда (`bonus_awards`) — обещание или начисление баллов участнику с
условиями, зафиксированными в момент её создания. Регистрация участника (событие `MemberEnrolled`
модуля `members`, в транзакции регистрации) создаёт welcome-награду — сразу начисленную или ждущую
покупки — и, если участник пришёл по реферальному коду, награды пригласившему и другу. Ждущие награды
получает первая подтверждённая покупка покупателя (`trigger_member_id`) не меньше минимальной суммы и не
позже срока; касса сообщает о покупке после подтверждения чека. Начисленная награда — проводка
`accrue` в леджере со ссылкой на награду; возврат чека целиком или его аннулирование сторнирует её по
политике программы для потраченных баллов. `occasion` делает награду уникальной: welcome — одна на
участника, день рождения — одна в год, пригласившему — одна за каждого друга.

**Антифрод.** Бонусы за регистрацию и приглашение по умолчанию ждут настоящей покупки и уходят вместе с
ней. Награды пригласившему ограничены в календарный месяц (награды одного пригласившего сериализуются
advisory-блокировкой), заблокированный участник наград не получает. Бонус ко дню рождения получают только
участники, зарегистрированные за N дней до даты: придуманная при регистрации дата ничего не даёт.

**День рождения.** `bonuses:birthdays` ежечасно проходит активные программы всех тенантов (реестр программ
системной ролью) и в тенанте программы начисляет баллы тем, чей день рождения наступит через `days_before`
дней по часовому поясу программы; поиск идёт по индексу месяца и дня даты рождения. Повторный запуск
ничего не начисляет дважды. Для акций движок получает дату рождения в `MemberSnapshot` и даёт условиям
переменную `member.birthday_offset` — число дней от ближайшего дня рождения до даты покупки (отрицательное
до него); вектор 17.

## Реализация (этап 3c): штампы

**Карта штампов** (`StampCard`) — часть правил программы: код, название, тип баллов леджера, в котором
хранятся штампы (целые, отдельный от баллов программы), товары — условие по строке, как у цели акции, цель
N штампов и срок жизни штампов (`LifecycleRules`). Карт в программе — до пяти.

**Расчёт.** Движок получает штампы участника по картам в `MemberSnapshot::stamps`. После скидок акций и до
списания баллов полная карта делает бесплатными единицы товаров карты: ⌊штампы / N⌋ единиц, самые дешёвые
первыми (цена единицы — после скидок акций), каждая не ниже нижней границы строки; скидка входит в `discount`
строки. В конце за каждую целую единицу товара карты, которая не была бесплатной, начисляется штамп.
Весовой товар (меньше одной целой единицы), ограниченные и не участвующие в акциях строки штампов не дают.
Штампы накоплены до чека: заполненная этим чеком карта даёт награду в следующем. В результате —
`stamps` по картам и строкам, в объяснении — `stamps.rewarded` и `stamps.earned`; вектор 18.

**Касса.** Штампы бесплатных единиц резервируются в леджере вместе с чеком (холд на тип баллов карты) и
списываются при подтверждении; начисленные штампы проводятся при подтверждении по сроку жизни карты. Отмена
резерва освобождает холд, аннулирование и возвраты забирают начисленные штампы и возвращают списанные —
долями возвращённых количеств, как баллы. Офлайн-касса не знала о наградах, поэтому офлайн-чеки только
копят штампы.
