# ADR-0003. Леджер баллов двойной записи

- Статус: принято, уточнено по итогам реализации (этап 1)
- Дата: 2026-09-30, уточнение — 2026-10-01

## Контекст

Баллы — обязательство мерчанта перед участником. Ошибки в балансе при частичных возвратах,
офлайн-чеках и параллельных списаниях встречаются даже у зрелых платформ. Поле «баланс» без истории
не даёт ни сверки, ни восстановления баланса на дату.

## Решение

- Баллы учитываются в неизменяемом (append-only) леджере двойной записи:
  `ledger_transactions` (бизнес-операция) и `ledger_entries` (проводки, только INSERT).
- У участника по каждому типу баллов три счёта: `available`, `pending` (ожидает активации),
  `held` (зарезервировано на кассе). Системные счета тенанта: эмиссия, погашение, сгорание, корректировки.
- Суммы — положительные целые с направлением (дебет/кредит). Сумма дебетов транзакции равна сумме
  кредитов — проверяется отложенным триггером на коммите.
- Балансы счетов участника материализованы в `ledger_accounts.balance` и обновляются в той же транзакции,
  что и проводки. `CHECK` запрещает отрицательный баланс, если счёт это явно не разрешает.
  Балансы системных счетов не обновляются синхронно, чтобы не создавать «горячие» строки.
- Каждое начисление — партия (`point_lots`) со сроками активации и сгорания. Резерв сразу уменьшает
  остаток партий, начиная с ближайшего сгорания; журнал движений партий позволяет вернуть баллы в те же
  партии при отмене или возврате.
- Исправления — только новыми (сторнирующими) транзакциями со ссылкой на исходную.
- Роли `app` и `system` не имеют прав UPDATE и DELETE на проводки; триггер дополнительно блокирует
  изменение и удаление для всех ролей.
- Только модуль `ledger` пишет проводки; остальные модули вызывают его контракт.
- Ночная сверка: материализованный баланс = сумма проводок; остаток партий = доступный + зарезервированный баланс.

## Уточнения по итогам реализации

- **Резерв — отдельный счёт `held`, а не «pending-перевод» в стиле TigerBeetle.** Резерв — обычная
  транзакция `available → held`; проведение — `held → redemption`, отмена — `held → available`. Так все
  изменения балансов остаются проводками, а инварианты проверяются одинаково.
- **Инварианты партий:** остаток активных партий = `max(available, 0)`, остаток ожидающих = `pending`,
  сумма активных резервов = `held`, остаток партии = сумма её движений (`lot_movements`).
- **Порядок списания:** сначала партии с ближайшим сгоранием, партии без срока — последними. При отмене
  или частичном проведении резерва баллы возвращаются в партии в обратном порядке; баллы, вернувшиеся
  в сгоревшую партию, сгорают сразу.
- **Долг.** Отрицательный доступный баланс возможен только если тип баллов это разрешает (флаг
  неизменяем). Любое новое поступление сначала гасит долг.
- **Возврат начисления:** сначала берётся остаток самой партии, затем сгоревшая часть корректируется на
  системной стороне (участник не платит дважды), потраченная часть — по политике `SpentPointsPolicy`.
- **Обслуживание встроено в операции:** каждая операция участника сначала активирует и сжигает
  наступившие по сроку партии. Плановые команды `ledger:*` догоняют неактивных участников.
- **Скользящий срок (этап 3c).** Партия с флагом `sliding_expiry` (политика «сгорают после N дней без
  покупок») получает новый срок от каждой подтверждённой покупки участника — `extendExpiry` сдвигает срок
  таких партий вперёд, если он ещё не наступил; уже просроченные партии не возвращаются. Сдвиг срока не
  меняет сумм и не пишет проводок.
- **Идемпотентность:** ключ уникален в пределах тенанта; повтор с теми же параметрами возвращает исходную
  транзакцию, с другими — ошибка `idempotency_key_reused`.
- **Партиционирование отложено.** Схема к нему готова (идентификатор проводки — bigint identity, есть
  `created_at`); переход — через `ATTACH PARTITION` существующей таблицы. Порог: ~100 млн проводок или
  первый enterprise-клиент.

## Последствия

- Любую операцию можно объяснить и воспроизвести; баланс на любую дату вычислим.
- Операции сложнее, чем изменение одного поля, но вся сложность сосредоточена в одном модуле и проверена
  рандомизированными тестами и тестом параллельных списаний.
- Базовая производительность (этап 1, Windows, один процесс, локальная БД): p50 15–24 мс на операцию,
  ~46 операций/с на процесс; основная цена — ~15 обращений к БД на операцию. Оптимизация — этап 4.
