> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Реферальная программа и лояльность

> Настройте, сколько платит приглашение, разберитесь, когда именно реферал засчитывается, и опубликуйте уровни лояльности, из которых клиент не выпадет незаметно

Здесь бок о бок стоят две схемы, и они намеренно не связаны друг с другом.
**Реферальная программа** платит клиенту кредит в кошелёк за то, что он привёл
того, кто затем купил. **Уровень лояльности** ставит клиента на лестницу,
выведенную из того, сколько он уже потратил, сколько заказал или как давно он с
вами. Одна двигает деньги, вторая — нет.

Обе настраиваются в `/admin/marketing`, хотя форма там есть только у реферальной
программы; определения лояльности публикуются через `/v1/panel`.

## Настройка реферальной программы

`referral_programs` — это строка-синглтон: первичный ключ здесь булев и должен
быть истинным. Вторая программа означала бы два ответа на вопрос «сколько
приносит приглашение?», а клиенту уже назвали один из них.

Откройте `/admin/marketing` и прокрутите до нижней карточки. Для редактирования
нужно **`settings.write`**, а не маркетинговое право: изменение того, сколько
платит приглашение, — коммерческое решение со счётом на руках, поэтому оно стоит
рядом с прочими вещами, которые стоят установке денег.

| Поле | Значение |
| - | - |
| `enabled` | Главный переключатель выдачи вознаграждений. Атрибуция происходит и при выключенном |
| `currency` | Валюта, в которой выражены оба вознаграждения; ровно три заглавные буквы |
| `inviterRewardMinor` | Сколько получает пригласивший, целым числом в минорных единицах; `0` отключает эту сторону |
| `inviteeRewardMinor` | Сколько получает приглашённый клиент; `0` отключает эту сторону |
| `qualification` | `first_paid_order`, единственное реализованное правило |
| `inviterRewardCap` | Максимум оплаченных приглашений на одного пригласившего; пусто — без ограничения |
| `attributionValidityDays` | Сколько времени атрибуция ещё может быть засчитана |
| `rewardExpiryDays` | Необязательный срок сгорания выданного кредита в кошелёк |
| `termsUrl` | Условия программы, показываемые клиентам |

Установка, где программу никогда не настраивали, читает выключенные значения по
умолчанию — `RUB`, `first_paid_order`, 90 дней действия, без ограничения, — а не
ошибку об отсутствующей строке.

Под формой стоит история программы на текущий момент: атрибутировано,
засчитано, отклонено и сколько денег выдано. Это размещено там, чтобы изменение
делалось на основании фактов, а не догадки.

### Что API отклоняет — всё с 422

* `qualification` вне `first_paid_order`. Это закрытый набор, потому что поле со
  свободным текстом было бы обещанием, которое ничем не обеспечено.
* Валюта, состоящая не ровно из трёх заглавных букв.
* Отрицательное вознаграждение с любой из сторон.
* **Включённая программа с нулевыми вознаграждениями с обеих сторон.** Это была
  бы схема, которая что-то обещает клиентам и не платит ничего, что хуже, чем
  отсутствие схемы: приглашения всё равно расходятся. Панель показывает красное
  предупреждение «ничего не платит» и блокирует сохранение, чтобы оператор узнал
  об этом до нажатия кнопки, а не после.
* `attributionValidityDays` ноль или меньше, а также — если они вообще заданы —
  `inviterRewardCap` или `rewardExpiryDays` ноль или меньше.

Сохранение пишет действие аудита `referral.program.saved` в категории маркетинга.

## Что на самом деле засчитывает реферала

### Сначала атрибуция, и только один раз

Реферальный код клиента — это десять символов base-32, соответствующих
`^[A-Z0-9]{10}$`, выпускаемых лениво при первом открытии реферального экрана.
Один и тот же генератор работает и в боте, и в веб-панели, поэтому код,
выпущенный на одной поверхности, атрибутируется на другой.

`referral_attributions` ключуется на **приглашённом** клиенте и вставляется с
`ON CONFLICT DO NOTHING`. Поэтому атрибуция неизменяема и выигрывает первая
запись: кто пригласил клиента, решается в момент его прихода, и никакая
последующая ссылка `/start` этого не перепишет. Самореферал невозможен по
построению — в таблице есть `CHECK`, что два клиента различны, а вставка ещё и
отклоняет код, принадлежащий тому же клиенту, который его использует.

