Перейти к содержанию

Сгорание баллов

Баллы в модуле лояльности учитываются лотами — отдельными порциями начисления со своим сроком действия. По истечении срока непогашенный остаток лота сгорает: он списывается с балльного счёта клиента на системный счёт сгорания (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, но остаток ещё не израсходован.

Откуда берётся срок лота

Срок лота вычисляется в момент начисления — первый заданный по цепочке:

  1. Срок, заданный начислением: у начисления по акции — поле акции «Срок жизни начисляемых баллов» (дней), у запроса начисления — явный expireAt. Оба реферальных начисления получают срок реферальной акции; источник такого срока — «срок задан при начислении».
  2. Своё правило субсчёта, на который легло начисление: через N дней после начисления или до фиксированного момента. Подробнее — Субсчета баллов.
  3. Срок уровня клиента в его конфигурации программы — колонка «Срок жизни, дней» таблицы уровней (см. Конфигурации программы).
  4. Базовый срок валюты счёта — поле «Срок жизни баллов, дней» в карточке балльной валюты (у новой валюты — 365 дней), см. Справочник валют.
  5. Если срок не задан нигде — лот бессрочный.

Субсчёт важнее уровня: свой срок субсчёта — сознательное правило для вида баллов (подарок сгорает через 30 дней у всех), а срок уровня описывает обычные баллы клиента.

Срок и его источник фиксируются в лоте и дальше не пересчитываются — ни сменой уровня клиента, ни правкой настроек субсчёта, уровня или валюты. Лот, возвращённый отменой возврата покупки, получает срок и источник исходного лота; копии лотов при переводе — срок исходных; лот, полученный обменом, — ближайший срок из списанных.

Срок и источник всегда согласованы: у лота с источником none срока нет, у лота с любым другим источником он есть — это правило держит и база данных. Лот, где оно нарушено ручной правкой данных, читается с ошибкой 500 lot_expiry_inconsistent, в том числе при списании.

Изменение поведения (26.3.0)

Режим субсчёта «Общий срок» (раньше — «Без сгорания») больше не означает бессрочные баллы: такой субсчёт берёт срок уровня клиента или базовый срок валюты (по умолчанию 365 дней). Так же сгорают баллы базового субсчёта и «Акционных баллов». Баллы не сгорают, только если срок не задан нигде по цепочке, в том числе у валюты.

Лоты без срока

Если у лота не задан expiresAt, он не попадает в выборку на сгорание и сохраняет остаток бессрочно — до момента полного погашения.

Когда баллы становятся недоступны

Лот действует до момента срока включительно и недоступен строго после него — сразу, а не с ночного прогона: списание такие баллы не берёт, доступный остаток их не показывает.

Доступный остаток (availableMinor) считается так:

доступно = max(0, баланс − резервы − баллы с наступившим сроком + покрытое)

где покрытое — часть баллов с наступившим сроком, которой подтвердятся действующие резервы. Резерв может взять лот, только если срок лота не раньше момента создания резерва: резерв, созданный до срока, держит свои баллы и подтверждается после срока, а резерв, созданный после срока, подтверждается только действующими лотами.

Пример

У клиента 700 действующих баллов и 300 баллов с наступившим, но ещё не сожжённым сроком. Касса уже после срока создаёт резерв на 400. Доступно — 300 (1 000 − 400 − 300), и резерв ещё на 600 будет отклонён 402 not_enough_funds: одни и те же баллы не обещаются дважды.

Плановое сгорание

Прогон сгорания:

  1. Отбирает открытые лоты с истёкшим сроком — срок уже прошёл, а остаток положителен — пачкой LOYALTY_EXPIRY_BATCH_SIZE (по умолчанию 5 000) в порядке «срок, лот». Остаток пачки разбирается следующими прогонами.
  2. Если таких лотов нет — завершается без изменений (нулевой результат).
  3. Каждый счёт клиента (то есть субсчёт) обрабатывает в своей транзакции под блокировкой счёта: касса не ждёт длинную транзакцию сгорания.
  4. Сжигает свободный остаток лота: часть, которую заберут активные резервы, созданные до срока, остаётся на лоте. Снятый или истёкший резерв освобождает удержанное — оно сгорит следующим прогоном.
  5. Формирует проводку «счёт клиента → системный счёт сгорания» на сжигаемую сумму. Лот, остаток которого сгорел полностью, получает статус EXPIRED.
  6. Пишет в историю операций клиента по записи на каждый субсчёт, по которому было сгорание, — с источником «Сгорание по сроку» и названием субсчёта.
  7. Ставит клиенту одно сообщение на валюту о факте сгорания — «Сгорело 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": 12,
  "burnedMinor": 4500
}
Поле Назначение
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 строка нет Субсчёт, с которого сжигать. Не указан — сгорание идёт по очереди субсчетов, как обычное списание, и может затронуть несколько
{ "transactionId": 91022, "balanceMinor": 70000, "availableMinor": 50000 }
  • Баллы под активными резервами не сжигаются: доступно к сгоранию только 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 срабатывает, когда прогон давно не отрабатывал.