Skip to main content
Здесь бок о бок стоят две схемы, и они намеренно не связаны друг с другом. Реферальная программа платит клиенту кредит в кошелёк за то, что он привёл того, кто затем купил. Уровень лояльности ставит клиента на лестницу, выведенную из того, сколько он уже потратил, сколько заказал или как давно он с вами. Одна двигает деньги, вторая — нет. Обе настраиваются в /admin/marketing, хотя форма там есть только у реферальной программы; определения лояльности публикуются через /v1/panel.

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

referral_programs — это строка-синглтон: первичный ключ здесь булев и должен быть истинным. Вторая программа означала бы два ответа на вопрос «сколько приносит приглашение?», а клиенту уже назвали один из них. Откройте /admin/marketing и прокрутите до нижней карточки. Для редактирования нужно settings.write, а не маркетинговое право: изменение того, сколько платит приглашение, — коммерческое решение со счётом на руках, поэтому оно стоит рядом с прочими вещами, которые стоят установке денег. Установка, где программу никогда не настраивали, читает выключенные значения по умолчанию — 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, что два клиента различны, а вставка ещё и отклоняет код, принадлежащий тому же клиенту, который его использует.
Сегодня атрибутирует только путь через 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, проставляемые на атрибуции один раз.
Когда вознаграждение уже выдано, засчитывание возвращает пустой результат, а не ошибку, поэтому повторный запуск задания ничего не делает вместо повторного зачисления. О том, как ведёт себя кредит после появления, см. Кошелёк и книга проводок. Вознаграждение приглашённого уходит клиенту, разместившему заказ; вознаграждение пригласившего — тому, кто его пригласил.

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

Решение, что кто-то злоупотребил программой, и изъятие его вознаграждения — это неблагоприятное для клиента решение, поэтому маршруты проверки стоят за правами risk, а не за маркетинговыми, — за теми же, что управляют списком блокировок. customerID — это приглашённый клиент, потому что именно он является ключом атрибуции. Тело — {state, note}, где состояние — clear, held или rejected.
Экрана в панели нет ни у одного из этих маршрутов. Проверка выполняется запросами к /v1/panel, и сейчас нет ничего, что поднимало бы сигнал автоматически — состояние held и фильтр signalled=true достижимы только тогда, когда их выставит оператор.

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

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

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

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

Лояльность

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

Строки loyalty_programs версионируются, и маршрута обновления нет вовсе. Опубликовать изменение — значит опубликовать следующую версию; старая продолжает объяснять сделанные под ней назначения. Одновременно включена может быть не более чем одна программа, что обеспечивается частичным уникальным индексом, а публикация с enable: true выключает все остальные программы в том же запросе. Клиент, дошедший до gold при одном наборе порогов, не должен незаметно из него выпасть из-за того, что кто-то поменял числа. Позиция хранит метрику, по которой было принято решение, и действовавшую версию, поэтому у вопроса «почему у меня silver?» есть ответ, не зависящий от того, каковы пороги сегодня. Публикация возможна только через API:
Номер новой версии на единицу выше самого высокого уже опубликованного. POST требует marketing.write; GET /v1/panel/loyalty/programs требует marketing.read.

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

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

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

Два из них ограничение CHECK выразить не может, и все они дают 422:
  • Уровней нет вовсе.
  • Порог самого нижнего уровня не равен нулю, из-за чего часть клиентов не попадала бы ни на один уровень. Уровень должен быть у каждого клиента.
  • Уровень выше стоимостью меньше, чем уровень ниже, — представимо и наверняка является ошибкой.
  • Неподдерживаемая метрика или неположительное окно для оконной метрики.
Схема добавляет собственные границы: discountBps от 0 до 10000, коды и пороги уровней уникальны внутри программы, graceDays ноль или больше, windowDays больше нуля, валюта — три заглавные буквы.

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

Evaluate принимает текущее время аргументом и не обращается к базе данных, поэтому одни и те же входные данные всегда дают один и тот же уровень. Решение о лояльности, которое никто не может воспроизвести, — это решение, которое никто не сможет защитить, когда клиент его оспорит. Правила по порядку:
  1. Текущего уровня нет — берётся заработанный уровень.
  2. Удерживаемый уровень принадлежит устаревшей версии — клиент переставляется по действующему определению.
  3. Заработанный порог не ниже удерживаемого — берётся заработанный уровень и сбрасывается льготный отсчёт; клиента никто не тащит на себе.
  4. Ниже удерживаемого уровня при нулевом льготном периоде — понижение сразу. Это законная конфигурация, а не ошибка.
  5. Ниже, отсчёт не запущен — уровень сохраняется, а отсчёт запускается до now + graceDays. В историю ничего не пишется, потому что ничего не изменилось.
  6. Ниже, отсчёт идёт и ещё не истёк — уровень сохраняется.
  7. Ниже, отсчёт истёк — понижение.
Программа лояльности, которая понижает человека за один тихий месяц, наказывает ровно тех клиентов, ради удержания которых её строили, — поэтому отсчёт и существует и поэтому восстановление внутри окна его сбрасывает. Оценка идемпотентна: loyalty_standing_history дописывается только тогда, когда позиция действительно изменилась, поэтому оператор, нажавший кнопку дважды, получит одну запись, а не две. Записи истории пишутся с причиной evaluation.
Автоматически ничего не оценивается. Нет ни обхода, ни запланированного задания, ни хука на расчёт заказа. Единственный вызывающий — POST /v1/panel/customers/{customerID}/loyalty/evaluate (marketing.write), по одному клиенту за раз, и экрана в панели у него нет. Клиент попадает на лестницу только тогда, когда кто-то вызовет этот маршрут для него, и его позиция — снимок того момента, когда вызов был сделан.
discountBps уровня показывается и никогда не применяется. Ни один путь ценообразования, оформления заказа, расчёта стоимости или акций не читает уровень лояльности. Цифра скидки показывается клиенту на /account/referrals и операторам в карточке клиента — и это всё, что она сегодня делает. Если скидка должна дойти до цены, используйте акцию.

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

/account/referrals отображает лестницу, ступень клиента, метрику, по которой принято решение, расстояние до следующей ступени и процент пройденной текущей полосы. Он читает тот уровень, который назван в позиции, а не пересчитывает его, потому что клиент внутри льготного периода удерживает уровень, который его текущая метрика уже не заработала, и пересчёт при загрузке страницы понизил бы его ровно в момент её открытия. Ответ содержит enabled: false, когда программа не опубликована или в программе нет уровней, и enabled: true, evaluated: false, когда позиция клиента принадлежит устаревшей версии: честный ответ здесь — что размещение ждёт переоценки, а не что уровня у клиента нет. Реферальная сводка на том же экране показывает количества приглашённых, засчитанных, ожидающих и отклонённых, взятые из строк, а не выведенные вычитанием, потому что атрибуция может быть засчитана и позже отклонена при проверке, и вычитание учло бы такую пару дважды. Заработанные и отменённые деньги показываются раздельно, чтобы клиент, у которого упал итог, видел причину, а не гадал. Ничто в этой проекции не несёт идентификатора приглашённого клиента. Пригласивший вправе знать, что приглашение принесло выплату, но не получать список того, кто его друзья.

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

Кошелёк и книга проводок

Куда попадает реферальный кредит и как читается отмена.

Сегменты аудитории и кампании

Фильтр loyaltyTier и всё остальное, что делает /admin/marketing.

Средства контроля рисков

Другие решения, которыми управляют risk.read и risk.write.

Веб-панель клиента

/account/referrals со стороны клиента.