> [!WARNING]
>
> **Сегодня атрибутирует только путь через Telegram.** Атрибуция записывается из
> полезной нагрузки `/start` в боте. Реферальная ссылка, которую выдают клиенту,
> ведёт на `<APP_PUBLIC_URL>/account/sign-in?ref=<CODE>`, но страница входа в
> вебе читает только свой параметр `error`, и ни один маршрут `/v1/account` не
> принимает реферальный код, поэтому приглашение, пришедшее из веба, никогда не
> атрибутируется. Раздавайте ссылку на бота, если программа должна платить.

### Засчитывание происходит внутри транзакции расчёта

Засчитывание и выдача происходят в той же транзакции базы данных, которая
рассчитывает засчитываемый заказ. Именно это делает их ровно-однократными:
повторный вебхук, воспроизведённое обновление Stars или ручное повторное
подтверждение не могут зачислить деньги в кошелёк дважды.

Должно выполняться всё перечисленное:

1. Программа включена, и её `qualification` — `first_paid_order`.
2. Заказ покупает время подписки: `purchase`, `extension`, `renewal`, `upgrade`
   или `downgrade`. Пополнение кошелька, покупка в магазине, подарок для
   кого-то другого и код доступа, оплаченный дистрибьютором, не рассматриваются —
   они не квалифицируют реферала и, рассчитавшись раньше, не мешают более
   позднему заказу подписки его квалифицировать.
3. Заказ дошёл до `paid` или `fulfilled`.
4. Платёжный провайдер действительно провёл деньги — `paid_minor` за вычетом
   `wallet_minor` положителен, — поэтому заказ, полностью оплаченный кошельком,
   не создаёт из реферала новую стоимость. В кошельке может лежать вознаграждение
   приглашённого по этому же рефералу; квалифицируют только деньги извне.
5. Это **первый** рассчитанный заказ подписки клиента. Ничего более раннего у
   него с одной из этих операций не лежит в `paid`, `fulfilled`,
   `partially_refunded` или `refunded`.
6. Атрибуция всё ещё укладывается в `attributionValidityDays`.
7. Пригласивший не исчерпал `inviterRewardCap`, считаемый по вознаграждениям,
   которые у него уже есть в роли пригласившего.
8. Эта роль по данной паре ещё не была выдана.

Выключенная программа всё равно записывает атрибуции; она просто ничего не
выдаёт. Включение её позже не оплачивает задним числом заказы, которые уже
рассчитаны.

### Выдача один раз и что она записывает

`referral_rewards` уникальна по `(referred_user_id, role)`, где роль — `inviter`
или `invitee`. Каждая выдача пишет в одной транзакции:

* сбалансированную транзакцию двойной записи в книге проводок — кредит
  `customer_wallet` против дебета `platform_clearing` — с ключом идемпотентности
  `referral:<invited customer>:<role>`;
* строку `referral_rewards`, вставленную с `ON CONFLICT DO NOTHING`;
* необязательный `expires_at`, выведенный из `rewardExpiryDays`;
* событие аудита `referral.reward_granted`;
* `qualified_at` и `qualifying_order_id`, проставляемые на атрибуции один раз.

Когда вознаграждение уже выдано, засчитывание возвращает пустой результат, а не
ошибку, поэтому повторный запуск задания ничего не делает вместо повторного
зачисления. О том, как ведёт себя кредит после появления, см.
[Кошелёк и книга проводок](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-ledger.md).

Вознаграждение приглашённого уходит клиенту, разместившему заказ; вознаграждение
пригласившего — тому, кто его пригласил.

## Проверка и отмена реферала

Решение, что кто-то злоупотребил программой, и изъятие его вознаграждения — это
неблагоприятное для клиента решение, поэтому маршруты проверки стоят за правами
**risk**, а не за маркетинговыми, — за теми же, что управляют списком блокировок.

| Маршрут | Право доступа |
| - | - |
| `GET /v1/panel/referrals/review?state=&signalled=true&pageSize=` | `risk.read` |
| `POST /v1/panel/referrals/review/{customerID}` | `risk.write` |

`customerID` — это **приглашённый** клиент, потому что именно он является ключом
атрибуции. Тело — `{state, note}`, где состояние — `clear`, `held` или
`rejected`.

> [!NOTE]
>
> Экрана в панели нет ни у одного из этих маршрутов. Проверка выполняется
> запросами к `/v1/panel`, и сейчас нет ничего, что поднимало бы сигнал
> автоматически — состояние `held` и фильтр `signalled=true` достижимы только
> тогда, когда их выставит оператор.

### Сигналы зовут человека, но никогда не наказывают

