# Ядро SaaS-платформы лояльности: ledger баллов, чековый флоу, движок правил, мультиарендность, события и аналитика

> Состояние на сентябрь 2026. Где факт взят только из фрагмента поисковой выдачи, а сама страница при проверке не открылась, это помечено отдельно. Схемы и рекомендации в разделах Inferences составлены мной на основе приведённых фактов. Это проектные предложения, а не цитаты из источников.

---

## 1. Ledger баллов: double-entry или single-entry, неизменяемые проводки, счета, материализованный баланс, лоты сгорания, холды, сторно, отрицательный баланс, аудит

### Takeaway
Баллы лучше вести как отдельную «валюту» в неизменяемом (append-only) double-entry ledger. Счёт участника устроен как пассив (credit-normal). Баланс материализуется в той же транзакции, что и проводки, и сверяется с суммой проводок. Для сгорания нужны лоты (buckets) с явной аллокацией списаний. Задержанная активация и резерв на кассе делаются через двухфазные pending-проводки (pending → post/void/timeout). Части этой модели (append-only double-entry, материализованные балансы, pending/posted) в разной мере реализованы в Modern Treasury, Square Books, Stripe Ledger и TigerBeetle. Лоты — моё проектное дополнение для задачи сгорания. Open Loyalty и Talon.One дают ту же функциональность на уровне продукта: pending, blocked, expired, отрицательный баланс.

### Cited Findings

