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

Баланс и операции

Раздел описывает работу с балльным счётом клиента: чтение баланса и истории операций, начисление, двухфазное и прямое списание, лимиты списания. Все операции выполняются над счётом клиента, который идентифицируется внешним идентификатором externalId (идентификатор профиля во внешней мастер-системе).

Модуль построен на принципе двойной бухгалтерии: каждая операция формирует проводки в балльном леджере, поэтому баланс всегда сводим, а история операций — неизменна и прослеживаема.

Связанные разделы

  • Ручные начисления, корректировки и перевод уровня оператором, в том числе пакетом по списку или файлу, — Ручные операции.
  • Целевые счета внутри карты клиента и очередь списания — Субсчета баллов.
  • Сгорание баллов по сроку и вручную — Сгорание баллов.

Баланс: три величины

Баланс клиента описывается тремя величинами. Все суммы передаются в минорных единицах (целое число; одна минорная единица — наименьший неделимый балл).

Величина Поле ответа Смысл
Баланс balanceMinor Полный остаток баллов на счёте по данным леджера
Удержано (в резерве) heldMinor Баллы, зарезервированные активными удержаниями (hold) с неистёкшим сроком
Доступно availableMinor Баллы, которые может взять новая операция — резерв, списание, корректировка — не лишив подтверждения ни один активный резерв

Как считается доступный остаток

Доступное считается по каждому субсчёту, итог — сумма по субсчетам:

доступно = max(0, баланс − резервы − баллы с наступившим сроком + покрытое)
  • Баллы с наступившим сроком — баллы, срок которых уже наступил, но прогон сгорания их ещё не сжёг. На счёте они лежат, но новой операции уже не достаются: иначе клиенту показали бы сумму, которую касса откажется списать.
  • Покрытое — часть активных резервов, которую подтвердят как раз эти баллы. Резерв может взять партию баллов, если её срок не раньше момента создания резерва; такую часть не вычитают второй раз.
  • Резерв, созданный уже после срока партии, подтверждается только действующими баллами и вычитается целиком.

Пока все резервы созданы до сроков партий, результат совпадает с «баланс минус большее из резервов и баллов с наступившим сроком». Доступное не бывает отрицательным.

Пример

На счёте 700 действующих баллов и 300 с наступившим, но не сожжённым сроком. После срока касса резервирует 400. Доступно — 300: 1 000 − 400 − 300 + 0. Резерв на 600 будет отклонён 402 not_enough_funds, а не принят с последующим отказом одного из подтверждений.

По той же формуле работают карточка клиента, личный кабинет, ответы резерва и списания, корректировка оператора и проверка пакета ручных операций (26.3.0). Прогон сгорания удерживает ровно те партии, которыми подтвердятся резервы, поэтому доступное после прогона не меняется.

Чтение баланса

GET /v1/customers/{externalId}/balance — роль LOYALTY_ACCOUNT_READ.

Баланс клиента — это не одна сумма, а набор субсчетов. Ответ содержит итог по валюте баллов и разбивку по субсчетам; поля итога сохранены в прежнем виде, поэтому потребители, которые читают только итог (касса, сайт, мобильное приложение, кабинет), продолжают работать без изменений.

{
  "currency": "PTS",
  "balanceMinor": 150000,
  "heldMinor": 20000,
  "availableMinor": 130000,
  "nearestExpiryAt": "2026-12-31T21:00:00Z",
  "nearestExpiryAmountMinor": 5000,
  "subaccounts": [
    { "subaccountId": 1, "code": "BASE", "name": "Основной",
      "balanceMinor": 120000, "heldMinor": 20000, "availableMinor": 100000,
      "nearestExpiryAt": "2026-12-31T21:00:00Z" },
    { "subaccountId": 4, "code": "WELCOME", "name": "Приветственные",
      "balanceMinor": 30000, "heldMinor": 0, "availableMinor": 30000,
      "nearestExpiryAt": null }
  ]
}
Поле разбивки Смысл
subaccountId, code, name Субсчёт
balanceMinor, heldMinor, availableMinor Три величины баланса на этом субсчёте
nearestExpiryAt Ближайший срок сгорания баллов субсчёта; пусто — сгорающих баллов нет