Сигнал придерживает реферальную пару для проверки и переводит её из `clear` в
`held`. Он никогда не может сам перевести пару в `rejected`.

В этом вся политика. Автоматическая система, которая молча наказывает клиентов, —
это система, которую никто не может объяснить наказанному клиенту, поэтому
единственная власть эвристики — позвать человека. Сигнал записывает увиденное в
терминах, которые оператор может проверить: количества и идентификаторы, но
никогда отпечаток устройства или IP-адрес, потому что поверхность для проверки —
не повод расширять то, что хранится.

### Отклонение отматывает деньги через книгу проводок

Отклонение пары обходит все её вознаграждения, которые ещё не отменены, и пишет
компенсирующую транзакцию `correction` в книге проводок — дебет кошелька против
клиринга платформы — с ключом `referral-reversal:<rewardID>`. Затем оно
проставляет на строке вознаграждения `reversed_at`, `reversed_by`,
`reversal_reason` и идентификатор транзакции отмены в книге проводок. Ограничение
`CHECK` гарантирует, что вознаграждение не может читаться как отменённое без
того, чтобы деньги действительно вернулись.

Оригинал не удаляется и не правится в ноль. Книга проводок доступна только на
дозапись, а «это было выдано, а потом изъято таким-то оператором по такой-то
причине» — факт иной, чем «этого никогда не выдавали»: клиент видел баланс в
любом случае, и запись обязана это сказать.

Отклонение **требует непустой заметки**, потому что оно забирает у клиента
деньги, а причина — это то, что оператор ему процитирует.

> [!WARNING]
>
> **Отмена может увести баланс кошелька в минус.** Так задумано. Отказ от отмены
> из-за того, что деньги уже потрачены, сделал бы злоупотребление реферальной
> программой выгодным при условии, что вознаграждение тратится быстро.
> Отрицательный баланс виден, объясним из книги проводок и восстановим.

Отмена также возвращает пригласившему слот в лимите, потому что счётчик, который
этим лимитом управляет, видит только живые вознаграждения. Решение аудируется как
`panel.referral.reviewed` в категории риска, вместе с состоянием и заметкой.

## Лояльность

### Определения версионируются и неизменяемы

Строки `loyalty_programs` версионируются, и маршрута обновления нет вовсе.
Опубликовать изменение — значит опубликовать следующую версию; старая продолжает
объяснять сделанные под ней назначения. Одновременно включена может быть не более
чем одна программа, что обеспечивается частичным уникальным индексом, а
публикация с `enable: true` выключает все остальные программы в том же запросе.

Клиент, дошедший до gold при одном наборе порогов, не должен незаметно из него
выпасть из-за того, что кто-то поменял числа. Позиция хранит метрику, по которой
было принято решение, и действовавшую версию, поэтому у вопроса «почему у меня
silver?» есть ответ, не зависящий от того, каковы пороги сегодня.

Публикация возможна только через API:

```json theme={null}
POST /v1/panel/loyalty/programs
{
  "metric": "spend",
  "currency": "RUB",
  "windowDays": 365,
  "graceDays": 30,
  "enable": true,
  "tiers": [
    {"code": "bronze", "nameEn": "Bronze", "nameRu": "Бронза", "threshold": 0, "discountBps": 0},
    {"code": "silver", "nameEn": "Silver", "nameRu": "Серебро", "threshold": 500000, "discountBps": 500},
    {"code": "gold",   "nameEn": "Gold",   "nameRu": "Золото", "threshold": 2000000, "discountBps": 1000}
  ]
}
```

Номер новой версии на единицу выше самого высокого уже опубликованного. `POST`
требует `marketing.write`; `GET /v1/panel/loyalty/programs` требует
`marketing.read`.

### По чему измеряется уровень

| Метрика | Значение |
| - | - |
| `spend` | Сумма `paid_minor` по заказам в `paid` и `fulfilled` в валюте программы внутри окна |
| `orders` | Количество тех же заказов |
| `tenure` | Дней с начала самого раннего права на услугу у клиента; окно не имеет значения |

Все три — факты, которые Omniflow и так записывает. Это сделано намеренно: модель
лояльности, которой нужно новое отслеживание, — это модель лояльности, которой
нужно новое согласие.

### Правила публикации, которые домен отклоняет

Два из них ограничение `CHECK` выразить не может, и все они дают 422:

* Уровней нет вовсе.
* Порог самого нижнего уровня не равен нулю, из-за чего часть клиентов не
  попадала бы ни на один уровень. Уровень должен быть у каждого клиента.
