# ADR-0009. Кассовый протокол: резерв, подтверждение, отмена, возвраты

- Статус: принято (этап 4b)
- Дата: 2026-10-05

## Контекст

Касса должна напечатать чек со скидками и списанием баллов, которые уже гарантированы: после печати
отказаться от них нельзя, а между расчётом и печатью другой чек может потратить те же баллы, бюджет
акции или последнее использование промокода. Связь с кассой ненадёжна: запросы повторяются, чеки
догружаются позже, резервы остаются незавершёнными.

## Решение

- **Три шага.** `POST /receipts:calculate` считает без изменений (сколько угодно раз).
  `POST /receipts` регистрирует чек и **резервирует** всё, что обещано кассе: баллы (холд в леджере),
  бюджеты и лимиты акций (`CampaignUsage::record`), промокоды (`PromoCodes::reserve`); чек получает статус
  `reserved`. `POST /receipts/{id}:confirm` после печати **проводит** холд и начисляет баллы (с активацией
  и сгоранием по правилам снапшота), принимает фискальные признаки (ФН, ФД, ФП). Флаг `confirm: true`
  делает регистрацию и подтверждение одним шагом (интернет-магазин, касса без двухфазного протокола).
- **Пересчёт на сервере.** При регистрации чек всегда считается заново по снапшоту момента покупки;
  числа кассы не принимаются на веру. Сохраняются снапшот и полный результат расчёта.
- **Естественный ключ** — (касса, бизнес-дата, номер чека): повторная регистрация того же чека — 409
  `receipt_exists` с id существующего. Точные повторы запроса обрабатывает HTTP-идемпотентность (4.5).
- **Отмена.** `:cancel` резерва возвращает баллы, бюджеты, использования и промокоды (`cancelled`);
  `:cancel` подтверждённого чека без возвратов — аннулирование целиком (`voided`): начисленное
  сторнируется, списанное восстанавливается. Подтверждать и отменять может только касса, создавшая чек.
- **Истечение резерва.** Резерв живёт `processing.reservation_minutes` (30 минут); после этого
  подтверждение — 409 `reservation_expired`, а `receipts:expire-reservations` (ежеминутно, системная роль
  находит, работа — от имени тенанта) отменяет чек. Холд в леджере живёт на 10 минут дольше, чтобы его
  освобождала команда чеков, а не леджер.
- **Возвраты** — по исходному чеку с любой кассы программы, частичные, по количеству строк (в тысячных).
  Возвращается доля сохранённых эффектов строки: начисленные баллы сторнируются по политике программы
  для потраченных баллов, списанные — восстанавливаются (в исходные партии или в новую), бюджеты акций —
  возвращаются. Доли накопительные: `⌊x × возвращено_всего / количество⌋ − ⌊x × возвращено_ранее /
  количество⌋`, поэтому последний возврат отдаёт ровно остаток, и сумма всех возвратов равна исходному
  эффекту. Номер чека возврата делает возврат идемпотентным. Промокоды и использования акций
  освобождаются, когда чек возвращён полностью.
- **Покупатель.** Идентификация по телефону, карте, коду из приложения, внешнему id или id участника.
  Замороженный участник копит, но не тратит; заблокированный — ни то, ни другое. Анонимный покупатель
  ничего не получает, но расчёт показывает, сколько он получил бы как участник.
- **Время.** Покупка не может быть позже серверного времени больше чем на 5 минут и старше 7 дней —
  более старые чеки догружаются офлайн-пакетом (этап 4c).

## Дополнение (этап 4c)

- **Подтверждение списания.** Политика программы `redemption.confirmation` (часть снапшота правил):
  `none`, `weak_identifiers` (по умолчанию), `always`. Код приложения (короткоживущий, доказывает владение
  телефоном) — сильный идентификатор; телефон, карта, внешний id и всё, что введено вручную, — слабые.
  Касса узнаёт о необходимости кода из расчёта (`redemption_confirmation_required`), запрашивает код
  `POST /receipts:send-redemption-code` на число баллов расчёта (код привязан к кассе и этим баллам),
  проверяет его `POST /verifications/{id}:verify` и регистрирует чек с `redemption_confirmation`.
  Проверка кода — отдельный вызов вне транзакции: иначе откат неудачной регистрации стирал бы и счётчик
  попыток, и перебор кода стал бы бесконечным. Регистрация только потребляет подтверждённый код.
- **Антифрод.** Списание приостанавливается на 24 часа после смены телефона и после 10 чеков со
  списанием за сутки (настройки `processing.antifraud`); причина видна в объяснении расчёта.
- **Офлайн-пакеты.** `POST /receipts:batch` принимает до 100 чеков, проданных без связи (до 30 дней),
  и ставит задачу в очередь `offline`; `GET /receipt-batches/{id}` отдаёт результат по каждому чеку
  (`created`, `exists`, `failed` с проблемой). Офлайн-чек только начисляет баллы — по правилам момента
  покупки без скидочных акций, которых касса не могла применить; списание и промокоды офлайн запрещены.

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

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