Поля итога nearestExpiryAt и nearestExpiryAmountMinor — ближайшее сгорание по клиенту: дата — ближайший срок партии баллов с остатком (в том числе уже наступивший, если прогон сгорания ещё не прошёл), сумма — все партии с той же календарной датой в часовом поясе программы по всем субсчетам, без вычета резерва.

Та же разбивка приходит в карточке клиента (GET /v1/customers/{externalId}/card, блок loyalty.subaccounts) — одним ответом с итогом.

Счета клиента во всех включённых валютах (26.3.0)

У каждого активного клиента есть счёт в каждой включённой валюте — балльной и денежной — на её базовом субсчёте. Счета открываются при заведении клиента (форма, API, импорт, первая запись профиля), а при создании или включении валюты — фоновой задачей для всех активных клиентов. Заблокированным клиентам фоновая задача счетов не открывает — они открываются при разблокировке. Флаг preCreateAccounts при создании клиента больше не нужен: он принимается и игнорируется. Счёт на другом субсчёте по-прежнему возникает первой проводкой на этот субсчёт; открыть счёт явно можно командой POST /v1/customers/{externalId}/accounts (роль LOYALTY_ACCOUNT_WRITE). Запрос баланса по неизвестному клиенту вернёт ошибку, а не создаст счёт.

Баланс в карточке клиента

В карточке клиента на вкладке «Счета и балансы» сверху вниз показываются:

  1. Плитки баланса: «Доступно баллов» — основной показатель, «Всего баллов» и «В резерве» (только если резерв больше нуля).
  2. Доступное по типам баллов — если у клиента два субсчёта и больше, под первым рядом плиток — плитка «Доступно — {название субсчёта}» на каждый субсчёт, в порядке очереди списания. В сумме они дают «Доступно баллов».
  3. Уровень — текущий уровень (с пометкой «Назначен вручную» при ручном назначении), «Множитель начисления» и прогресс до следующего уровня, см. Уровни.
  4. «Разбивка по субсчетам» — название и код каждого субсчёта и полный остаток на нём. Порядок строк — очередь списания (в этом порядке баллы будут потрачены), а не величина остатка. Если разбивки нет, секция не показывается.
  5. «Счета» — счета клиента во всех включённых валютах, балльных и денежных: номер счёта, валюта, статус («Активен», «Неактивен», «Приостановлен») и суммы «Баланс», «Начислено», «Списано». Счёт, который сервер ещё не открыл, показывается с прочерком вместо номера и нулями.

Суммы показываются с точностью своей валюты из справочника валют: плитки и разбивка — в валюте баллов клиента, строка счёта — в своей валюте. Без роли LOYALTY_CURRENCY_READ справочник недоступен, и суммы выводятся с двумя знаками после запятой. Сроки сгорания на этой вкладке не показываются — для них есть вкладка «Сроки сгорания», см. Сгорание баллов.

GET /v1/customers/{externalId}/accounts (роль LOYALTY_ACCOUNT_READ) отдаёт по каждому счёту сводку summary: balanceMinor, creditMinor, debitMinor, а также heldMinor («В резерве») и expiredMinor («Сгорело всего» — ушло на счёт сгорания за всё время; null, если счёт сгорания не отделён от счетов списания и начислений). Включённая валюта без открытого счёта приходит строкой с нулями и accountNumber: null.

История операций клиента

GET /v1/customers/{externalId}/operations — роль LOYALTY_MONITORING_READ. Ответ — страница {items, page, size, total}.

Вкладка «Бонусные операции»

В карточке клиента история показывается на вкладке «Бонусные операции»: колонки «ID транзакции», «Дата», «Тип операции», «Сумма», «Источник», «Причина», «Оператор». Фильтры — в заголовках колонок, фильтрует сервер по всей истории клиента; смена фильтра возвращает на первую страницу.

Колонка Фильтр
Дата Период «с — по»; любая граница может быть пустой, обе включительно (сутки — в часовом поясе программы)
Тип операции «Начисление», «Списание», «Корректировка», «Сторно» — несколько значений по «ИЛИ»
Сумма «от — до» в баллах; сравнивается без знака: 500 — 500 находит и начисление, и списание на 500
Источник «Покупка в магазине», «Акция», «Промокод», «Ручная операция», «Оплата баллами», «Сгорание по сроку», «Сгорание оператором», «Реферальная программа», «Сценарий RT Flow», «Партнёрский оффер» — несколько значений по «ИЛИ»