**Основы double-entry (Modern Treasury, «Accounting for developers» и «How to scale a ledger»)**
- Счёт (account) — это обособленный пул стоимости. Транзакция состоит минимум из двух проводок (entries), каждая относится к одному счёту и имеет направление debit или credit — [Modern Treasury, Accounting for Developers I](https://www.moderntreasury.com/journal/accounting-for-developers-part-i)
- Счета бывают debit-normal (активы, расходы: растут по дебету) и credit-normal (обязательства, капитал, выручка: растут по кредиту). Сумма балансов credit-normal всегда равна сумме балансов debit-normal. Нарушение равенства означает, что деньги «созданы» или «уничтожены» — [Modern Treasury, Accounting for Developers I](https://www.moderntreasury.com/journal/accounting-for-developers-part-i)
- Масштабируемый ledger по Modern Treasury держится на трёх сущностях (Accounts, Transactions, Entries) и четырёх гарантиях: неизменяемость (любое прошлое состояние можно восстановить), принудительный double-entry (API не даёт двигать деньги без источника и получателя), контроль конкурентности (нет double-spend даже при параллельной и неупорядоченной записи), быстрые агрегаты — [Modern Treasury, How to Scale a Ledger I](https://www.moderntreasury.com/journal/how-to-scale-a-ledger-part-i)
- Modern Treasury держит у счёта отдельные pending_balance и posted_balance. Для конкурентной записи используется optimistic locking: клиент передаёт в каждой entry `lock_version` счёта, при несовпадении транзакция БД откатывается и запрос отклоняется. Pessimistic locking отвергли, потому что чтений в ledger значительно больше, чем записей, а блокировки заставляли бы чтения и записи ждать друг друга — [Modern Treasury, Designing Ledgers with Optimistic Locking](https://www.moderntreasury.com/journal/designing-ledgers-with-optimistic-locking)

**Промышленные ledger-системы**
- Square Books: сущности books (счета), journal entries и book entries. Кроме таблицы books, где текущий баланс обновляется при каждой операции, в схеме только INSERT, без UPDATE. Ошибки исправляются новой корректирующей записью. Сумма выплаты мерчанту читается одной строкой без агрегаций. Хранилище — Google Cloud Spanner. Команда из трёх человек обслуживает около 20 ТБ — [Square, Books: an immutable double-entry accounting database service](https://developer.squareup.com/blog/books-an-immutable-double-entry-accounting-database-service/)
- Stripe Ledger: опубликованные транзакции нельзя удалить или изменить, любое прошлое состояние восстанавливается проигрыванием событий. Модель double-entry. Транзитные (clearing) счета в норме обнуляются, и ненулевой остаток на них сигнализирует об аномалии. Качество данных проверяется трижды: clearing, timeliness и completeness (у каждого ID из системы-источника есть событие в Ledger). Объём — 5 млрд событий в сутки. 99.99% долларового объёма загружается и сверяется за 4 дня, объяснимость движения денег выше 99.9999% — [Stripe, Ledger](https://stripe.dev/blog/ledger-stripe-system-for-tracking-and-validating-money-movement)
- Uber LedgerStore — неизменяемое хранилище финансовых записей с проверяемыми полнотой и корректностью. В нём 2 трлн уникальных индексов, за 6+ месяцев в продакшене не найдено ни одной несогласованности. Индексы двух видов. Strongly consistent строятся через 2PC (intent пишется раньше записи) и нужны там, где задержка видимости грозит двойным списанием, например в авторизации карты. Eventually consistent строятся асинхронно через materialized views и годятся, например, для истории платежей. Экономия от консолидации — 6 млн долларов в год — [Uber, How LedgerStore Supports Trillions of Indexes](https://www.uber.com/blog/how-ledgerstore-supports-trillions-of-indexes/)
- Shopify советует регулярно сверять свои записи с данными партнёров и заводить расхождения как аномалии для расследования — [Shopify Engineering, 10 Tips for Building Resilient Payment Systems](https://shopify.engineering/building-resilient-payment-systems)

**Модель TigerBeetle (удобный эталон семантики)**
- Счета бывают debit-balance (balance = debits − credits, активы и расходы) и credit-balance (balance = credits − debits, обязательства, капитал, доход). Неотрицательность credit-balance счёта задаётся флагом `flags.debits_must_not_exceed_credits`, debit-balance счёта — флагом `flags.credits_must_not_exceed_debits` — [TigerBeetle, Data Modeling](https://docs.tigerbeetle.com/coding/data-modeling/)
- Поле `ledger` разделяет счета по валюте или типу актива, и переводы возможны только между счетами одного ledger. Поле `code` хранит причину: тип счёта или тип перевода (покупка, возврат). Поля `user_data_128/64/32` ссылаются на внешние сущности, причём `user_data_64` можно использовать как альтернативный timestamp для битемпоральности. ID рекомендуется делать time-based и сортируемыми (48 бит миллисекунд + 80 бит случайности), и они же служат ключом идемпотентности. Asset scale нельзя легко поменять, потому что счета и переводы неизменяемы — [TigerBeetle, Data Modeling](https://docs.tigerbeetle.com/coding/data-modeling/)
- Two-phase transfers. Pending-перевод (`flags.pending`) резервирует сумму в `debits_pending`/`credits_pending` и не трогает `*_posted`. Затем `post_pending_transfer` проводит сумму целиком или частично (остаток возвращается), `void_pending_transfer` возвращает всё, а `timeout` в секундах автоматически освобождает резерв. Проверка инвариантов пессимистична: pending отклоняется сразу, если может нарушить флаги баланса. Разрешение — отдельный неизменяемый перевод со ссылкой `pending_id` — [TigerBeetle, Two-Phase Transfers](https://docs.tigerbeetle.com/coding/two-phase-transfers/)

**Ledger на PostgreSQL (pgledger)**
- pgledger состоит из таблиц `pgledger_accounts` (с `balance` и `version`), `pgledger_transfers` и `pgledger_entries`. В entries есть `account_previous_balance`, `account_current_balance`, `created_at` и `event_at` (бизнес-время). ID — ULID с префиксами. API реализован SQL-функциями `pgledger_create_account` и `pgledger_create_transfer(s)` — [GitHub pgr0ss/pgledger](https://github.com/pgr0ss/pgledger)
- Автор называет главные риски: конкурентные переводы, уводящие счёт в минус, и затирающие друг друга обновления баланса. Приложению достаточно вызывать SQL-функции внутри своей транзакции — [pgrs.net, pgledger](https://www.pgrs.net/2025/03/24/pgledger-ledger-implementation-in-postgresql/)
- Замер на M3 MacBook Air с PostgreSQL 17: 10 636.8 переводов/с (1.9 мс, 743 байта на перевод) при 50 счетах и 20 воркерах; 7 558.9 переводов/с (2.6 мс) при 10 счетах, то есть при высокой конкуренции за строки — [GitHub pgr0ss/pgledger](https://github.com/pgr0ss/pgledger)

**Кошельки, лоты и статусы в продуктах лояльности**
- Open Loyalty. У типа кошелька настраиваются название единиц и pending («No pending» или «Pending for X days»). Методы сгорания: No expiration, After X days, At the end of the month, At the end of the X-th year, Annual expiration on a chosen date. Pending продлевает срок при «After X days» и не меняет дату при сгорании на конец месяца или года. Есть лимиты «Global units limitation» и «Member units limitation», а также опция отрицательного баланса, при которой участник «занимает» единицы у бизнеса — [Open Loyalty, Wallet Types and Configuration](https://help.openloyalty.io/members-and-activity/wallets/wallet-types-and-configuration.md)
- Поля unit transfer в Open Loyalty: `transferId`, `tenantId`, `points`, `memberId`, `walletId`, `walletTypeCode`, `type`, `createdAt`, `expiredAt`, `cancelled`, `cancelledAt`, `locked` (pending), `unlockAt`, `campaignId`, `transactionId`, `customEventId`, `internalEventName`, `performedAt` (бизнес-дата) и `updatedAt`. Типы: `adding`, `spending`, `expired`, `blocked`, `p2p_adding`, `p2p_spending` — [Open Loyalty, Unit Transfers data structure](https://help.openloyalty.io/technical-guide/data-exports/data-structure-and-types/unit-transfers.md)
- В Open Loyalty `blocked` означает зарезервированные единицы. Pending-перевод не входит в доступный баланс до активации, активировать его можно и вручную. Отмена перевода возможна, только если у участника ещё хватает свободных единиц. Перевод можно досрочно пометить как сгоревший — [Open Loyalty, Managing Unit Transfers](https://help.openloyalty.io/members-and-activity/wallets/unit-transfers/managing-unit-transfers.md)
- Talon.One. Баллы активируются при закрытии сессии или через заданный интервал после неё. По умолчанию при откате (отмене или частичном возврате) можно откатить только pending-баллы. Для активных нужно включить «Active points deduction», а отрицательный баланс после откатов и ручных списаний включается отдельной настройкой «Negative balance». Уровней (tiers) до 20. Есть subledgers. API name, часовой пояс и окружение программы после создания менять нельзя — [Talon.One, Manage loyalty programs](https://docs.talon.one/docs/product/loyalty-programs/manage-loyalty-programs)
- Спортмастер (Habr, 2019). В новой системе от физического сжигания бонусов отказались: бонусы хранятся, но в какой-то момент перестают считаться активными. Списание идёт по приоритетам: сначала специфичные бонусы (товарные, на день рождения), потом регулярные — [Habr, Как мы делали клубную программу Спортмастера](https://habr.com/ru/companies/sportmaster_lab/articles/453252/)
- Коды ошибок процессинга Manzana Loyalty. Оплата баллами запрещена при отрицательном балансе карты. Есть отдельные ошибки на нехватку доступных баллов и на неверный «вид учёта» (1 — дебет, 2 — кредит) при загрузке и коррекции бонусов. Тип операции — начисление или списание — [Manzana Loyalty, Руководство по техническому обслуживанию (PDF)](https://manzanagroup.ru/upload/iblock/cf7/manzana_loyalty_tehnicheskoe_obsluzhivanie.pdf)

### Inferences
- **Double-entry оправдан даже для баллов.** В SaaS есть несколько источников финансирования баллов (мерчант, партнёр, коалиция) и несколько «стоков» (оплата покупок, сгорание, перевод P2P, конвертация). Контрольное равенство дебетов и кредитов плюс clearing-счета по образцу Stripe дают автоматическую сверку, которой single-entry (история изменений баланса участника) не даёт. Single-entry (одна строка «+100 / −50» на участника) допустим только как внутренний кэш или проекция.
- **План счетов на тип баллов** (ledger по TigerBeetle: одна программа × один тип баллов):
  - `member:{id}:available` и `member:{id}:pending` — credit-normal, обязательства программы перед участником. Для них действует `allow_negative = false`, если политика типа баллов не разрешает долг.
  - `issuance:{funding_source}` — debit-normal (маркетинговый расход или дебиторка мерчанта). В коалиции это отдельный счёт на каждого мерчанта-спонсора, по нему идут взаиморасчёты.
  - `redemption:{merchant}` — оплата баллами, нужен для settlement с мерчантом.
  - `breakage` (сгорание), `adjustment` (ручные корректировки), `clearing:hold`.
  - Начисление с отложенной активацией: D `issuance` / C `member:pending`. Активация: D `member:pending` / C `member:available`. Оплата: D `member:available` / C `redemption:{merchant}`. Сгорание: D `member:available` / C `breakage`. Сторно — обратная проводка со ссылкой `reverses_id`.
- **Баланс хранить материализованным** в `account_balances` (posted, pending_debits, pending_credits, version) и обновлять в той же транзакции БД, что и вставку entries (как books в Square и accounts в pgledger). Доступный остаток: available = posted − pending_debits, по семантике TigerBeetle. Ночной reconciliation-джоб сверяет `SUM(entries)` с материализованным балансом по каждому счёту и проверяет обнуление clearing-счетов. Это стоит выставить внутренним SLO: число расхождений равно нулю.
- **Лоты (buckets) для сгорания.** Каждое начисление создаёт лот с `amount_initial`, `amount_remaining`, `activates_at` и `expires_at`. Каждое списание, резерв или сгорание пишет строки `lot_allocations(transaction_id, lot_id, amount)`. Порядок потребления по умолчанию: сначала лоты с ближайшим `expires_at` (NULLS LAST), затем по `earned_at`, а сверху — приоритеты типов, как у Спортмастера (специфичные бонусы раньше регулярных). Сгорание — это не удаление: джоб создаёт проводку expiration на `amount_remaining` лота. Сторно списания возвращает единицы в те же лоты по записанной аллокации. Лот, срок которого уже прошёл, при сторно сразу сгорает отдельной проводкой.
- **Холды на кассе** — pending-транзакция на `member:available` с `expires_at` (аналог `timeout` в TigerBeetle). Затем capture полностью или частично (частичный возврат остатка), либо void, либо автоматическое освобождение. Проверку «хватает ли баллов» делать пессимистично, учитывая pending_debits.
- **Отрицательный баланс** нужен как явная политика типа баллов (как у Open Loyalty и Talon.One), а не как побочный эффект. Типичный случай: возврат товара, за который начисленные баллы уже потрачены. Варианты: а) разрешить долг и гасить его будущими начислениями; б) списать с других лотов; в) удержать эквивалент деньгами из суммы возврата на кассе. Manzana, например, запрещает оплату баллами при отрицательном балансе, и этот гейт полезно повторить.
- **Время.** Хранить `effective_at`/`performed_at` (бизнес-время чека) отдельно от `created_at` (системное время) — так сделано в `performedAt` у Open Loyalty, `event_at` у pgledger и `user_data_64` у TigerBeetle. Сгорание и активацию считать в часовом поясе программы. У Talon.One таймзона программы после создания неизменяема, и это разумное ограничение.
- **Масштаб точности.** Баллы хранить целыми (`bigint`) в минимальных единицах. Scale (например, 0 или 2 знака) выбрать при создании типа баллов и не менять: у TigerBeetle asset scale тоже практически неизменяем.

**Пример схемы (PostgreSQL, пул-модель, все ключи начинаются с tenant_id):**
```sql
CREATE TABLE point_types (
  tenant_id uuid NOT NULL, id uuid NOT NULL, program_id uuid NOT NULL,
  code text NOT NULL, scale smallint NOT NULL DEFAULT 0,          -- неизменяемо после создания
  activation_policy jsonb NOT NULL,   -- {"type":"relative","delay":"P14D"} | {"type":"immediate"}
  expiration_policy jsonb NOT NULL,   -- {"type":"after","period":"P365D"} | {"type":"end_of_month"} | {"type":"never"}
  allow_negative boolean NOT NULL DEFAULT false,
  PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, program_id, code));

CREATE TABLE ledger_accounts (
  tenant_id uuid NOT NULL, id uuid NOT NULL,              -- UUIDv7/ULID
  point_type_id uuid NOT NULL,
  owner_type text NOT NULL CHECK (owner_type IN ('member','system','merchant')),
  owner_id uuid,
  purpose text NOT NULL,          -- available|pending|issuance|redemption|breakage|adjustment|clearing_hold
  normal_balance char(1) NOT NULL CHECK (normal_balance IN ('D','C')),
  allow_negative boolean NOT NULL DEFAULT false,
  PRIMARY KEY (tenant_id, id),
  UNIQUE (tenant_id, point_type_id, owner_type, owner_id, purpose));

CREATE TABLE ledger_transactions (
  tenant_id uuid NOT NULL, id uuid NOT NULL,
  kind text NOT NULL,   -- accrual|activation|redemption|hold|hold_capture|hold_void|hold_expire|expiration|reversal|adjustment|p2p
  status text NOT NULL CHECK (status IN ('pending','posted','voided','expired')),
  source_type text, source_id uuid,          -- receipt / return / campaign / manual / import
  reverses_id uuid, pending_id uuid,         -- сторно и разрешение pending
  effective_at timestamptz NOT NULL,         -- бизнес-время
  created_at timestamptz NOT NULL DEFAULT now(),
  idempotency_key text, created_by text, reason_code text, metadata jsonb,
  PRIMARY KEY (tenant_id, id), UNIQUE (tenant_id, idempotency_key));

CREATE TABLE ledger_entries (                -- только INSERT
  tenant_id uuid NOT NULL, id bigint GENERATED ALWAYS AS IDENTITY,
  transaction_id uuid NOT NULL, account_id uuid NOT NULL,
  direction char(1) NOT NULL CHECK (direction IN ('D','C')),
  amount bigint NOT NULL CHECK (amount > 0),
  lot_id uuid, account_version bigint NOT NULL, balance_after bigint NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, created_at, id)) PARTITION BY RANGE (created_at);

CREATE TABLE account_balances (
  tenant_id uuid NOT NULL, account_id uuid NOT NULL,
  posted bigint NOT NULL DEFAULT 0, pending_debits bigint NOT NULL DEFAULT 0,
  pending_credits bigint NOT NULL DEFAULT 0, version bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, account_id));

CREATE TABLE point_lots (
  tenant_id uuid NOT NULL, id uuid NOT NULL, account_id uuid NOT NULL,
  origin_transaction_id uuid NOT NULL, priority smallint NOT NULL DEFAULT 100,
  amount_initial bigint NOT NULL, amount_remaining bigint NOT NULL CHECK (amount_remaining >= 0),
  earned_at timestamptz NOT NULL, activates_at timestamptz, expires_at timestamptz,
  status text NOT NULL,    -- pending|active|exhausted|expired|reversed
  PRIMARY KEY (tenant_id, id));
CREATE INDEX lots_fifo ON point_lots (tenant_id, account_id, priority, expires_at NULLS LAST, earned_at)
  WHERE status = 'active' AND amount_remaining > 0;

CREATE TABLE lot_allocations (tenant_id uuid, transaction_id uuid, lot_id uuid, amount bigint NOT NULL,
  PRIMARY KEY (tenant_id, transaction_id, lot_id));
```
Инвариант SUM(D) = SUM(C) на `transaction_id` проверяет единственная функция постинга (SQL-функция по образцу pgledger или доменный сервис). Прямой INSERT в `ledger_entries` у прикладной роли запрещён через GRANT.

### Gaps
- Статью Airbnb про идемпотентность платежей и библиотеку Orpheus (medium.com/airbnb-engineering) проверить не удалось: WebFetch получил 403. Статьи Airbnb по ledger в заметки не включены.
- Ни Open Loyalty, ни Talon.One в проверенных страницах явно не документируют порядок потребления баллов (FIFO по сроку сгорания). Рекомендация FIFO по `expires_at` — моё проектное предложение, которое подкреплено только логикой приоритетов Спортмастера.
- Официального PHP-клиента TigerBeetle в проверенных источниках не нашёл. Поиск по этому вопросу не выполнялся: исчерпан бюджет поиска.
- Бухгалтерский аспект (баллы как обязательство и отложенная выручка по IFRS 15/ФСБУ) в первичных источниках не проверен.

---

## 2. Консистентность и конкурентность: защита от double-spend, idempotency keys, transactional outbox, exactly-once с очередями

### Takeaway
Двойное списание надёжно предотвращают короткие транзакции PostgreSQL. На уровне Read Committed это `SELECT … FOR UPDATE` строк баланса в фиксированном порядке либо условный `UPDATE … WHERE available >= :x`. Альтернативы — optimistic locking по версии счёта или SERIALIZABLE с обязательным retry на SQLSTATE 40001. Идемпотентность API строится на таблице idempotency keys по образцу Stripe и Brandur Leach (fingerprint запроса, сохранённый ответ, 409 на параллельный повтор). События публикуются только через transactional outbox. «Exactly-once» на практике означает at-least-once доставку плюс идемпотентного консьюмера.

### Cited Findings

**Уровни изоляции и блокировки PostgreSQL**
- По умолчанию в PostgreSQL используется Read Committed. На Repeatable Read конкурентное обновление той же строки даёт ошибку «could not serialize access due to concurrent update», и транзакцию нужно повторить целиком — [PostgreSQL, Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)
- Serializable реализован как Serializable Snapshot Isolation. Приложению нужен общий механизм повтора при ошибках сериализации: они всегда приходят с SQLSTATE 40001. Документация рекомендует помечать транзакции READ ONLY, ограничивать число активных соединений пулом, не класть в транзакцию лишнего и не держать соединения в состоянии «idle in transaction» (для этого есть `idle_in_transaction_session_timeout`) — [PostgreSQL, Transaction Isolation](https://www.postgresql.org/docs/current/transaction-iso.html)
- `FOR UPDATE` блокирует выбранные строки от блокировки, изменения и удаления другими транзакциями до конца текущей. В REPEATABLE READ и SERIALIZABLE попытка заблокировать строку, изменённую после старта транзакции, вызывает ошибку. PostgreSQL сам находит deadlock и откатывает одну из транзакций, но лучшая защита — брать блокировки на несколько объектов всегда в одном порядке — [PostgreSQL, Explicit Locking](https://www.postgresql.org/docs/current/explicit-locking.html)
- Advisory locks — блокировки со смыслом, заданным приложением. Session-level блокировка не подчиняется транзакционной семантике и переживает ROLLBACK. Transaction-level (`pg_advisory_xact_lock`) снимается автоматически в конце транзакции. Advisory locks живут в общей памяти (`max_locks_per_transaction` × `max_connections`), поэтому их одновременно может быть порядка десятков или сотен тысяч — [PostgreSQL, Explicit Locking](https://www.postgresql.org/docs/current/explicit-locking.html)
- Optimistic locking по версии счёта (`lock_version`) в Modern Treasury: при несовпадении версии транзакция откатывается — [Modern Treasury](https://www.moderntreasury.com/journal/designing-ledgers-with-optimistic-locking)

**Idempotency keys**
- Stripe сохраняет код и тело ответа первого запроса с данным ключом, будь то успех или ошибка, включая 500. Ключ до 255 символов, рекомендуются UUID v4, чувствительные данные в ключ класть нельзя. Ключи можно удалять не раньше чем через 24 часа. Параметры повтора сравниваются с исходными, и при расхождении возвращается ошибка. Результат не сохраняется, если запрос не прошёл валидацию или столкнулся с параллельным запросом (такие запросы можно повторять). Ключ принимают все POST, для GET и DELETE он не нужен — [Stripe API, Idempotent requests](https://docs.stripe.com/api/idempotent_requests)
- Реализация Brandur Leach на Postgres. Таблица `idempotency_keys`: `idempotency_key` (до 100 символов), `locked_at`, `request_method`, `request_params`, `request_path`, `response_code`, `response_body`, `recovery_point` (до 50 символов), `user_id`; уникальный индекс `(user_id, idempotency_key)`. Recovery points (`started` … `finished`) разбивают запрос на atomic phases между внешними побочными эффектами. Параллельный запрос с тем же ключом получает 409 Conflict, запрос с теми же ключом и другими параметрами тоже отклоняется. Reaper удаляет ключи примерно через 72 часа. Фоновые задачи пишутся в `staged_jobs` в той же транзакции и уходят в очередь после коммита. Atomic phases выполняются в SERIALIZABLE — [brandur.org, Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys)
- IETF-черновик заголовка `Idempotency-Key` предписывает: 400 при отсутствии обязательного ключа, 422 при повторе ключа с другим payload, 409 при повторе, пока исходный запрос ещё выполняется. Fingerprint запроса опционален, политику истечения ключей нужно документировать, в примерах ошибок используется problem+json — [draft-ietf-httpapi-idempotency-key-header-07](https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html)
- Статус черновика: версия 07 от 15.10.2025, срок действия истёк, RFC он не стал — [IETF Datatracker](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
- Shopify: ULID вместо UUID в качестве idempotency keys дали примерно на 50% более быстрые вставки в БД — [Shopify Engineering](https://shopify.engineering/building-resilient-payment-systems)
- В TigerBeetle идемпотентность обеспечивается ID перевода: повтор запроса с тем же ID не исполняется второй раз — [TigerBeetle, Data Modeling](https://docs.tigerbeetle.com/coding/data-modeling/)

**Outbox, CDC и идемпотентный консьюмер**
- Transactional outbox: сообщения пишутся в таблицу outbox в той же транзакции, что и бизнес-данные, а отдельный relay (polling publisher или transaction log tailing) отправляет их брокеру. Порядок отправки сохраняется, но relay может опубликовать сообщение повторно, поэтому консьюмеры должны быть идемпотентны — [microservices.io, Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html)
- Debezium Outbox Event Router ожидает колонки `id`, `aggregatetype` (определяет топик), `aggregateid` (становится ключом сообщения Kafka, что сохраняет порядок внутри партиции), `type` и `payload`. Outbox-таблица работает как очередь только на INSERT — [Debezium, Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html)
- Idempotent consumer: ID обработанных сообщений пишутся в `PROCESSED_MESSAGES` с PK `(subscriberId, messageID)` в той же транзакции, что и бизнес-изменения. На дубликате INSERT падает, и сообщение отбрасывается — [microservices.io, Idempotent Consumer](https://microservices.io/patterns/communication-style/idempotent-consumer.html)
- Debezium PostgreSQL connector даёт at-least-once: после рестарта возможны дубликаты. Пока connector остановлен, replication slot не даёт удалять WAL, и диск растёт. Для малоактивных таблиц рекомендуют `heartbeat.interval.ms` — [Debezium, PostgreSQL connector](https://debezium.io/documentation/reference/stable/connectors/postgresql.html)

### Inferences
- **Рекомендуемый путь списания на Read Committed** (быстрый, без retry-штормов):
  1. `BEGIN`;
  2. `SELECT … FROM account_balances WHERE (tenant_id, account_id) IN (…) ORDER BY account_id FOR UPDATE` — всегда в порядке `account_id`, чтобы исключить deadlock;
  3. проверить доступный остаток с учётом pending, выбрать лоты FIFO (`FOR UPDATE` по тем же лотам, тоже в порядке id);
  4. INSERT транзакции, entries и аллокаций; UPDATE балансов и лотов; INSERT в outbox;
  5. `COMMIT`.
  Альтернатива без явной блокировки: `UPDATE account_balances SET posted = posted - :x, version = version + 1 WHERE … AND posted - pending_debits >= :x RETURNING …`. Ноль затронутых строк означает «недостаточно баллов».
- **SERIALIZABLE** стоит оставить для редких многосчётных операций (объединение участников, P2P, массовые корректировки) с обязательным retry-циклом на 40001. **Advisory xact locks** подходят для сериализации по «участнику целиком», например `pg_advisory_xact_lock(hashtext(tenant_id || member_id))`, когда операция затрагивает много таблиц.
- **Горячие системные счета** (issuance, breakage мерчанта) — главный источник конкуренции. Замер pgledger показывает падение пропускной способности примерно на 30% при сужении набора счетов с 50 до 10. Поэтому материализованный баланс системных счетов не стоит обновлять синхронно: достаточно entries, а баланс вычислять асинхронно или держать через шардированные подсчета (N субсчетов и сумма при чтении).
- **Область ключа идемпотентности**: `(tenant_id, credential_id, key)`. Хранить SHA-256 fingerprint метода, пути и тела. TTL не меньше 24 часов, а для кассовых сценариев не меньше максимального окна офлайна. Для чеков дополнительно нужен **постоянный естественный ключ** `UNIQUE (tenant_id, terminal_id, business_date, receipt_no)`: такое правило есть в процессинге Manzana (см. раздел 3). Он защищает от дублей и тогда, когда касса повторила чек с новым idempotency key.
- **Outbox → брокер/вебхуки**: партиционировать по `member_id` (или `aggregate_id`), чтобы события одного участника шли по порядку. Консьюмеры (начисление по событиям, вебхуки, аналитика) идемпотентны через `processed_messages`.

```sql
CREATE TABLE idempotency_keys (
  tenant_id uuid NOT NULL, credential_id uuid NOT NULL, key text NOT NULL CHECK (length(key) <= 255),
  request_fingerprint bytea NOT NULL, locked_at timestamptz,
  recovery_point text NOT NULL DEFAULT 'started',
  response_code int, response_body jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, credential_id, key));

CREATE TABLE outbox_events (
  id uuid PRIMARY KEY,                   -- UUIDv7: сортируемый
  tenant_id uuid NOT NULL,
  aggregate_type text NOT NULL, aggregate_id text NOT NULL,   -- ключ партиции (Debezium aggregateid)
  type text NOT NULL, schema_version int NOT NULL DEFAULT 1,
  payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now());

CREATE TABLE processed_messages (consumer text NOT NULL, message_id uuid NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (consumer, message_id));
```

### Gaps
- Работу Airbnb по идемпотентности (Orpheus) не удалось прочитать (403). Их опыт в заметки не вошёл.
- Публичных бенчмарков «FOR UPDATE против optimistic против SERIALIZABLE» именно для ledger баллов не нашёл. Выбор выше основан на документации PostgreSQL и опыте Modern Treasury.

---

## 3. Моделирование чекового флоу: calculate → reserve/hold → confirm → cancel/refund, авто-освобождение холдов, поздние офлайн-чеки, рассинхрон часов, версии чеков и повторы

### Takeaway
Индустриальный стандарт — двух- или трёхшаговый протокол. Сначала stateless-расчёт без побочных эффектов (Manzana: soft-чек, Voucherify: validate, Talon.One: открытая сессия). Затем фиксация (fiscal-чек, redeem, закрытие сессии), опционально с промежуточным резервом баллов с TTL. Отмена и частичный возврат откатывают ранее применённые эффекты построчно. Офлайн-чеки принимаются асинхронной загрузкой и обрабатываются по расписанию с журналом ошибок. Ключевые данные — естественный ключ чека (касса, дата, номер) и бизнес-время чека.

### Cited Findings
- Manzana/CSI (по фрагменту выдачи, страница портала при проверке показала только навигацию). Касса сначала отправляет soft-чек, затем идентичный fiscal. Soft-чек предварительный: он проверяет параметры и возвращает, например, число баллов, в системе не регистрируется, и касса может запрашивать его для одной покупки многократно. Продажа регистрируется только по fiscal-чеку — [CSI Support, Manzana Loyalty](https://crystals.atlassian.net/wiki/spaces/SR10SUPPORT/pages/439255831/Manzana+Loyalty)
- Офлайн-чеки в Manzana Loyalty сначала попадают в `loyalty.rawcheque` (шапка), `loyalty.rawchequeitem` (позиции) и `loyalty.rawchequepayment` (платежи), затем обрабатываются по расписанию и удаляются. При ошибке у чека остаются код, сообщение и время последней обработки, и при следующем запуске он обрабатывается снова. Типичные причины ошибок — проблемы мастер-данных. Неправильно настроенное расписание офлайн-обработки может мешать онлайн-транзакциям — [Manzana Loyalty, Руководство по ТО (PDF)](https://manzanagroup.ru/upload/iblock/cf7/manzana_loyalty_tehnicheskoe_obsluzhivanie.pdf)
- В кодах ошибок Manzana видны инварианты чекового домена [(PDF)](https://manzanagroup.ru/upload/iblock/cf7/manzana_loyalty_tehnicheskoe_obsluzhivanie.pdf):
  - один номер чека с одного POS за один день допустим лишь однажды;
  - отменить транзакцию можно только с того POS и по той карте, что и исходный чек;
  - в возврате нельзя вернуть больше, чем куплено, или вернуть позицию повторно;
  - позиции возврата должны соответствовать позициям исходного чека, сумма возврата не может превышать сумму покупки;
  - в возвратном чеке нельзя указывать оплату бонусами.
- Loymax (по фрагменту выдачи, страница при проверке вернула 404): решение интегрировано примерно с пятнадцатью кассовыми ПО и работает в онлайн- и офлайн-режиме, сохраняя преференции клиента при потере связи кассы с процессингом — [retail-loyalty.org, Платформа Loymax](https://retail-loyalty.org/lr/long/)
- Talon.One, состояния customer session: `open`, `closed`, `cancelled`, `partially_returned`. Допустимые переходы: open→closed, open→cancelled, closed→cancelled, closed→partially_returned, closed→open (reopen), partially_returned→cancelled. При закрытии погашаются купоны, обновляются баллы, сессия попадает в аналитику. При отмене rollback-эффекты отменяют ранее применённые эффекты, а влияние на бюджеты кампаний откатывается (кроме созданных купонов). Обновления атрибутов не откатываются. При частичном возврате per-item эффекты откатываются только для возвращённых позиций. Reopen откатывает то же, что и отмена. ID сессии уникален и принадлежит интеграции. Для возврата позиций есть отдельный endpoint returnCartItems — [Talon.One, Customer session entity](https://docs.talon.one/docs/dev/concepts/entities/customer-sessions)
- Voucherify: validation — серверная проверка без побочных эффектов погашения, redemption делается отдельным вызовом. В одном запросе можно передать до 30 redeemables — [Voucherify API, Validate Stackable Discounts](https://docs.voucherify.io/api-reference/validations/validate-stackable-discounts)
- TigerBeetle: pending-резерв с `timeout` в секундах автоматически освобождается, если его не провели и не отменили. Частичное проведение возвращает остаток — [TigerBeetle, Two-Phase Transfers](https://docs.tigerbeetle.com/coding/two-phase-transfers/)
- В Open Loyalty `performedAt` — бизнес-дата перевода, отдельная от `createdAt`. Резерв моделируется типом `blocked`, отложенная активация — через `locked`/`unlockAt` — [Open Loyalty, Unit Transfers](https://help.openloyalty.io/technical-guide/data-exports/data-structure-and-types/unit-transfers.md)
- Часы и порядок событий. Stripe предупреждает, что поле `created` события имеет секундную точность, поэтому по нему нельзя ни определять порядок, ни проверять дубликаты: для этого есть ID событий. Для проверки подписи по умолчанию допускается расхождение времени 5 минут, а часы серверов рекомендуется синхронизировать через NTP — [Stripe Docs, Webhooks](https://docs.stripe.com/webhooks)
- X5: процессинг принимает несколько тысяч запросов в секунду от касс, а фоновая нагрузка на API ещё существеннее — [Habr, X5 Tech: Быстро. Качественно. Недорого](https://habr.com/ru/companies/X5Tech/articles/686524/)

### Inferences
**Рекомендуемая машина состояний чека** (сущность `receipt`, у кассы и e-commerce одна и та же):
```
(нет записи) --calculate--> [ничего не сохраняем или сохраняем quote с TTL]
            --confirm(без списания)--> CONFIRMED
            --reserve(списание баллов)--> RESERVED --confirm--> CONFIRMED
                                           |--cancel/TTL--> VOIDED
CONFIRMED --return(часть позиций)--> PARTIALLY_RETURNED --return(остаток)--> RETURNED
CONFIRMED --cancel(в пределах смены/того же POS)--> CANCELLED
```
- **calculate** — чистая функция (receipt, контекст участника, снапшот правил) → (скидки и баллы по строкам, доступный максимум к списанию). Результат не пишется в ledger. Для трассировки можно вернуть `quote_id` и `ruleset_version`.
- **reserve** создаёт hold (pending-транзакцию) на сумму списания с `expires_at` (например, 15–30 минут, настраивается на тенанта). Джоб-sweeper (или ленивая проверка при чтении) переводит просроченные холды в `expired` и освобождает pending_debits.
- **confirm** (fiscal) идемпотентен по естественному ключу `(tenant, terminal, business_date, receipt_no)` и по Idempotency-Key. Он выполняет capture холда (полный или частичный), начисляет баллы в pending или available и пишет `receipt_effects` по строкам. Если confirm пришёл без reserve, списание проверяется и проводится атомарно.
- **return** ссылается на исходный чек и строки. Сторно считается от сохранённых per-line эффектов исходного чека, а не повторным расчётом по текущим правилам (так устроен откат по позициям в Talon.One). Возвращённые баллы, потраченные на этот же чек, восстанавливаются в исходные лоты. Начисленные за возвращённые строки баллы списываются, и если их уже потратили, применяется политика отрицательного баланса.
- **Поздние офлайн-чеки.** Отдельный ingestion-endpoint (batch) с журналом «сырых» чеков (`raw_receipts`: payload, статус, код ошибки, попытки), как в Manzana. Обработка асинхронная, очередь отделена от онлайн-флоу, чтобы офлайн-догрузка не мешала кассе. Правила применяются по `performed_at` чека (снапшот правил на момент покупки), проводки получают `effective_at = performed_at` и `created_at = now()`. Списание баллов в офлайне по умолчанию запрещено: баланс проверить нельзя. Если тенант его разрешает, то с лимитом и с явным риском уйти в отрицательный баланс.
- **Рассинхрон часов.** Сервер фиксирует `received_at`. `performed_at` от кассы принимается в окне [received_at − max_offline_window; received_at + допуск, например 5 минут]. За пределами окна чек помечается флагом и время обрезается. Активация и сгорание считаются от бизнес-времени в таймзоне программы.
- **Версии чека.** Каждое изменение (reserve, confirm, return) создаёт `receipt_version`, текущее состояние хранится в `receipts`. Повтор (replay) того же confirm возвращает сохранённый ответ. Если повтор пришёл с другим содержимым, возвращается 409 или 422 по черновику IETF.

```sql
CREATE TABLE receipts (
  tenant_id uuid NOT NULL, id uuid NOT NULL, program_id uuid NOT NULL, member_id uuid,
  location_id uuid NOT NULL, terminal_id uuid NOT NULL,
  receipt_no text NOT NULL, business_date date NOT NULL,
  status text NOT NULL,  -- reserved|confirmed|voided|cancelled|partially_returned|returned
  performed_at timestamptz NOT NULL, received_at timestamptz NOT NULL DEFAULT now(),
  is_offline boolean NOT NULL DEFAULT false, ruleset_snapshot_id uuid NOT NULL,
  version int NOT NULL DEFAULT 1, totals jsonb NOT NULL,
  PRIMARY KEY (tenant_id, id),
  UNIQUE (tenant_id, terminal_id, business_date, receipt_no));
CREATE TABLE receipt_lines (tenant_id uuid, receipt_id uuid, line_no int, sku text, category_path text[],
  qty numeric(12,3), unit_price bigint, amount bigint, discount bigint, net_amount bigint,
  returned_qty numeric(12,3) NOT NULL DEFAULT 0, PRIMARY KEY (tenant_id, receipt_id, line_no));
CREATE TABLE receipt_effects (tenant_id uuid, receipt_id uuid, receipt_version int, line_no int,
  campaign_version_id uuid, effect_type text, amount bigint, ledger_transaction_id uuid,
  PRIMARY KEY (tenant_id, receipt_id, receipt_version, line_no, campaign_version_id, effect_type));
CREATE TABLE holds (tenant_id uuid, id uuid, receipt_id uuid, account_id uuid, amount bigint,
  captured bigint NOT NULL DEFAULT 0, status text NOT NULL, expires_at timestamptz NOT NULL,
  pending_transaction_id uuid NOT NULL, PRIMARY KEY (tenant_id, id));
CREATE INDEX holds_expiring ON holds (expires_at) WHERE status = 'pending';
```

### Gaps
- Полного публичного описания протоколов касса ↔ процессинг у Loymax и Mindbox (методы, таймауты, поведение при таймауте) не нашёл. Поиск прекращён из-за исчерпания бюджета. Страница CSI по Manzana открылась без контента.
- Рекомендованных вендорами TTL холдов и окон офлайна (в часах или днях) в источниках нет.

---

## 4. Движок правил и акций: условия и эффекты как данные, порядок вычисления, stacking и эксклюзивность, распределение по строкам, прекомпиляция и кэш, версионирование, симуляция

### Takeaway
Правила хранят как данные: JSON-дерево условий (JsonLogic-подобное) плюс выражения для вычисляемых величин (CEL или Symfony ExpressionLanguage; Open Loyalty использует синтаксис Symfony Expression). Их компилируют один раз при публикации и вычисляют много раз. Порядок вычисления задаётся явно: дерево или группы кампаний с режимами stackable, first, best, приоритет (sortOrder), классы скидок (товар → заказ → доставка), лимиты и категории эксклюзивности. В item-scope одну единицу товара обслуживает одна кампания. Для корректных возвратов нужно хранить версию набора правил, по которой считали чек, и построчный результат.

### Cited Findings

**Языки и представления правил**
- JsonLogic: правила — JSON вида `{"operator": [values]}`, без eval, только чтение переданных данных, без записи и циклов. Одни и те же правила можно исполнять на фронтенде и бэкенде. Реализации есть для JavaScript, PHP, Python, Ruby, Go, Java, .NET и C++ — [jsonlogic.com](https://jsonlogic.com/)
- CEL: язык спроектирован быстрым, переносимым и безопасным для исполнения пользовательского кода, вычисляется за наносекунды или микросекунды. Цикл parse → check → evaluate, скомпилированное выражение переиспользуется с разными входами (compile-once/evaluate-many). Окружение объявляет доступные переменные и функции, type checker проверяет ссылки — [cel.dev, CEL overview](https://cel.dev/overview/cel-overview)
- Symfony ExpressionLanguage (PHP) прямо предназначен для правил вроде скидки, хранимой в БД и редактируемой не-разработчиками. Доступ есть только к явно переданным переменным. Поддерживаются evaluate и compile (в PHP-код). Разобранные выражения кэшируются как `ParsedExpression` или `SerializedParsedExpression`, можно подключить PSR-6 кэш (например Redis). Есть `lint()` с флагами `IGNORE_UNKNOWN_VARIABLES`/`IGNORE_UNKNOWN_FUNCTIONS` и регистрация своих функций через `ExpressionFunctionProviderInterface` — [Symfony, ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html)
- Язык выражений Open Loyalty основан на синтаксисе Symfony Expression и применяется в условиях (condition «Expressions») и эффектах кампаний — [Open Loyalty, Expressions](https://help.openloyalty.io/integrations-and-data-exchange/expressions.md)
- Примеры эффектов Open Loyalty (по фрагментам выдачи): `1 * transaction.grossValue` (1 балл за 1 у.е.) и формула с потолком 100 баллов через тернарный оператор. Ограничения: не больше 6 правил и 30 условий в кампании, в правиле должны выполниться все условия — [Open Loyalty, Earn 1 point for every $1 spent](https://help.openloyalty.io/sample-setups/sample-campaigns/1-earn-1-point-for-every-usd1-spent); [Open Loyalty, Creating Campaigns](https://help.openloyalty.io/campaigns/campaigns/campaigns-and-referral-campaigns/creating-campaigns)

**Порядок вычисления и stacking**
- Talon.One вычисляет в таком порядке [(Talon.One, Campaign evaluation)](https://docs.talon.one/docs/product/applications/evaluation-order-for-rules-and-filters):
  1. загрузка данных из каталогов;
  2. кампании одна за другой по evaluation tree;
  3. внутри кампании — cart item filters в порядке следования;
  4. затем правила в порядке следования;
  5. в правиле сначала все условия сверху вниз, потом эффекты.
  Бюджеты кампаний проверяются при применении эффектов.
- Режимы групп Talon.One: Stackable (эффекты складываются), First Campaign (применяется первая сработавшая), Highest Discount Value (применяется кампания с наибольшей скидкой). Scope группы — Session или Item, базовая группа всегда session. В item-scope каждая единица товара получает эффекты только одной кампании — [Talon.One, Manage campaign evaluation](https://docs.talon.one/docs/product/applications/manage-campaign-evaluation)
- commercetools применяет Cart Discounts по `sortOrder`. `StackingMode` бывает `Stacking` или `StopAfterThisDiscount`. Скидка на итог корзины применяется последней независимо от sortOrder. Цели: line items, custom line items, shipping, total price, multi-buy («купи X — получи Y»), pattern. Значения: relative, absolute, fixed, gift line item. Есть `requiresDiscountCode`, `validFrom`/`validUntil` и лимит числа скидок на проект — [commercetools, Cart Discounts](https://docs.commercetools.com/api/projects/cartDiscounts)
- Shopify: три класса скидок (product, order, shipping). Сначала применяются товарные скидки, затем скидки на заказ к пересчитанному подытогу, последними — на доставку. Если несколько скидок на одну позицию нельзя совместить, применяется лучшая для покупателя. На заказ можно использовать не больше 5 кодов product/order и 1 код доставки, активных автоматических скидок не больше 25 — [Shopify Help Center, Combining discounts](https://help.shopify.com/en/manual/discounts/discount-combinations)
- Voucherify, объект `stacking_rules`: `redeemables_limit` (по умолчанию 30), `applicable_redeemables_limit` (5), `applicable_exclusive_redeemables_limit` (1), `applicable_redeemables_per_category_limit` (1), `exclusive_categories` (кампания такой категории — единственная применённая), `joint_categories` (применяется всегда, вне зависимости от эксклюзивности других), `redeemables_application_mode` = `ALL`/`PARTIAL`, `redeemables_sorting_rule` = `CATEGORY_HIERARCHY`/`REQUESTED_ORDER` — [Voucherify API](https://docs.voucherify.io/api-reference/validations/validate-stackable-discounts). По фрагменту выдачи из справки Voucherify, максимум для never-stackable — 5 — [Voucherify Support, Stacking Rules](https://support.voucherify.io/article/604-stacking-rules). Противоречия, скорее всего, нет: 1 — значение по умолчанию, 5 — максимум.
- Откат по позициям в Talon.One (per-item эффекты откатываются только для возвращённых позиций) предполагает, что эффекты сохранены построчно — [Talon.One, Customer sessions](https://docs.talon.one/docs/dev/concepts/entities/customer-sessions)

### Inferences
- **Представление.** Кампания → неизменяемая `campaign_version` (JSON-определение: scope, фильтры строк, условия, эффекты, лимиты, категория stacking) → `ruleset_snapshot` программы (упорядоченное дерево групп с режимами, ссылки на конкретные версии кампаний, `active_from`/`active_to`). Чек всегда считается по снапшоту, активному на `performed_at`. ID снапшота сохраняется в чеке, построчные эффекты — в `receipt_effects`. Возвраты и отмены работают только с сохранёнными эффектами и правила не пересчитывают.
- **Язык.** Условия лучше хранить JSON-деревом (JsonLogic-совместимым): его легко строить в UI-конструкторе и валидировать. Вычисляемые значения (формулы баллов) — выражениями в песочнице: CEL (не Тьюринг-полный, вычисляется за ограниченное время) или Symfony ExpressionLanguage в PHP-стеке (путь Open Loyalty). Дополнительно ограничить размер AST, глубину и время вычисления на тенанта.
- **Прекомпиляция и кэш.** Компиляция при публикации (`lint`, type-check), артефакт кэшируется по ключу `(tenant_id, snapshot_id)` в памяти процесса (для PHP — opcache или APCu через скомпилированный PHP-код Symfony EL) с fallback в Redis. Инвалидация по событию `RulesetPublished`: снапшоты неизменяемы, поэтому инвалидировать по сути нечего, меняется только указатель «активный снапшот».
- **Порядок расчёта чека** (по совокупности Shopify, commercetools и Talon.One):
  1. нормализация строк;
  2. фильтры строк;
  3. товарные скидки (item-scope, одна кампания на единицу товара внутри эксклюзивной группы);
  4. скидки на чек с аллокацией обратно на строки;
  5. начисление баллов от net-суммы строк после скидок;
  6. списание баллов с ограничением доли оплаты;
  7. округление.
- **Stacking и эксклюзивность** как данные: у кампании есть `category`, у группы — `mode` (stackable, first, best), у программы — лимиты в стиле Voucherify (максимум применённых, максимум эксклюзивных, максимум на категорию). Отдельный класс «joint» применяется всегда, например базовое начисление по уровню.
- **Распределение по строкам.** Скидки и баллы уровня чека распределять пропорционально net-сумме строк методом наибольшего остатка, чтобы сумма долей точно равнялась целому. Хранить долю на каждой строке: без этого частичный возврат нельзя посчитать детерминированно.
- **Симуляция и тесты.** Тот же движок, режим dry-run: calculate-endpoint (аналог soft-чека Manzana и validate у Voucherify) плюс бэктест кампании на исторических чеках в OLAP до публикации. Золотые тесты: снапшот правил + чек → ожидаемые эффекты, хранятся вместе с версией кампании.

```json
{
  "campaign": "cmp_shoes_x2", "version": 7, "scope": "item",
  "stacking": {"group": "promo", "category": "category_boost", "joint": false},
  "itemFilter": {"in": [{"var": "line.category"}, ["shoes", "apparel"]]},
  "conditions": {"and": [
    {"==": [{"var": "context.dayOfWeek"}, 1]},
    {">=": [{"var": "receipt.netTotal"}, 300000]},
    {"in": ["gold", {"var": "member.tiers"}]}]},
  "effects": [{"type": "earn_points", "pointType": "bonus",
               "expr": "floor(line.netAmount / 100) * 2",
               "activation": "P14D", "expiration": "P365D"}],
  "limits": {"perMemberPerDay": 1000, "budgetPoints": 5000000}
}
```

### Gaps
- Документации вендоров, где прямо сказано, что возврат обрабатывается по версии правил на момент покупки, не нашёл. Это вывод из модели rollback-эффектов Talon.One.
- Зрелой PHP-реализации CEL в проверенных источниках не нашёл, а поиск по этому вопросу не выполнялся из-за исчерпания бюджета. В PHP-стеке практичнее Symfony ExpressionLanguage (им пользуется Open Loyalty) или JsonLogic (PHP-реализация есть).
- Страницы Open Loyalty о распределении наград по позициям (Percent value distribution) и о механике возвратов открылись без нужного содержания.

---

## 5. Мультиарендность: shared DB + tenant_id + RLS, schema-per-tenant или database-per-tenant; иерархия тенантов; шумные соседи; конфигурация, экспорт и удаление; white-label

### Takeaway
Для тысяч тенантов практически безальтернативна пул-модель: общая схема, `tenant_id` в каждом ключе, RLS как второй рубеж защиты. Schema-per-tenant и database-per-tenant, по оценке PlanetScale, вряд ли масштабируются дальше нескольких сотен тенантов из-за каталога, миграций и пулов соединений. Крупным клиентам можно давать silo (выделенную БД или шард) с тем же кодом и той же схемой. Иерархию платформа → партнёр → тенант → бренд → юрлицо → точка → терминал стоит строить по образцу Adyen и Stripe Connect. Шумных соседей гасят лимиты на тенанта и приоритизация трафика: rate limiters и load shedders Stripe, AIMD-ограничение конкурентности Mindbox.

### Cited Findings
- AWS описывает три модели. Silo — отдельная БД на тенанта: максимальная изоляция и максимальная стоимость. Bridge — схема на тенанта. Pool — общие таблицы с ключом тенанта: минимальная стоимость, но изоляцию нужно тщательно обеспечивать. Роль PostgreSQL на тенанта не масштабируется, поэтому рекомендуется политика вида `tenant_id = current_setting('app.current_tenant')::uuid`. Владелец таблицы не подчиняется RLS, пока не включён `FORCE ROW LEVEL SECURITY`. Сессионные переменные могут не работать с server-side пулом вроде PgBouncer — [AWS Database Blog, Multi-tenant data isolation with PostgreSQL RLS](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/)
- В `set_config(name, value, is_local)` при `is_local = true` значение действует только до конца текущей транзакции. `current_setting(name, missing_ok)` при `missing_ok = true` возвращает NULL вместо ошибки — [PostgreSQL, System Administration Functions](https://www.postgresql.org/docs/current/functions-admin.html)
- Правила RLS в PostgreSQL [(PostgreSQL, Row Security Policies)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html):
  - если RLS включён, а политики нет, действует default-deny;
  - суперпользователь и роли с `BYPASSRLS` обходят RLS всегда, владелец таблицы — по умолчанию;
  - политика только с USING неявно даёт такой же WITH CHECK;
  - permissive-политики объединяются через OR, restrictive — через AND;
  - проверки ссылочной целостности (unique, PK, FK) идут в обход RLS, и через них возможна утечка по covert channel;
  - выражение политики вычисляется до пользовательских условий (кроме leakproof-функций);
  - `row_security = off` не отключает RLS, а вызывает ошибку, если политика отфильтровала бы строки.
- PlanetScale: shared-schema легко масштабируется до многих тысяч тенантов, а schema-per-tenant и database-per-tenant, скорее всего, не выйдут за пределы нескольких сотен из-за роста системного каталога. Миграции в shared-schema применяются один раз, в schema-per-tenant — к каждой схеме. При database-per-tenant пулы PgBouncer считаются на базу и быстро превышают `max_connections`. От шумных соседей помогают `statement_timeout` и `idle_in_transaction_session_timeout`. Рекомендуемый подход — shared-schema — [PlanetScale, Approaches to tenancy in Postgres](https://planetscale.com/blog/approaches-to-tenancy-in-postgres)
- Citus: распределяющая колонка — `tenant_id`, все данные тенанта лежат на одном узле. PK и FK обязаны включать распределяющую колонку. Общие справочники хранятся как reference tables, реплицированные на все узлы. Крупного тенанта можно вынести на отдельный шард и узел через `isolate_tenant_to_new_shard()` и `citus_move_shard_placement()` — [Citus Docs, Multi-tenant Applications](https://docs.citusdata.com/en/stable/use_cases/multi_tenant.html)
- AWS: изоляция тенантов — отдельный механизм, не сводящийся к аутентификации и авторизации. Аутентифицированный и авторизованный пользователь всё ещё может добраться до ресурсов другого тенанта, если изоляция не обеспечена — [AWS SaaS Architecture Fundamentals, Tenant isolation](https://docs.aws.amazon.com/whitepapers/latest/saas-architecture-fundamentals/tenant-isolation.html)
- Иерархия Adyen: company account (юридическое лицо, пользователи, счёт на оплату) → merchant accounts (обработка платежей, выплаты, отчёты для сверки; здесь настраиваются методы оплаты и переопределяются правила риска уровня компании) → stores (физические точки для POS). Account groups дают сквозной доступ к отчётам и платежам нескольких merchant accounts — [Adyen Docs, Account structure](https://docs.adyen.com/account/account-structure)
- Stripe Connect: платформа вызывает API от имени подключённого аккаунта своим секретным ключом и заголовком `Stripe-Account: acct_…`. Так же работает клиентская сторона: ID аккаунта передаётся в SDK — [Stripe Docs, Making API calls for connected accounts](https://docs.stripe.com/connect/authentication)
- Stripe использует четыре механизма защиты:
  1. request rate limiter (запросы в секунду на пользователя, token bucket в Redis);
  2. concurrent requests limiter (одновременные запросы к дорогим endpoint);
  3. fleet usage load shedder: некритичный трафик сверх 80% выделенной ёмкости получает 503, критичные операции (создание платежа) идут в приоритете;
  4. worker utilization load shedder с уровнями critical, POST, GET и test mode.
  Лимитеры рекомендуют сначала запускать в dark mode и держать kill switch — [Stripe Blog, Scaling your API with rate limiters](https://stripe.com/blog/rate-limiters)
- Mindbox (мультитенантная платформа) столкнулся с тем, что асинхронные рассылки (до 8000 RPS) деградировали базу и замедляли синхронные запросы (150 RPS). Решение — адаптивный лимит конкурентности по AIMD из TCP: цель p95 ответа хранилища ≤ 200 мс, лишние запросы получают 429. Добавление инстансов при этом почти не влияет на задержку и RPS хранилища — [Habr, Mindbox: ограничиваем нагрузку на сервисы подходами из TCP](https://habr.com/ru/companies/mindbox/articles/803577/)
- Open Loyalty хранит `tenantId` в каждой записи unit transfer и выгружает данные тенанта в S3 (раздел «data exports») — [Open Loyalty, Unit Transfers data structure](https://help.openloyalty.io/technical-guide/data-exports/data-structure-and-types/unit-transfers.md). Названия единиц баллов настраиваются на тип кошелька, это элемент white-label — [Open Loyalty, Wallet Types](https://help.openloyalty.io/members-and-activity/wallets/wallet-types-and-configuration.md)

### Inferences
- **Модель данных тенантов** (предложение):
  - `platform` (мы);
  - `partner` (реселлер или встраивающий продукт: POS-вендор, e-com-платформа, банк);
  - `tenant` = организация-владелец программы, единица изоляции данных, биллинга, лимитов и экспорта;
  - `program` (программа лояльности, своя таймзона и валюта);
  - `brand`;
  - `legal_entity` (для фискальных и взаиморасчётных документов);
  - `location` (магазин);
  - `terminal` (касса).
  Партнёр получает делегированный доступ к своим тенантам (аналог `Stripe-Account` или account groups Adyen), но не к данным других. Коалиция (общие баллы нескольких тенантов) оформляется отдельным «коалиционным» тенантом-программой, в которой мерчанты-участники — `merchant`-счета взаиморасчётов, а не общий доступ к данным.
- **Пул по умолчанию**: `tenant_id` первым столбцом всех PK и FK (заодно готовность к Citus), RLS на всех тенантных таблицах с `FORCE ROW LEVEL SECURITY`. Прикладная роль — не владелец и без `BYPASSRLS`, тенант выставляется через `SET LOCAL`/`set_config('app.tenant_id', $1, true)` в начале каждой транзакции (совместимо с PgBouncer в transaction mode). Миграции идут под ролью-владельцем. Для бэкапов, CDC и админки нужна отдельная роль с `BYPASSRLS`. Главный механизм — фильтр по тенанту в репозиториях и scope в ORM, RLS служит страховкой.
- **Уровень изоляции как настройка тенанта**: реестр тенантов хранит `isolation_mode` (pool или silo) и `db_cluster`. Enterprise-тенант получает отдельный кластер с той же схемой и тем же кодом, а маршрутизация идёт на уровне пула соединений.
- **Шумные соседи.** Token bucket на `(tenant, credential)` и отдельно на тенанта. Раздельные очереди и пулы воркеров по классу трафика: касса и чек (critical) > онлайн-API > импорт и массовые операции > вебхуки и аналитика. `statement_timeout` на ролях, AIMD или concurrency limit на доступе к БД для фоновых задач. Shedding начинать с некритичного трафика, как у Stripe.
- **Конфигурация тенанта**: версионируемые настройки (JSON со схемой) на уровнях tenant, program и location с наследованием и переопределением (как merchant account у Adyen переопределяет настройки company).
- **Экспорт и удаление.** Экспорт — per-tenant выгрузка по `tenant_id` (NDJSON или Parquet в объектное хранилище, как S3-экспорт у Open Loyalty). Удаление участника по запросу субъекта ПДн — псевдонимизация PII в профиле с сохранением неизменяемых проводок ledger, где остаётся только `member_id`. PII стоит держать в отдельных таблицах.

```sql
ALTER TABLE ledger_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_entries FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON ledger_entries
  USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- в приложении, внутри каждой транзакции:
-- BEGIN; SELECT set_config('app.tenant_id', $1, true); ... COMMIT;
```

### Gaps
- Количественных бенчмарков RLS против фильтров в приложении при тысячах тенантов в первичных источниках не нашёл. Точка деградации schema-per-tenant (несколько сотен тенантов) — оценка PlanetScale, а не замер.
- Первичных источников о white-label (кастомные домены, брендирование личного кабинета и приложений) и о юридических требованиях к удалению данных (152-ФЗ, GDPR) в рамках этой задачи не собрал.

---

## 6. Событийная архитектура: доменные события, event sourcing/CQRS или ledger + outbox, доставка вебхуков

### Takeaway
Ledger с неизменяемыми проводками уже даёт главное преимущество event sourcing для баллов: полную историю и восстановимость. Остальное проще делать CRUD-моделью плюс transactional outbox. Open Loyalty применял event sourcing (Broadway, PHP) избирательно: для транзакций, баллов и изменений клиента. Фаулер и практики предупреждают о цене ES и CQRS: версионирование событий, проекции, eventual consistency в UI. Вебхуки: подпись HMAC по Standard Webhooks, at-least-once с экспоненциальными повторами до нескольких суток, без гарантии порядка, дедупликация по ID события.

### Cited Findings
- Open Loyalty реализует event sourcing через внешнюю библиотеку Broadway. Намеренно event-sourced не всё, а только чувствительные к изменениям части, где нужна полная перемотка: транзакции, баллы и изменения данных клиента. Страница помечена как документация предыдущей версии, актуальная — на help.openloyalty.io — [Open Loyalty Docs (previous version), Event Sourcing](https://docs.openloyalty.io/en/latest/developer/architecture/event_sourcing.html). Broadway — PHP-библиотека для CQRS/ES — [GitHub broadway/broadway](https://github.com/broadway/broadway)
- Сейчас в GitHub-организации OpenLoyalty видны только репозитории loyalty-blockchain (JavaScript, Hyperledger Fabric), контракты, import-generator и api-docs с обновлениями 2023 года. Публичного PHP-репозитория платформы там на момент проверки нет — [GitHub OpenLoyalty](https://github.com/OpenLoyalty)
- Фаулер: для большинства систем CQRS добавляет рискованную сложность, применять его стоит только к отдельным bounded contexts, а не ко всей системе. Оправдан он в редких сложных доменах или при большом перекосе нагрузки чтения и записи — [martinfowler.com, CQRS](https://martinfowler.com/bliki/CQRS.html)
- Практик (Chris Kiehl) о трудностях event sourcing: события быстро теряют актуальность, а их переписывание разрушает обещанный точный аудит. Каждая новая проекция удваивает код, работающий с потоком событий. Eventual consistency ломает UI: новые данные отдают 404, удалённые продолжают висеть. Команду трудно переучить. Для развязки процессов ES не нужен. По его оценке, обычная history-таблица даёт около 80% ценности ledger почти без затрат — [Chris Kiehl, Event Sourcing is Hard](https://chriskiehl.com/article/event-sourcing-is-hard)
- Standard Webhooks. Заголовки `webhook-id`, `webhook-timestamp` (unix-секунды) и `webhook-signature` (список подписей через пробел). Симметричная схема `v1` — HMAC-SHA256 с секретом 24–64 байта (префикс `whsec_`), асимметричная `v1a` — ed25519. Подписывается строка `msg_id.timestamp.payload`. Сравнивать подписи нужно за константное время, timestamp проверять на допуск (защита от replay), `webhook-id` использовать как ключ идемпотентности. Payload содержит `type`, `timestamp` и `data`. Повторы идут несколько суток с экспоненциальной задержкой, таймаут запроса — 15–30 с. Ротация секрета без простоя делается подписью сразу старым и новым ключом — [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
- Stripe повторяет доставку в live-режиме до 3 суток с экспоненциальным backoff, в sandbox — трижды за несколько часов. Вручную переотправить событие можно до 15 дней (дашборд) или до 30 дней (CLI). Порядок доставки не гарантирован, дубликаты случаются, и их нужно отсеивать по ID события. Отвечать 2xx нужно сразу, до сложной логики. При повторах подпись и timestamp генерируются заново. Событие формируется в версии API аккаунта на момент события и после этого не меняется — [Stripe Docs, Webhooks](https://docs.stripe.com/webhooks)
- Talon.One хранит историю эффектов сессии и откатывает их при отмене или возврате (rollback-эффекты), что по сути является журналом доменных событий на уровне сессии — [Talon.One, Customer sessions](https://docs.talon.one/docs/dev/concepts/entities/customer-sessions)

### Inferences
- **Выбор по умолчанию — ledger + outbox, без полного ES.** Неизменяемые `ledger_entries`, `receipt_versions` и `receipt_effects` — это и есть «события» денежной части. Их достаточно для аудита и восстановления баланса на любую дату. ES оправдан точечно, например для профиля участника с жёсткими требованиями к истории, но history-таблицы дают основное почти бесплатно.
- **Каталог доменных событий** (имена в стиле `type` Standard Webhooks):
  - `member.enrolled`, `member.updated`, `member.merged`;
  - `receipt.confirmed`, `receipt.cancelled`, `receipt.returned`;
  - `points.accrued` (pending), `points.activated`, `points.redeemed`, `points.hold_created`, `points.hold_released`, `points.expired`, `points.adjusted`, `points.expiring_soon`;
  - `tier.changed`;
  - `coupon.issued`, `coupon.redeemed`;
  - `campaign.published`, `campaign.budget_exhausted`.
  В каждом событии: `id` (UUIDv7), `tenant_id`, `occurred_at` (бизнес-время), `recorded_at`, `sequence` на участника, `schema_version`.
- **Вебхуки.** Подписки на тенанта с фильтром по типам. Доставка из outbox через отдельный пул воркеров с очередью на подписку, чтобы медленный эндпоинт одного клиента не тормозил остальных. Экспоненциальные повторы до 3 суток, автоотключение эндпоинта после N суток ошибок, журнал попыток и ручной redeliver. Порядок не гарантируется, поэтому в payload кладётся `sequence` на участника и рекомендуется thin-payload с дочиткой состояния через API.
- **Версионирование событий**: `schema_version` в outbox и upcasters на чтении. Менять смысл полей нельзя, допустимо только добавлять (по правилам совместимости Stripe, см. раздел 9).

### Gaps
- Ретроспективы Open Loyalty о результатах event sourcing (почему PHP-репозиторий больше не публичен, остался ли ES в новой версии) не нашёл. Факт отсутствия публичного репозитория не объясняет причин.
- Замеров надёжности доставки вебхуков у loyalty-вендоров (Mindbox, Loymax) не нашёл.

---

## 7. Аналитика и сегментация: OLTP PostgreSQL и OLAP ClickHouse, CDC (Debezium, PeerDB/ClickPipes) или стриминг событий, near-real-time сегменты, RFM на масштабе

### Takeaway
В ритейле принят такой путь: CDC из OLTP через WAL (Debezium → Kafka) в ClickHouse для near-real-time и BI и в батчевое хранилище (у «Магнита» — Greenplum). Для Postgres → ClickHouse есть управляемый CDC ClickPipes на базе PeerDB с синхронизацией раз в 60 секунд по умолчанию и таблицами ReplacingMergeTree. Дедупликация там отложенная, поэтому нужны FINAL или argMax. Агрегаты для RFM строятся инкрементальными materialized views. Опыт Mindbox предостерегает от попытки закрыть одной аналитической БД и отчётность, и операционные задачи.

### Cited Findings
- «Магнит» OMNI: ядро хранилища — Greenplum (метрики «сегодня за вчера» и другие батч-расчёты). ClickHouse работает в реальном времени (clickstream, журналы касс) и под BI. Основной инструмент поставки данных — Kafka + Debezium, который читает WAL бэкенд-баз. Маршруты: Kafka → ClickHouse (реальное время) и Kafka → S3 → Greenplum (батч). В «Магнит Плюс» около 70 млн пользователей — [Habr, Магнит: Платформа данных в хранилище Магнит OMNI](https://habr.com/ru/companies/magnit/articles/864472/)
- Mindbox отказался от ClickHouse: одной базой не удалось закрыть и сбор статистики для отчётов, и перенос истории переходов в другой микросервис при идентификации человека. В планах были миграция монолита на EF Core «в сторону Postgres» и аналитика, не зависящая от монолита (Datamesh) — [Habr, Mindbox: Миллиард отправок в неделю и 730 тысяч запросов в минуту](https://habr.com/ru/company/mindbox/blog/596919/)
- ClickPipes для Postgres использует логическую репликацию и публикации. Режимы: `cdc` (снапшот + поток, по умолчанию), `snapshot` и `cdc_only`. Целевые движки — MergeTree, ReplacingMergeTree или Null. В таблицы добавляются `_peerdb_synced_at`, `_peerdb_is_deleted` и `_peerdb_version`. Синхронизация идёт с интервалом 60 с по умолчанию (настраивается). Технология основана на PeerDB — [ClickHouse Docs, ClickPipes for Postgres](https://clickhouse.com/docs/integrations/clickpipes/postgres)
- ReplacingMergeTree дедуплицирует только при слияниях, которые происходят в неизвестный момент, поэтому отсутствие дублей не гарантировано. `ver` задаёт, какая строка останется, `is_deleted` допустим только вместе с `ver`. Для корректных результатов нужен `FINAL` — [ClickHouse Docs, ReplacingMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree)
- Инкрементальный materialized view в ClickHouse — триггер на INSERT, который обрабатывает только вставленный блок. Для предагрегации используются SummingMergeTree или AggregatingMergeTree (частичные агрегатные состояния). MV не срабатывают на слияния, update и delete. В JOIN изменения правой таблицы MV не запускают. GROUP BY должен согласовываться с ORDER BY целевой таблицы — [ClickHouse Docs, Incremental materialized view](https://clickhouse.com/docs/materialized-view/incremental-materialized-view)
- Риски Debezium: WAL копится, пока connector стоит; после рестарта возможны дубликаты (at-least-once); для тихих таблиц нужен heartbeat — [Debezium, PostgreSQL connector](https://debezium.io/documentation/reference/stable/connectors/postgresql.html)
- «Магнит Плюс» ежедневно формирует более 100 млн персональных предложений, держателей карт больше 80 млн — [Retail.ru, Результаты «Магнит Плюс» на платформе Manzana](https://www.retail.ru/rbc/pressreleases/rezultaty-magnit-plyus-na-platforme-loyalnosti-manzana-uvelichenie-proniknoveniya-programmy-loyalnos/). Цифра расходится с «около 70 млн пользователей» в статье на Habr выше. Вероятно, это разные метрики или даты (держатели карт и пользователи).

### Inferences
- **Путь данных** (предложение для старта):
  1. OLTP Postgres (ledger, чеки, участники);
  2. CDC через логическую репликацию: PeerDB/ClickPipes, если ClickHouse Cloud, или Debezium → Kafka/Redpanda, если self-hosted;
  3. в ClickHouse сырые таблицы ReplacingMergeTree с `ver` = LSN или версия строки;
  4. инкрементальные MV в AggregatingMergeTree по `(tenant_id, member_id)`: `maxState(performed_at)` (recency), `countState()` (frequency), `sumState(net_amount)` (monetary), разбивка по категориям и каналам.
  Доменные события из outbox идут в тот же ClickHouse отдельным потоком: это семантика («почему начислено»), тогда как CDC даёт точность состояния.
- **Сегменты** вычисляются в ClickHouse SQL по расписанию (каждые N минут) или по триггеру. Результат (членство участника в сегменте, RFM-ячейки) пишется обратно в OLTP-таблицу `member_segments(tenant_id, segment_id, member_id, valid_from)`. Касса и движок правил читают только OLTP: синхронных запросов в ClickHouse на пути чека быть не должно.
- **RFM на масштабе**: хранить квантили на тенанта (`quantilesState`) и пересчитывать ячейки ночью. Near-real-time обновлять только recency и frequency на событии `receipt.confirmed`.
- **Урок Mindbox**: отчётность (OLAP) и операционные сценарии (перенос или слияние истории при идентификации) разнести по разным хранилищам.

### Gaps
- Первичных источников с конкретными схемами RFM или сегментации на ClickHouse у российских loyalty-вендоров не нашёл: поиск остановлен исчерпанием бюджета.
- Реальных задержек CDC → сегмент у промышленных систем в источниках нет. Известен только интервал ClickPipes по умолчанию — 60 с.

---

## 8. Масштаб и надёжность: реальные нагрузки, латентность на кассе, доступность, fallback на кассе, модульный монолит или микросервисы

### Takeaway
Публичные цифры российского ритейла: X5 обрабатывает около 500 млн покупок в месяц, несколько тысяч запросов в секунду от касс, 60–70 млн клиентов и около 20 тыс. магазинов. У «Магнит Плюс» больше 80 млн держателей карт, у Mindbox до 730 тыс. запросов в минуту на всю платформу. Средний поток чеков даже у X5 (около 190/с) по силам одному хорошо спроектированному PostgreSQL. Проблемы создают пики, горячие счета и фоновая нагрузка, а не средний поток. Кассы у всех крупных вендоров умеют работать офлайн. Для маленькой команды разумно начать с модульного монолита.

### Cited Findings
- X5: около полумиллиарда покупок в месяц. Процессинг принимает несколько тысяч запросов в секунду от касс плюс ещё более существенную фоновую нагрузку на API. Стек: .NET Web API с OpenAPI 3.0, Kafka и RabbitMQ, MS SQL 2019 с планом перехода на PostgreSQL. Раскатка шла по 3500 касс в день, команда — около 30 человек. Миграцию данных сократили до недели, потому что штатные инструменты вендора не были рассчитаны на такой масштаб — [Habr, X5 Tech: Быстро. Качественно. Недорого](https://habr.com/ru/companies/X5Tech/articles/686524/)
- X5 запустила единую программу 22.07.2022. С марта по июль на новый процессинг российской разработки перевели более 60 млн клиентов. Сеть на конец июня: 18 558 «Пятёрочек» и 986 «Перекрёстков». Кэшбэк — 0.5% от суммы чека — [Интерфакс](https://www.interfax.ru/business/853374)
- Вакансия X5 Tech (в архиве с 13.01.2023): «Процессинг Лояльности» управляет баллами и скидками 20 тыс. магазинов «Пятёрочка» и «Перекрёсток» для 70 млн клиентов. Основа — кастомизированное решение Loymax. Стек: .NET Framework и .NET Core 6–7, PostgreSQL и MS SQL, RabbitMQ, Kafka, микросервисы, Docker/k8s — [hh.ru, вакансия X5 Tech](https://hh.ru/vacancy/73374573). Около 86 тыс. кассовых узлов упоминаются только во фрагменте выдачи — [New-Retail.ru](https://new-retail.ru/novosti/retail/x5_group_zapustila_edinuyu_programmu_loyalnosti8145/)
- Mindbox: 730 тыс. запросов в минуту, более 15 млрд запросов к API в ноябре 2021, больше миллиарда отправок в неделю. В R&D 89 человек (55 C#-разработчиков, 7 SRE). Команда перевела больше половины приложения на .NET Core в k8s, внедрила Cassandra и Kafka там, где это было нужно, вынесла из монолита изолированные сервисы и планировала частично вынести из него сервисы лояльности — [Habr, Mindbox](https://habr.com/ru/company/mindbox/blog/596919/)
- Внутренняя цель Mindbox — p95 ответа хранилища не больше 200 мс. Перегрузку отсекают ответом 429 через AIMD — [Habr, Mindbox](https://habr.com/ru/companies/mindbox/articles/803577/)
- Спортмастер: архитектура из трёх частей — препроцессинг (максимально быстрый ответ магазину), процессинг (расчёт бонусов по чекам) и маркетинг. Oracle Exadata. Больше 1200 магазинов, более 1 ТБ данных сконвертировано за 2 часа — [Habr, Спортмастер](https://habr.com/ru/companies/sportmaster_lab/articles/453252/)
- Manzana Loyalty исторически построена на MS SQL Server, Microsoft Dynamics CRM и IIS. Офлайн-чеки загружаются через службы интеграции и обрабатываются по расписанию. Регламент требует следить, чтобы офлайн-обработка не мешала онлайн-транзакциям — [Manzana Loyalty, Руководство по ТО (PDF)](https://manzanagroup.ru/upload/iblock/cf7/manzana_loyalty_tehnicheskoe_obsluzhivanie.pdf)
- «Магнит Плюс» (платформа Manzana): больше 80 млн держателей карт, больше 100 млн персональных предложений в день — [Retail.ru](https://www.retail.ru/rbc/pressreleases/rezultaty-magnit-plyus-na-platforme-loyalnosti-manzana-uvelichenie-proniknoveniya-programmy-loyalnos/)
- Shopify для вызовов платёжных сервисов рекомендует начинать с таймаута соединения 1 с и чтения 5 с, использовать circuit breakers (Semian) и считать ёмкость по закону Литтла. Очереди на практике начинают расти при 70–80% загрузки — [Shopify Engineering](https://shopify.engineering/building-resilient-payment-systems)
- Фаулер (MonolithFirst): почти все успешные микросервисные истории начинались с монолита, который разросся и был разделён. Микросервисы работают, только когда найдены стабильные границы, поэтому новый проект не стоит начинать с них, даже если система обещает вырасти — [martinfowler.com, MonolithFirst](https://martinfowler.com/bliki/MonolithFirst.html)
- Shopify выбрал модульный монолит: модульность без роста числа деплоимых единиц. Код (около 6000 классов) разложен по доменным компонентам с публичными API, соблюдение границ контролирует инструмент Wedge. В итоге смогли заменить налоговый движок целиком — [Shopify Engineering, Deconstructing the Monolith](https://shopify.engineering/deconstructing-monolith-designing-software-maximizes-developer-productivity)
- Stripe Ledger обрабатывает 5 млрд событий в сутки, что даёт ориентир верхней границы для ledger-подобных систем — [Stripe, Ledger](https://stripe.dev/blog/ledger-stripe-system-for-tracking-and-validating-money-movement)
- pgledger на ноутбуке с PostgreSQL 17 выдаёт около 7.5–10.6 тыс. переводов в секунду — [GitHub pgledger](https://github.com/pgr0ss/pgledger)

### Inferences
- **Порядок величин.** 500 млн покупок в месяц у X5 — это в среднем около 193 покупок в секунду (500 000 000 / 2 592 000 с). «Несколько тысяч RPS» от касс означает, что на чек приходится несколько вызовов (расчёт, подтверждение, баланс, поиск клиента), а пиковый коэффициент порядка 10×. Для SaaS на старте разумные проектные цели: 500–1000 чеков в секунду в пике на кластер и десятки миллионов участников суммарно по тенантам. Один primary PostgreSQL с партиционированием `ledger_entries` по времени и материализованными балансами с этим справляется, судя по замеру pgledger.
- **Латентность.** Публичных SLA вендоров для кассы не нашёл, поэтому ниже предложение. Серверная p95 для calculate и confirm — не больше 150–200 мс, p99 — не больше 500 мс (сопоставимо с внутренней целью Mindbox для хранилища). Кассовый клиент ставит таймаут 1–3 с и после него уходит в офлайн-сценарий. Путь чека не делает синхронных вызовов во внешние системы: ни CRM, ни OLAP, ни рассылки.
- **Доступность и fallback.** Цель онлайн-процессинга — 99.9–99.95%, это моё предложение: источников с официальными цифрами нет. Fallback на кассе по образцу Manzana и Loymax: касса продаёт без лояльности или по локальному кэшу (скидки уровня карты), а чек досылается потом в batch-ingestion. Списание баллов офлайн запрещено или ограничено.
- **Архитектура для маленькой команды** — модульный монолит (Фаулер, Shopify) с компонентами:
  - `Tenancy & Access`;
  - `Members`;
  - `Ledger` (единственный, кто пишет проводки);
  - `Receipts/Checkout`;
  - `Rules/Campaigns`;
  - `Rewards/Coupons`;
  - `Tiers`;
  - `Events/Webhooks`;
  - `Imports/Exports`;
  - `Analytics sync`.
  Отдельные процессы (не сервисы) под очереди: outbox relay, webhooks, expiration и activation-джобы, офлайн-ingestion. Выделять в сервис стоит, когда появится реальная причина: отдельный профиль нагрузки, как у Mindbox с рассылками, или отдельная команда.

### Gaps
- Публичных цифр p95/p99 латентности на кассе и целевой доступности у X5, «Магнита», «Ленты», Спортмастера, Mindbox, Loymax и Manzana не нашёл. По «Ленте» и докладам HighLoad++ о процессинге лояльности данных не собрано: бюджет поиска исчерпан.
- Пиковых значений чеков в секунду (например, в предпраздничные дни) в первичных источниках нет. Есть только «несколько тысяч запросов в секунду» у X5.
- Цифры «20 тыс. магазинов / 70 млн клиентов» (вакансия, 2023) и «более 60 млн клиентов» (Интерфакс, 2022) относятся к разным датам и формулировкам.

---

## 9. Дизайн API: REST или gRPC, версионирование, OpenAPI-first, уровни аутентификации, лимиты на тенанта, пагинация, формат ошибок (RFC 9457)

### Takeaway
Для кассовых и партнёрских интеграций стандарт де-факто — REST/JSON с OpenAPI (X5 использует OpenAPI 3.0). Остальной набор:
- ошибки в формате RFC 9457 `application/problem+json`;
- обязательный `Idempotency-Key` на POST (семантика Stripe и черновика IETF);
- версионирование по датам с закреплением версии за клиентом (Stripe);
- курсорная пагинация (Slack);
- лимиты token bucket на тенанта и ключ, заголовки RateLimit (черновик IETF 11, май 2026), 429 и 503;
- для платформенных партнёров — действие от имени тенанта через заголовок контекста, как `Stripe-Account`.

### Cited Findings
- RFC 9457 заменяет RFC 7807. Медиатипы — `application/problem+json` и `application/problem+xml`. Члены объекта: `type` (URI типа проблемы, по умолчанию `about:blank`), `status`, `title` (одинаковый для типа), `detail` (про конкретный случай) и `instance`. Допускаются extension members, и клиент должен игнорировать неизвестные. Появился реестр общих типов проблем. Если проблем несколько и они разного типа, возвращается самая важная — [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
- Версионирование Stripe: версии называются датами. При первом запросе аккаунт закрепляется на текущей версии, запрос может переопределить её заголовком `Stripe-Version`. Каждое несовместимое изменение инкапсулировано в version change module, который трансформирует ответ назад во времени. Добавлять поля и endpoints совместимо, а удалять поля и менять их тип нельзя — [Stripe Blog, APIs as infrastructure: future-proofing Stripe with versioning](https://stripe.com/blog/api-versioning). В 2026 году Stripe по-прежнему использует датированные версии, в документации встречается `Stripe-Version: 2026-08-26.preview` — [Stripe Docs, Connect authentication](https://docs.stripe.com/connect/authentication)
- Семантика `Idempotency-Key`: у Stripe — до 255 символов, хранение не меньше 24 часов, сверка параметров — [Stripe API](https://docs.stripe.com/api/idempotent_requests). По черновику IETF — 400, 422 и 409 для соответствующих ситуаций; черновик 07 истёк в октябре 2025 и RFC не стал — [IETF draft-07](https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html); [Datatracker](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)
- RateLimit header fields: активный черновик draft-ietf-httpapi-ratelimit-headers-11 от 23.05.2026, RFC ещё нет. Определены поля `RateLimit-Policy` (параметры `q` — квота, `w` — окно, `qu` — единицы квоты, `pk` — ключ партиции) и `RateLimit` (`r` — остаток, `t` — время до сброса, `pk`) в синтаксисе Structured Fields — [IETF Datatracker, RateLimit headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- Stripe отвечает 429 при превышении rate limit и 503 при сбросе нагрузки. Лимиты считаются token bucket в Redis — [Stripe Blog, Rate limiters](https://stripe.com/blog/rate-limiters)
- Slack отказался от offset-пагинации: большие смещения дороги (БД читает offset + count строк), а при частых вставках страницы дают дубли и пропуски. Вместо неё введены непрозрачные курсоры (Base64) с `next_cursor` и `limit`. Цена решения — нет общего числа страниц и перехода на произвольную страницу — [Slack Engineering, Evolving API Pagination at Slack](https://slack.engineering/evolving-api-pagination-at-slack/)
- В Stripe Connect платформа вызывает API своим секретным ключом, а контекст задаёт `Stripe-Account: acct_…`. На клиенте ID аккаунта передаётся в SDK вместе с publishable key — [Stripe Docs, Connect authentication](https://docs.stripe.com/connect/authentication)
- Валидация стэкируемых скидок в Voucherify — серверный метод, рассчитанный на приватные (серверные) ключи — [Voucherify API](https://docs.voucherify.io/api-reference/validations/validate-stackable-discounts)
- X5: веб-сервисы на .NET Web API с поддержкой OpenAPI 3.0 — [Habr, X5 Tech](https://habr.com/ru/companies/X5Tech/articles/686524/)
- Grant type client credentials в OAuth 2.0 предназначен для доступа клиента к ресурсам под его собственным контролем, без участия пользователя — [RFC 6749, §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)

### Inferences
- **Стиль.** Наружу — REST/JSON, OpenAPI 3.1 как источник истины: генерация SDK для POS-вендоров, контрактные тесты, моки для партнёров. Ресурсные URL плюс явные действия для процессинга: `POST /v1/receipts:calculate`, `POST /v1/receipts` (confirm), `POST /v1/receipts/{id}:reserve|:confirm|:cancel`, `POST /v1/receipts/{id}/returns`, `GET /v1/members/{id}/balances`, `GET /v1/members/{id}/ledger?cursor=`. gRPC имеет смысл только для внутренних вызовов, если модули разойдутся по сервисам. Кассовые вендоры и e-com-платформы ожидают HTTP/JSON.
- **Уровни аутентификации** (предложение):
  1. **Партнёр/платформа**: OAuth2 client credentials, токен со scope `partner:*`, контекст тенанта задаётся заголовком (`Loyalty-Tenant: ten_…`, аналог `Stripe-Account`). Выдать токен на чужого тенанта нельзя.
  2. **Серверный API-ключ тенанта** (secret key, `sk_live_…`/`sk_test_…`) со scopes и ограничением по IP (опционально).
  3. **Учётные данные терминала**: отдельный ключ на кассу или магазин с минимальными правами (расчёт, подтверждение и отмена своих чеков), привязкой к `location_id`/`terminal_id`, ротацией и отзывом. Правило Manzana «отмена только с того же POS» проверяется на уровне авторизации.
  4. **Публичный ключ или токен участника** для мобильного приложения и личного кабинета (только свои данные).
  Для каждого уровня разделены test и live окружения.
- **Ошибки**: problem+json с доменными `type` (`https://docs.example.com/problems/insufficient-points`) и extension-полями (`balance`, `requested`, `retryable`, `request_id`). Совместимые коды: 400 (валидация, нет Idempotency-Key), 401/403, 404, 409 (конфликт состояния, параллельный запрос с тем же ключом), 422 (ключ повторно использован с другим телом, бизнес-правило), 429 (лимит), 503 (shedding).
- **Версионирование**: датированные версии, закреплённые за ключом или тенантом, с переопределением заголовком. Payload вебхуков формируется в версии подписки.
- **Пагинация**: только курсорная по `(created_at, id)`, у ledger-выгрузок — по `id` проводки.
- **Лимиты**: token bucket на `(tenant, credential)` с отдельными квотами для кассовых endpoints (critical) и фоновых (импорт, выгрузки). Заголовки `RateLimit-Policy`/`RateLimit` по черновику 11 и `Retry-After`.

### Gaps
- Сравнительных первичных источников «REST против gRPC» именно для кассовых интеграций лояльности не нашёл. Рекомендация REST опирается на практику X5 (OpenAPI) и публичные API Stripe, Talon.One и Voucherify.
- Как Talon.One, Loymax и Mindbox разделяют уровни ключей (терминал, магазин, организация), по первичным источникам не проверено.