* Уровень выше стоимостью меньше, чем уровень ниже, — представимо и наверняка
  является ошибкой.
* Неподдерживаемая метрика или неположительное окно для оконной метрики.

Схема добавляет собственные границы: `discountBps` от 0 до 10000, коды и пороги
уровней уникальны внутри программы, `graceDays` ноль или больше, `windowDays`
больше нуля, валюта — три заглавные буквы.

### Оценка воспроизводима, а понижение медленнее повышения

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

Правила по порядку:

1. Текущего уровня нет — берётся заработанный уровень.
2. Удерживаемый уровень принадлежит устаревшей версии — клиент переставляется по
   действующему определению.
3. Заработанный порог не ниже удерживаемого — берётся заработанный уровень и
   сбрасывается льготный отсчёт; клиента никто не тащит на себе.
4. Ниже удерживаемого уровня при нулевом льготном периоде — понижение сразу. Это
   законная конфигурация, а не ошибка.
5. Ниже, отсчёт не запущен — уровень сохраняется, а отсчёт запускается до
   `now + graceDays`. В историю ничего не пишется, потому что ничего не
   изменилось.
6. Ниже, отсчёт идёт и ещё не истёк — уровень сохраняется.
7. Ниже, отсчёт истёк — понижение.

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

Оценка идемпотентна: `loyalty_standing_history` дописывается только тогда, когда
позиция действительно изменилась, поэтому оператор, нажавший кнопку дважды,
получит одну запись, а не две. Записи истории пишутся с причиной `evaluation`.

> [!WARNING]
>
> **Автоматически ничего не оценивается.** Нет ни обхода, ни запланированного
> задания, ни хука на расчёт заказа. Единственный вызывающий —
> `POST /v1/panel/customers/{customerID}/loyalty/evaluate` (`marketing.write`),
> по одному клиенту за раз, и экрана в панели у него нет. Клиент попадает на
> лестницу только тогда, когда кто-то вызовет этот маршрут для него, и его
> позиция — снимок того момента, когда вызов был сделан.

> [!WARNING]
>
> **`discountBps` уровня показывается и никогда не применяется.** Ни один путь
> ценообразования, оформления заказа, расчёта стоимости или акций не читает
> уровень лояльности. Цифра скидки показывается клиенту на `/account/referrals` и
> операторам в карточке клиента — и это всё, что она сегодня делает. Если скидка
> должна дойти до цены, используйте [акцию](https://omniflow-vpn.mintlify.site/ru/commerce/catalog-promotions.md).

### Что видит клиент

`/account/referrals` отображает лестницу, ступень клиента, метрику, по которой
принято решение, расстояние до следующей ступени и процент пройденной текущей
полосы. Он читает тот уровень, который **назван** в позиции, а не пересчитывает
его, потому что клиент внутри льготного периода удерживает уровень, который его
текущая метрика уже не заработала, и пересчёт при загрузке страницы понизил бы
его ровно в момент её открытия.

Ответ содержит `enabled: false`, когда программа не опубликована или в программе
нет уровней, и `enabled: true, evaluated: false`, когда позиция клиента
принадлежит устаревшей версии: честный ответ здесь — что размещение ждёт
переоценки, а не что уровня у клиента нет.

Реферальная сводка на том же экране показывает количества приглашённых,
засчитанных, ожидающих и отклонённых, взятые из строк, а не выведенные
вычитанием, потому что атрибуция может быть засчитана и позже отклонена при
проверке, и вычитание учло бы такую пару дважды. Заработанные и отменённые деньги
показываются раздельно, чтобы клиент, у которого упал итог, видел причину, а не
гадал.

Ничто в этой проекции не несёт идентификатора приглашённого клиента.
Пригласивший вправе знать, что приглашение принесло выплату, но не получать
список того, кто его друзья.

## Смежные страницы

> **[Кошелёк и книга проводок](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-ledger.md)**
>
> Куда попадает реферальный кредит и как читается отмена.

> **[Сегменты аудитории и кампании](https://omniflow-vpn.mintlify.site/ru/commerce/campaigns.md)**
>
> Фильтр `loyaltyTier` и всё остальное, что делает `/admin/marketing`.

> **[Средства контроля рисков](https://omniflow-vpn.mintlify.site/ru/operations/risk-controls.md)**
>
> Другие решения, которыми управляют `risk.read` и `risk.write`.

> **[Веб-панель клиента](https://omniflow-vpn.mintlify.site/ru/customer/web-panel.md)**
>
> `/account/referrals` со стороны клиента.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.