Сортировка — только по дате: новые сверху или старые сверху. Знак и цвет суммы задаёт тип операции: начисление — «+», списание, корректировка, сгорание и сторно — «−». Сторно — это отмена начисления (возврат покупки, отзыв промокода), баллы при нём снимаются. Фильтры и сортировка вкладки не сохраняются между сеансами.

Применения акций отдельно показывает вкладка «Участие в акциях», см. Карточка клиента.

Параметры запроса

Параметр Назначение
page, size Номер страницы (с 0) и размер (по умолчанию 20)
sort Сортировка по дате: createdAt,desc (по умолчанию) или createdAt,asc. Единственное поддерживаемое поле — createdAt; прочие поля сортировки игнорируются без ошибки
createdFrom, createdTo Период по моменту операции, границы включительно; любую можно не передавать
type Тип операции: ACCRUAL, REDEMPTION, CORRECTION, REVERSAL. Несколько значений — по «ИЛИ»
amountFromMinor, amountToMinor Сумма в минорных единицах, без знака, границы включительно
source Источник операции (см. ниже). Несколько значений — по «ИЛИ»
promotionId Версия сработавшей акции
hasPromotion true — только применения акций (источник PROMOTION); false — вся остальная история; не передан — история целиком
mechanicCode Отбор по механике сработавшей акции. Несколько значений — по «ИЛИ»

Правила отбора:

  • Все параметры необязательны и складываются по «И»; списки передаются повтором параметра или через запятую.
  • total считается по тому же отбору, что и строки, — счётчик совпадает с содержимым выдачи.
  • Отбор идёт по всей истории клиента на сервере, а не по загруженной странице.
  • Неизвестное значение type, source или mechanicCode даёт пустую выдачу, а не ошибку; пустое значение — как отсутствие параметра. Перевёрнутый диапазон периода или суммы — пустая страница.
  • Сторно начисления по акции наследует поля акции (и подходит под promotionId), но участием в акции не считается: под hasPromotion=true не попадает.
  • Строки без акции под фильтр mechanicCode не подходят, поэтому он осмыслен вместе с hasPromotion=true.
  • При одинаковом времени записи (операции одной транзакции) порядок доопределяется идентификатором операции — страницы не теряют и не дублируют строки.

Поля строки истории

Поле Смысл
operationId Идентификатор операции
createdAt Момент операции
type Тип: ACCRUAL «Начисление», REDEMPTION «Списание», CORRECTION «Корректировка», REVERSAL «Сторно»
amountMinor Сумма в минорных единицах
source Источник: PURCHASE — покупка, PROMOTION — акция, PROMOCODE — промокод, MANUAL — ручная операция, REDEMPTION — оплата баллами, EXPIRY — плановое сгорание, MANUAL_EXPIRY — сгорание оператором, REFERRAL — реферальная программа, RT_FLOW — сценарий RT Flow, PARTNER — партнёрский оффер
reason, reasonCode, comment Подпись причины, код причины из справочника «Причины операций» и комментарий оператора (у ручных операций)
operator Оператор ручной операции
promotionId, promotionName, promotionMechanicCode Сработавшая акция: версия, применившаяся к покупке, её название и механика. У операций без акции пусты
subaccount Субсчёт операции {id, code, name}
accountNumber Счёт клиента по проводке операции; у операции по нескольким счетам — счёт первой проводки, без проводки — null. В интерфейсе пока не выводится

Сгорание в истории

Плановое сгорание баллов по сроку попадает в историю за прогон записью на каждый субсчёт, по которому было сгорание (источник EXPIRY «Сгорание по сроку»). Ручное сгорание, запущенное оператором, записывается с источником MANUAL_EXPIRY «Сгорание оператором», причиной и оператором. Тип у обеих записей — REDEMPTION «Списание»: отличить сгорание от траты баллов можно по источнику, в том числе фильтром «Источник». Подробнее — Сгорание баллов.

Начисление баллов

POST /v1/customers/{externalId}/accruals — роль LOYALTY_ACCRUAL_WRITE, обязателен заголовок Idempotency-Key.

Это одиночное ручное начисление оператора. Интеграционные начисления (RT Flow, модуль Promo, очередь команд) идут своими точками приёма и в этом разделе не описываются.

