Сгорание баллов¶
Баллы в модуле лояльности учитываются лотами — отдельными порциями начисления со своим
сроком действия. По истечении срока непогашенный остаток лота сгорает: он списывается с
балльного счёта клиента на системный счёт сгорания (breakage, по умолчанию BRK). Операция
выполняется средствами балльного леджера на двойной бухгалтерии, поэтому суммарный баланс системы
остаётся сбалансированным, а каждое сгорание оставляет проводку.
Баллы с наступившим сроком перестают быть доступны сразу, в момент срока, а плановый прогон затем переносит их на счёт сгорания. Кроме планового сгорания по сроку, оператор может сжечь баллы клиента вручную, а клиент заранее получает предупреждения о скором сгорании.
Все даты сгорания — сроки лотов, расписания прогонов, тексты уведомлений — трактуются в часовом
поясе программы (системная настройка «Часовой пояс программы», по умолчанию Europe/Moscow).
Учёт баллов лотами¶
Каждое начисление формирует лот (account_lot) со следующими ключевыми атрибутами:
| Атрибут | Назначение |
|---|---|
originalBalance |
Исходный объём начисленных баллов лота (в минорных единицах). |
currentBalance |
Текущий непогашенный остаток лота. |
redemption |
Тип погашаемости баллов лота (RedemptionType). |
expiresAt |
Момент истечения срока действия лота. Может отсутствовать — лот без срока не сгорает. |
expirySource |
Откуда взят срок: promotion, request, subaccount, tier, currency или none (у уровня — ещё код уровня и конфигурация). У лотов, начисленных до 26.3.0, пусто. |
status |
Статус лота: OPEN, CLOSED, CANCELLED, EXPIRED. |
createdAt |
Момент создания лота. |
Списание баллов при погашении идёт по лотам и закрывает их по мере исчерпания остатка
(лот переходит в CLOSED, когда currentBalance доходит до нуля). Сгорание затрагивает только
лоты, у которых наступил срок expiresAt, но остаток ещё не израсходован.
Откуда берётся срок лота¶
Срок лота вычисляется в момент начисления — первый заданный по цепочке:
- Срок, заданный начислением: у начисления по акции — поле акции «Срок жизни начисляемых баллов»
(дней), у запроса начисления — явный
expireAt. Оба реферальных начисления получают срок реферальной акции; источник такого срока — «срок задан при начислении». - Своё правило субсчёта, на который легло начисление: через N дней после начисления или до фиксированного момента. Подробнее — Субсчета баллов.
- Срок уровня клиента в его конфигурации программы — колонка «Срок жизни, дней» таблицы уровней (см. Конфигурации программы).
- Базовый срок валюты счёта — поле «Срок жизни баллов, дней» в карточке балльной валюты (у новой валюты — 365 дней), см. Справочник валют.
- Если срок не задан нигде — лот бессрочный.
Субсчёт важнее уровня: свой срок субсчёта — сознательное правило для вида баллов (подарок сгорает через 30 дней у всех), а срок уровня описывает обычные баллы клиента.
Срок и его источник фиксируются в лоте и дальше не пересчитываются — ни сменой уровня клиента, ни правкой настроек субсчёта, уровня или валюты. Лот, возвращённый отменой возврата покупки, получает срок и источник исходного лота; копии лотов при переводе — срок исходных; лот, полученный обменом, — ближайший срок из списанных.
Срок и источник всегда согласованы: у лота с источником none срока нет, у лота с любым другим
источником он есть — это правило держит и база данных. Лот, где оно нарушено ручной правкой данных,
читается с ошибкой 500 lot_expiry_inconsistent, в том числе при списании.
Изменение поведения (26.3.0)
Режим субсчёта «Общий срок» (раньше — «Без сгорания») больше не означает бессрочные баллы: такой субсчёт берёт срок уровня клиента или базовый срок валюты (по умолчанию 365 дней). Так же сгорают баллы базового субсчёта и «Акционных баллов». Баллы не сгорают, только если срок не задан нигде по цепочке, в том числе у валюты.
Лоты без срока
Если у лота не задан expiresAt, он не попадает в выборку на сгорание и сохраняет остаток
бессрочно — до момента полного погашения.
Когда баллы становятся недоступны¶
Лот действует до момента срока включительно и недоступен строго после него — сразу, а не с ночного прогона: списание такие баллы не берёт, доступный остаток их не показывает.
Доступный остаток (availableMinor) считается так:
где покрытое — часть баллов с наступившим сроком, которой подтвердятся действующие резервы. Резерв может взять лот, только если срок лота не раньше момента создания резерва: резерв, созданный до срока, держит свои баллы и подтверждается после срока, а резерв, созданный после срока, подтверждается только действующими лотами.
Пример
У клиента 700 действующих баллов и 300 баллов с наступившим, но ещё не сожжённым сроком. Касса
уже после срока создаёт резерв на 400. Доступно — 300 (1 000 − 400 − 300), и резерв ещё на 600
будет отклонён 402 not_enough_funds: одни и те же баллы не обещаются дважды.
Плановое сгорание¶
Прогон сгорания:
- Отбирает открытые лоты с истёкшим сроком — срок уже прошёл, а остаток положителен — пачкой
LOYALTY_EXPIRY_BATCH_SIZE(по умолчанию 5 000) в порядке «срок, лот». Остаток пачки разбирается следующими прогонами. - Если таких лотов нет — завершается без изменений (нулевой результат).
- Каждый счёт клиента (то есть субсчёт) обрабатывает в своей транзакции под блокировкой счёта: касса не ждёт длинную транзакцию сгорания.
- Сжигает свободный остаток лота: часть, которую заберут активные резервы, созданные до срока, остаётся на лоте. Снятый или истёкший резерв освобождает удержанное — оно сгорит следующим прогоном.
- Формирует проводку «счёт клиента → системный счёт сгорания» на сжигаемую сумму. Лот, остаток
которого сгорел полностью, получает статус
EXPIRED. - Пишет в историю операций клиента по записи на каждый субсчёт, по которому было сгорание, — с источником «Сгорание по сроку» и названием субсчёта.
- Ставит клиенту одно сообщение на валюту о факте сгорания — «Сгорело 500 баллов: истёк срок действия.».
flowchart LR
A["Открытые лоты с истёкшим сроком (пачка)"] --> B{"Есть просроченные лоты?"}
B -- "Нет" --> Z["Результат: 0 лотов, 0 баллов"]
B -- "Да" --> C["По каждому счёту: свободный остаток → счёт сгорания"]
C --> D["Лот сгорел полностью → EXPIRED"]
D --> H["Запись в истории операций — по субсчёту"]
H --> M["Сообщение клиенту о факте сгорания"]
M --> E["Итог: число лотов и сумма списания"]
Системные счета по умолчанию разведены: MRK — источник начислений, RDM — счёт списания, BRK —
счёт сгорания. Счёт сгорания задаётся настройкой операционной конфигурации expiry.breakage-account
(по умолчанию — значение переменной LOYALTY_EXPIRY_BREAKAGE_ACCOUNT, BRK). Благодаря разведению
итоги субсчёта и отчёты делят ушедшее на «списано» и «сгорело»; проводки, сделанные до 26.3.0,
остаются на прежнем счёте.
Сгорание видно клиенту и оператору
Плановое сгорание отображается в истории операций с источником «Сгорание по сроку» и отличается от ручного сгорания оператором — источник «Сгорание оператором». В отчётах и мониторинге сгорание не считается тратой клиента и его активностью: оно показывается отдельной величиной «Сгорело».
Балансовая корректность
Сгорание не уничтожает баллы, а переносит их на системный счёт breakage. Объём сгоревших баллов всегда можно сверить по системному счёту сгорания и по итогам субсчёта за период («Сгорело за период»).
Запуск процедуры¶
Сгорание выполняется по расписанию LOYALTY_EXPIRY_CRON (по умолчанию выключено). Расписание
трактуется в часовом поясе программы; на стендах задано 0 30 0 * * * — вскоре после полуночи, чтобы
баллы «до 23:59:59» сгорали в ту же ночь. Смена часового пояса программы применяется к расписанию
после перезапуска сервиса.
Для ручного запуска и проверки предусмотрен сервисный эндпоинт:
| Метод и путь | Назначение | Требуемая роль |
|---|---|---|
POST /v1/maintenance/points/expire |
Сжечь просроченные баллы по сроку лота | LOYALTY_MAINTENANCE_WRITE |
POST /v1/maintenance/points/expire?dryRun=true |
Пробный прогон: тот же ответ, но лоты, проводки и история не меняются, сообщения не ставятся | LOYALTY_MAINTENANCE_WRITE |
curl -X POST "https://<host>/v1/maintenance/points/expire?dryRun=true" \
-H "Authorization: Bearer <JWT>"
| Поле | Назначение |
|---|---|
expiredLots |
Количество лотов, по которым сожжён остаток за прогон. |
burnedMinor |
Суммарный остаток (в минорных единицах), списанный на счёт сгорания. |
Защита от параллельного запуска
Прогон сгорания выполняется под общей блокировкой: если в этот момент уже идёт прогон (например,
по расписанию), ручной вызов получает 409 points_expiry_in_progress, а плановый тик просто
пропускается. Повторный вызов после завершения безопасен: уже сгоревшие лоты имеют статус
EXPIRED и в выборку не попадают.
Первое включение сгорания на контуре
Включите предупреждения → выждите самый длинный шаг предупреждения → выполните пробный прогон
dryRun=true и оцените объём → задайте LOYALTY_EXPIRY_CRON.
Предупреждение о скором сгорании¶
Баллы сгорают по календарю, а не по действию клиента, поэтому клиент заранее получает предупреждение — например, «Через 7 дней сгорят 500 баллов — 25.09.2026 в 23:59 UTC+3».
- Шаги предупреждения. Настройка
LOYALTY_EXPIRY_NOTIFY_BEFOREзадаёт один или несколько сроков до сгорания через запятую, напримерP30D,P7D,P1D. По каждому лоту и шагу отправляется ровно одно предупреждение. Тексты шагов различаются: «Через N дней…», «Завтра…», «Сегодня…». - Задним числом не предупреждают. Шаг, окно которого прошло к моменту начисления, пропускается:
при единственном шаге
P7Dлот, начисленный меньше чем за неделю до срока, предупреждения не получает. Лот, пропустивший несколько шагов (прогон не работал), получает одно предупреждение — ближайшее к сроку. - Одно сообщение на клиента, валюту и шаг в пределах прогона: остатки лотов складываются, дата берётся ближайшая — важно, когда накопленное начнёт теряться. Разные валюты баллов и разные шаги не складываются.
- Сумма — в баллах с учётом точности валюты и с разрядами тысяч; дата и время — в часовом поясе программы с подписью смещения. Если сгорают баллы одного субсчёта и он не базовый, субсчёт называется в тексте.
- По лоту с уже истёкшим сроком предупреждение не отправляется — это работа сгорания.
Предупреждения и сообщение о факте сгорания ставятся в очередь исходящих уведомлений клиенту —
ту же, через которую идут уведомления о начислении и списании. Несрочные сообщения уходят утром по
часовому поясу клиента; перед отправкой предупреждение перепроверяется: если баллы уже потрачены,
оно не отправляется, а после момента сгорания теряет актуальность. Отправка из очереди выполняется
событием в брокер для сценария RT Flow и включается на контуре отдельно (LOYALTY_NOTIFICATIONS_ENABLED,
по умолчанию выключено) — до этого сообщения копятся в очереди. Согласия клиента и канал доставки
продукт не проверяет — это настройка сценария RT Flow.
| Параметр | Назначение | По умолчанию |
|---|---|---|
LOYALTY_EXPIRY_NOTIFY_BEFORE |
Шаги предупреждения до сгорания (ISO-8601, через запятую); непригодное значение — ноль, отрицательное, повтор — заменяется умолчанием | P7D; на стендах dev, qa, prod — P7D,P1D |
LOYALTY_EXPIRY_NOTIFY_CRON |
Расписание прогона предупреждений, в часовом поясе программы | выключено; на стендах — 0 0 9 * * * (09:00) |
LOYALTY_EXPIRY_NOTIFY_BATCH_SIZE |
Сколько лотов забирает один прогон предупреждений; остаток — следующим прогоном | 5 000 |
Предупреждения не зависят от расписания сгорания: сгорание может быть выключено, а предупреждать всё равно можно.
Ручной запуск прогона предупреждений — POST /v1/maintenance/points/expiry-notice (роль
LOYALTY_MAINTENANCE_WRITE), ответ { "queued": N } — сколько сообщений поставлено в очередь. Если
прогон уже идёт — 409 points_expiry_notice_in_progress.
Ручное сгорание баллов клиента¶
Оператор может сжечь заданную сумму баллов конкретного клиента — например, по результатам разбора. Это списание на системный счёт сгорания (write-off), а не трата клиента.
POST /v1/customers/{externalId}/expirations — роль LOYALTY_REDEMPTION_WRITE, обязателен
заголовок Idempotency-Key.
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
amountMinor |
целое > 0 | да | Сумма сгорания в минорных единицах |
reason |
строка | да | Причина (записывается в историю операций) |
reference |
строка | да | Бизнес-ссылка операции |
subaccountCode |
строка | нет | Субсчёт, с которого сжигать. Не указан — сгорание идёт по очереди субсчетов, как обычное списание, и может затронуть несколько |
- Баллы под активными резервами не сжигаются: доступно к сгоранию только
availableMinor. - Не хватает доступных баллов —
402 not_enough_funds. - Операция попадает в историю операций клиента с источником
MANUAL_EXPIRY(«Сгорание оператором»), причиной и оператором. Разбор ручных операций её видит, а отчёты, мониторинг и достижения не считают её тратой клиента. - Повтор с тем же
Idempotency-Keyвозвращает сохранённый результат и повторно не сжигает.
Только через API
Отдельного пункта «Сгорание» в интерфейсе нет: на экране «Ручные операции» доступны начисление, корректировка, перевод уровня и операции с тегами. Для снятия баллов оператором из интерфейса используется корректировка.
Сроки сгорания в карточке клиента¶
Карточка клиента → категория «Баллы и уровень» → вкладка «Сроки сгорания» (после «Бонусные операции»). Вкладка показывает, что и когда сгорит у клиента; только чтение.
- Над таблицей — строка «Ближайшее сгорание: {дата} в {время} — {сумма} баллов». Её считает сервер по всем лотам клиента, а не по строкам текущей страницы; разные валюты не складываются — берётся валюта лота, сгорающего первым.
- Таблица лотов — от ближайшего срока, бессрочные в конце.
| Колонка | Содержание |
|---|---|
| «Сгорает» | Дата и время сгорания лота; после них серым — откуда взят срок: «срок акции», «срок задан при начислении», «срок субсчёта», «срок уровня «Золото»», «базовый срок валюты». У бессрочного лота — «—» и «не сгорают»; у лотов, начисленных до 26.3.0, источник не показывается |
| «Осталось» | Остаток лота |
| «Субсчёт» | Субсчёт лота |
| «Начислено» | Когда начислен лот |
| «Источник» | «Покупка», «Акция», «Ручная операция», «Перевод» |
Если лотов нет — «Баллов нет». Вкладке нужны права LOYALTY_PROFILE_READ и
LOYALTY_MONITORING_READ.
API — GET /v1/customers/{externalId}/points-lots?page=&size= (размер страницы до 100): в items —
lotId, expiresAt, remainingMinor, currency, subaccount, accruedAt, source,
expirySource {kind, tierCode, tierName}; рядом — итог nearest {expiresAt, amountMinor, currency},
пустой, если лотов со сроком нет.
Ближайшее сгорание одной парой на клиента отдаёт и баланс (GET /v1/customers/{externalId}/balance,
бонусный блок карточки): nearestExpiryAt — наименьший срок открытого лота с остатком (в том числе
с наступившим, но ещё не сожжённым сроком), nearestExpiryAmountMinor — сумма лотов с той же
календарной датой в часовом поясе программы по всем субсчетам, без вычета резервов. В разбивке по
субсчетам — свой nearestExpiryAt у каждого субсчёта.
Мониторинг¶
Баллы с приближающимся сроком сгорания можно получить списком лотов:
GET /v1/monitoring/expiring (роль LOYALTY_MONITORING_READ) — клиент, счёт, лот, остаток и срок.
Период задаётся границами from/to (включительно); без границ — ближайшие days дней (по
умолчанию 14).
Работу прогона сгорания отражают метрики loyalty_points_expiry_last_run_seconds (момент последнего
прогона), loyalty_points_expiry_last_burned_minor, loyalty_points_expiry_last_lots и
loyalty_points_expiry_scheduled; алерт LoyaltyExpiryStalled срабатывает, когда прогон давно не
отрабатывал.