Поле Тип Обязательность Описание
pointsMinor целое > 0 да Сумма начисления в минорных единицах
reasonCode строка да Код причины из справочника «Причины операций» (активная запись)
comment строка ≤ 500 при OTHER Комментарий оператора; обязателен при причине «Прочее»
reference строка да Внешняя ссылка операции (например, номер документа-основания)
subaccountCode строка нет Субсчёт; не указан — базовый субсчёт валюты. Субсчёт должен быть активен
expireAt дата-время нет Срок сгорания лота; не указан — первый заданный по цепочке: свой срок субсчёта → срок уровня клиента → базовый срок валюты; не задан нигде — баллы не сгорают (см. Сгорание баллов)
curl -X POST https://<host>/v1/customers/CUST-42/accruals \
  -H "Authorization: Bearer <token>" \
  -H "Idempotency-Key: 8f1c0b2e-..." \
  -H "Content-Type: application/json" \
  -d '{"pointsMinor": 50000, "reasonCode": "COMPENSATION", "reference": "DOC-1001", "subaccountCode": "BASE"}'
{ "transactionId": 90871, "balanceMinor": 200000 }

Причина — только из справочника (26.3.0)

Поле свободного текста reason у ручного начисления убрано: причина задаётся кодом reasonCode и необязательным комментарием comment. Правила причины (права на «Прочее», обязательный комментарий) и коды ошибок описаны в разделе Ручные операции.

Начисление отклоняется:

  • заблокированному клиенту — 422 customer_blocked;
  • на неактивный субсчёт — 409 subaccount_not_active;
  • в выключенную валюту баллов — 422 currency_disabled (см. Справочник валют).

Двухфазное списание (hold → commit/cancel)

Двухфазное списание разделяет резервирование и фактическое списание баллов. Это модель авторизации/захвата (auth/capture), удобная для кассовых сценариев: сначала баллы резервируются (hold), затем — по факту завершения покупки — либо списываются (commit), либо резерв освобождается (cancel).

sequenceDiagram
    participant POS as Касса/корзина
    participant L as Loyalty
    POS->>L: POST /holds (резерв, срок жизни)
    L-->>POS: holdId, availableMinor, parts
    alt покупка завершена до срока
        POS->>L: POST /holds/{holdId}/commit
        L-->>POS: transactionId, availableMinor
    else покупка отменена
        POS->>L: POST /holds/{holdId}/cancel
        L-->>POS: статус, availableMinor
    else срок резерва истёк
        Note over L: резерв перестаёт держать баллы,<br/>подтвердить его нельзя
    end

Все три команды требуют роль LOYALTY_REDEMPTION_WRITE и заголовок Idempotency-Key.

Резерв (hold)

POST /v1/customers/{externalId}/holds

Уменьшает доступный остаток, не затрагивая полный баланс.

Поле Тип Обязательность Описание
amountMinor целое > 0 да Сумма резерва в минорных единицах
reference строка да Внешняя ссылка операции
purchaseAmountMinor целое нет Сумма чека — нужна для процентных лимитов списания
items массив нет Позиции чека {sku, category, amountMinor} (до 1000) — нужны субсчетам с ограничением «на что» (товары, категории), см. ниже
storeCode, merchantCode строка нет Место покупки: код торговой точки и код мерчанта — нужны субсчетам с ограничением «где»
expireAt дата-время нет Срок жизни резерва; не указан — действует срок по умолчанию
{
  "holdId": 5512,
  "availableMinor": 110000,
  "parts": [ { "subaccountId": 1, "code": "BASE", "amountMinor": 20000 } ]
}

parts — разбивка резерва по субсчетам в порядке очереди списания. Разбивка фиксируется в момент создания резерва, и подтверждение списывает именно по ней, даже если очередь субсчетов успела измениться.

BREAKING (26.3.0): у резерва всегда есть срок

Если expireAt не передан, резерв получает срок по умолчанию — операционный параметр redemption.hold-ttl (по умолчанию 30 минут, меняется в администрировании без перезапуска). Бессрочных резервов больше нет: раньше резерв без срока держал баллы клиента вечно, если касса не дошла ни до подтверждения, ни до отмены.

Интеграция, которая создаёт резерв без expireAt и подтверждает его позже срока по умолчанию, получит отказ. Явно переданный expireAt по-прежнему не переопределяется.

Срок жизни резерва

  • Как только срок резерва вышел, баллы снова доступны клиенту — независимо от того, успел ли фоновый сборщик отметить резерв.
  • Подтвердить просроченный резерв нельзя: проверка срока выполняется в самой операции подтверждения.
  • Снять (cancel) просроченный резерв можно: вызов завершается успешно и возвращает статус EXPIRED — резерв и так уже освобождён.
  • Фоновый сборщик переводит активные резервы с вышедшим сроком в статус EXPIRED, чтобы состояние было видно. Расписание — LOYALTY_REDEMPTION_HOLD_EXPIRY_CRON (по умолчанию выключен; на корректность списания не влияет).

Подтверждение (commit)

POST /v1/customers/{externalId}/holds/{holdId}/commit

Списывает зарезервированные баллы по зафиксированной разбивке: полный баланс уменьшается.

{ "transactionId": 90930, "availableMinor": 110000 }

Резерв не в активном состоянии (подтверждён, отменён, истёк) — 409 hold_not_active; неизвестный резерв — 404 hold_not_found.

Отмена (cancel)

POST /v1/customers/{externalId}/holds/{holdId}/cancel

Освобождает резерв без списания.

{ "holdId": 5512, "status": "CANCELLED", "availableMinor": 130000 }

Прямое списание (redeem)

POST /v1/customers/{externalId}/redemptions — роль LOYALTY_REDEMPTION_WRITE, обязателен заголовок Idempotency-Key.

Поле Тип Обязательность Описание
amountMinor целое > 0 да Сумма списания в минорных единицах
reference строка да Внешняя ссылка операции
purchaseAmountMinor целое нет Сумма чека — для процентных лимитов списания
items, storeCode, merchantCode нет Позиции чека и место покупки — как у резерва
reason строка нет Технический комментарий источника
{
  "transactionId": 90945,
  "balanceMinor": 80000,
  "availableMinor": 60000,
  "parts": [
    { "subaccountId": 4, "code": "WELCOME", "amountMinor": 30000 },
    { "subaccountId": 1, "code": "BASE", "amountMinor": 40000 }
  ]
}

Списание идёт по очереди субсчетов и может затронуть несколько субсчетов; parts показывает, с какого субсчёта сколько списано. Сторно возвращает баллы на те же субсчета. Подробнее — Субсчета баллов.

Нехватка баллов

Если доступных баллов не хватает суммарно по всем субсчетам, списание отклоняется целиком — 402 not_enough_funds; частичного списания нет. Баллы под активными резервами не считаются доступными.

Ограничения субсчетов при списании

У субсчёта могут быть ограничения области списания (26.3.0), см. Субсчета баллов:

  • «на что» — товары и категории: такой субсчёт отдаёт не больше стоимости подходящих ему позиций items, пересчитанной в баллы по курсу валюты баллов (с округлением вниз); остальное списывается со следующих субсчетов по очереди. Без items такие субсчета в списании не участвуют. Категория подходит, если она есть среди категорий позиции (у товара каталога — на любом из трёх уровней);
  • «где» — мерчанты и торговые точки: субсчёт участвует, если точка запроса есть среди точек правила или мерчант запроса — среди мерчантов правила. Запрос без места такой субсчёт пропускает.

Ограничения «на что» и «где» действуют вместе. Позицию, подходящую двум субсчетам, расходуют по очереди; субсчета без ограничений её стоимость не расходуют. Резерв фиксирует разбивку в момент создания. Сгорание, корректировки и обмен ограничения не проверяют.

Лимиты списания

Перед списанием и резервом сумма проверяется по правилам списания клиента:

  • правила активной конфигурации программы (26.3.0) — область списания, минимум и максимум за покупку (в том числе свой максимум на каждом уровне), минимальный баланс, кратность; см. Конфигурации программы. У конфигурации без своих правил действует общая политика списания;
  • частные лимиты по разрезам — уровень, сегмент, валюта баллов — сильнее правил конфигурации. Общая политика и разрезы настраиваются в разделе «Настройка ПЛ», блоки «Ограничения на списание баллов» и «Лимиты по разрезам», см. Операционная конфигурация.
Тип границы Как считается
FIX Абсолютное значение в минорных единицах
PERCENT Процент от суммы чека purchaseAmountMinor; при области «товары» или «категории» — от суммы подходящих позиций items. Если сумма чека не передана, процентная граница общей политики не применяется

Проверки идут в порядке: минимальный баланс, область, минимум и максимум, кратность. Нарушение — отказ, а не урезание суммы:

Код Когда
redemption_below_min_balance Доступных баллов меньше минимального баланса для списания — тратить нельзя даже меньшую сумму
redemption_scope_not_eligible Покупка не подходит под область списания
redemption_items_required Область «товары» или «категории», а позиции чека items не переданы
redemption_purchase_amount_required Область «покупки от суммы», а сумма чека purchaseAmountMinor не передана
redemption_below_min / redemption_above_max Сумма меньше минимума или больше максимума
redemption_not_multiple Сумма не кратна заданной кратности

Округление границ — внутрь допустимого промежутка (26.3.0)

Дробная граница округляется так, чтобы не расширять разрешённое: максимум — вниз, минимум — вверх. Например, потолок «не больше 30 % чека» на чеке в 15 даёт 4,5 → 4 (раньше 5, то есть 33 %), порог «не меньше 10 % чека» на чеке в 14 даёт 1,4 → 2 (раньше 1). То же правило — для границы FIX. На значениях по умолчанию (минимум 0, максимум 100 %) поведение не меняется.

Лимит с нераспознанным типом границы больше не отключается молча — списание по нему отклоняется.

Перевод и обмен

POST /v1/transfer переносит сумму между двумя счетами (в т. ч. между субсчетами одного клиента или между разными клиентами), POST /v1/exchange — пересчитывает и переносит сумму между счетами в разных валютах по действующему курсу; оба — на уровне счетов, а не под /v1/customers/{externalId}/..., и оба требуют роль LOYALTY_OPERATION_WRITE и заголовок Idempotency-Key.

Пересчёт суммы по курсу обмена округляется один раз, вниз и сразу в точности целевой валюты (26.3.0). Раньше при переходе к более грубой точности результат мог округлиться вверх (10,00 по курсу 4 → 3 вместо 2), а при исходной точности грубее целевой терялись разряды (100 по курсу 3 → 33,00 вместо 33,33).

Идемпотентность операций

Все операции, изменяющие баланс — начисление, резерв, подтверждение, отмена, прямое списание, корректировка и ручное сгорание — идемпотентны. Каждый такой запрос обязан содержать заголовок Idempotency-Key.

  • При первом обращении с данным ключом операция выполняется, а её результат сохраняется.
  • Повторное обращение с тем же ключом не выполняет операцию заново, а возвращает ранее сохранённый результат.

Это защищает от двойного начисления или двойного списания при повторах запроса (сетевые ретраи, таймауты, повторная отправка кассой).

Уникальность ключа

Idempotency-Key должен быть уникален для каждой логически отдельной операции. Используйте новый ключ (например, UUID) на каждую новую транзакцию и повторяйте тот же ключ только при ретрае именно этого запроса.

Сводка эндпоинтов

Операция Метод и путь Роль Идемпотентность
Баланс клиента GET /v1/customers/{externalId}/balance LOYALTY_ACCOUNT_READ — (чтение)
История операций GET /v1/customers/{externalId}/operations LOYALTY_MONITORING_READ — (чтение)
Начисление POST /v1/customers/{externalId}/accruals LOYALTY_ACCRUAL_WRITE Idempotency-Key
Корректировка POST /v1/customers/{externalId}/corrections LOYALTY_REDEMPTION_WRITE Idempotency-Key
Резерв (hold) POST /v1/customers/{externalId}/holds LOYALTY_REDEMPTION_WRITE Idempotency-Key
Подтверждение резерва POST /v1/customers/{externalId}/holds/{holdId}/commit LOYALTY_REDEMPTION_WRITE Idempotency-Key
Отмена резерва POST /v1/customers/{externalId}/holds/{holdId}/cancel LOYALTY_REDEMPTION_WRITE Idempotency-Key
Прямое списание POST /v1/customers/{externalId}/redemptions LOYALTY_REDEMPTION_WRITE Idempotency-Key
Ручное сгорание POST /v1/customers/{externalId}/expirations LOYALTY_REDEMPTION_WRITE Idempotency-Key