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

Партнёр приводит магазины на teeu и получает вознаграждение за каждый, который начал работать.
Раздел — «Партнёрская программа» в кабинете пользователя (`/account/partner`), управление — в админке
(**Партнёры → Начисления / Выплаты**), суммы и тексты — в настройках (**Интеграции → Реферальная
программа**).

## Имена: `referral.*`, а не `partner.*`

Префикс `partner.*` в настройках и `teeu.partner` в конфиге **уже заняты** лендингом Korzilla
(`/korzilla-partner`). Реферальная программа живёт под `referral.*` и `teeu.referral`. Второй ключ
`'partner'` в `config/teeu.php` PHP молча съедает — остаётся последний, а первый становится мёртвым.

## Путь клиента

1. Партнёр открывает раздел — код выдаётся **лениво**, при первом заходе, а не всем подряд.
2. Ссылка вида `/r/{код}` кладёт код в куку (по умолчанию 90 дней) и ведёт на `/sell`.
3. Клиент регистрируется — слушатель `AttachReferral` на событии `UserRegistered` читает куку и
   проставляет `referred_by_user_id`. Слушатель, а не код в `AccountService`: регистраций две
   (телефон и OAuth), правило должно работать одинаково в обеих.
4. Клиент заводит магазин и подключает выгрузку.
5. **Импорт прошёл, товары на витрине** → `YmlImportSucceeded` → `AccrueReferralReward` создаёт
   начисление со статусом «на проверке».
6. Администратор подтверждает → сумма попадает в баланс партнёра.
7. Партнёр запрашивает вывод → администратор отправляет деньги вручную и закрывает заявку.

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

Партнёр видит чужих людей, поэтому имени и почты в списке нет, а телефон показан частично:
`+7 (906) ***-50-94`.

Сначала не показывали и его — считалось, что кому давал ссылку, партнёр помнит сам. Не помнит:
строка «Регистрация от 28.08.2026» не говорит ничего, а помочь клиенту дойти до витрины — ровно
то, ради чего список и сделан. Частичный номер решает это без утечки: кто действительно привёл
человека, узнаёт его по коду города и последним цифрам, потому что номер у него и так есть; кто
раскидал ссылку веерно — не узнаёт ничего нового. Согласия на передачу чужих контактов при таком
виде не требуется.

## Два тарифа: сотрудники KORZILLA и все остальные

Программа открыта всем, но условия разные, и решает это флаг `users.is_korzilla_staff`.

| Кто | За магазин на KORZILLA | За любой другой сайт |
|---|---|---|
| Сотрудник KORZILLA | `referral.reward_korzilla` (1000 ₽) | `referral.reward_other` (1500 ₽) |
| Остальные партнёры | `referral.reward_default` (500 ₽) | то же, 500 ₽ |

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

**Флаг на пользователе, а не роль.** Роли у нас про доступ к разделам, а это про деньги. Смешать их
значит однажды выдать доступ вместе с тарифом.

**Ставит только владелец проекта** (пользователь №1) — как и ручную привязку к партнёру
(`KorzillaStaffAction`, видимость через `isRootAdmin()`). Действие создаёт денежные обязательства,
и решать, кому какой тариф, не должен каждый администратор. Обе операции пишутся в аудит.

**В кабинете каждый видит свои суммы.** Блок «Сколько платим» берёт их из того же
`ReferralService::rewardFor()`, что и начисление. Разбивка «сайт на KORZILLA / любая другая система»
показывается только тому, у кого ставки за площадку различаются, — сотруднику (1000 и 1500 ₽).
Остальные видят одну строку «За подключённый магазин — 500 ₽»: им платят одинаково за любой магазин,
будь выгрузка с сайта или из кабинета Ozon и Wildberries. Пояснений про тарифы на странице нет
намеренно. Раньше всем показывались ставки
сотрудников, и обычный партнёр видел суммы в 2–3 раза больше тех, что ему начислят.

**Уже начисленное не пересчитывается.** Сумма выбирается в момент начисления и записывается в саму
запись (`partner_rewards.amount`), поэтому снятие флага не отбирает у человека заработанное, а
установка не поднимает старые начисления задним числом.
## Стадия клиента: где он остановился

В списке партнёра видны **все** приведённые, а не только те, за кого заплачено. Иначе раздел
отвечал бы лишь на вопрос «сколько я заработал», а полезнее второй: **кому нужна помощь, пока она
ещё уместна**. Клиент появляется в списке сразу после регистрации — до магазина, до выгрузки.

Стадия вычисляется из состояния магазина (`App\Enums\ReferralStage`, считает
`Services\Partner\ReferralClients`), отдельной таблицы под это нет: любое хранимое поле рассыпалось
бы при первом же изменении задним числом, а состояние всегда актуально.

| Стадия | Что видит партнёр | Чем помочь |
|---|---|---|
| Зарегистрировался | аккаунт есть, магазина нет | пройти регистрацию продавца |
| Магазин не подтверждён | `draft` / `pending_email` | обычно не нажата ссылка в письме |
| Нет выгрузки | магазин активен, фидов ноль | нужна ссылка на каталог сайта |
| Выгрузка добавлена | фид есть, товаров ещё нет | ничего, первый импорт идёт до часа |
| Выгрузка не загружается | `failed_sync_count > 0` либо статус `disabled`/`blocked` | ради этой строки всё и сделано |
| Товары на витрине | товары есть | клиент работает |
| Магазин отключён | `suspended` / `blocked` | вопрос к поддержке, не к настройке |

**Порядок проверок не случаен.** «Товары на витрине» важнее сломанного фида: выгрузок у магазина
бывает несколько, и одна упавшая не отменяет того, что клиент уже торгует.

**Стадию определяет свой магазин клиента, а не тот, где он менеджер** — берём участие с ролью
`owner`. Иначе сотрудник чужого магазина выглядел бы как дошедший до конца.

### Приватность

Партнёр — не сотрудник teeu, и в списке нет **ни имени, ни телефона, ни почты** клиента. Показываем
публичное название магазина (оно и так на витрине), а до создания магазина — только дату перехода:
«Регистрация от 12.03.2026». Кому именно он давал ссылку, партнёр помнит и без нас, а вот выдавать
ему контакты чужих людей мы не вправе. На это есть отдельный тест, который проверяет, что телефон
клиента не появляется на странице.
## Решения, которые стоит помнить

**Начисляем после импорта, а не при создании магазина.** Магазин заводят за минуту и бросают; платить
надо за работающий. Товары на витрине — простейший признак, что клиент настоящий. Заодно только к
этому моменту известен адрес фида, а по нему — тариф.

**Авторство закрепляется навсегда.** `attach()` не перезаписывает уже проставленного партнёра: иначе
последний, кто дал ссылку, забирал бы чужих клиентов.

**За магазин платим один раз** — это гарантирует уникальный индекс по `seller_id`, а не только
проверка в коде.

**Тариф определяется по адресу выгрузки** (`Services\Partner\FeedPlatform`). Правила от отдела продаж:
первый сегмент пути `a` (`expostavka.ru/a/express/yml.xml`), `/yml/{цифры}/{цифры}`
(`kardanmaster.ru/yml/230/0/`) либо `{логин}/yml.xml` ровно из двух сегментов
(`kabinkam.ru/kabinkam/yml.xml`) — это KORZILLA, всё остальное — сторонняя система.

Третье правило нарочно узкое: ровно два сегмента и именно `yml.xml`. `example.com/yml.xml` без
логина или `/wp-content/uploads/yml.xml` под него не попадают. Причина в том, что осторожность здесь
работает наоборот: чужая CMS, засчитанная как KORZILLA, **занизит** выплату партнёру. Неоднозначность
трактуется в пользу партнёра: не распознали — платим больше. Ошибка в эту сторону дороже нам, а не
человеку, который привёл клиента.

**Свой магазин не считается приведённым.** Вознаграждение не начисляется, если получатель состоит в
этом магазине — в любой роли, не только владельцем. Это закрывает и «добавлю себе второй магазин», и
случай, когда партнёр записан совладельцем у клиента. Владелец при этом определяется **по роли**, а
не «первым попавшимся» участником: у магазина бывают менеджеры, и клиентом считается тот, на кого
магазин записан.

Оборотная сторона того же правила: если партнёра самого кто-то привёл, а он потом открыл магазин,
платят пригласившему. Это не «свой магазин», а обычный приведённый клиент, который дорос до продавца.

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

**Заявка забирает конкретные начисления, а не сумму.** `payout_request_id` проставляется в самих
начислениях под `lockForUpdate` — иначе при двух параллельных запросах одни деньги ушли бы дважды.
Отказ по заявке **возвращает** начисления в баланс: отказ бывает про реквизиты, а не про заработанное.

**Реквизиты живут только в заявке**, в зашифрованном поле. Постоянно хранить платёжные данные
партнёра мы не хотим — по этой же причине их нет в профиле.

В списке заявок реквизиты **открытой** заявки видны сразу: по ним и делается перевод, прятать их
от того, кто платит, бессмысленно. Они же продублированы в окне «Отметить выплаченной» — перевод
делают руками, и возвращаться в список за номером лишний раз незачем. У закрытой заявки остаётся
только хвост номера: узнать её он позволяет, а карту в истории не светит. Ещё и **телефон партнёра**
виден сразу: имена в списке бывают вида «Марина Е», и по ним не понять, кому уходит перевод.
Показан он в читаемом виде, а копируется в том, который принимает банк (`+7XXXXXXXXXX`).

## Итоги под списком заявок

Список заявок отвечает на вопрос «кому перевести сейчас». Вопрос «сколько мы уже отдали и кому
больше всех» из него не собрать: один партнёр встречается в списке по многу раз, заявки лежат
вперемешку по статусам и месяцам. Поэтому под таблицей — три виджета подвала
(`ListPartnerPayouts::getFooterWidgets()`):

| Виджет | Что показывает |
|---|---|
| `PayoutTotalsWidget` | выплачено всего, за текущий месяц (и за прошлый), ждёт выплаты, начислено без заявки |
| `PayoutMonthsChartWidget` | суммы по месяцам, 6/12/24 |
| `TopPartnersWidget` | топ-15 партнёров по выплатам: сумма, заявки, ожидание, баланс |

Два решения, которые видно только в коде:

**Месяц расхода считается по `processed_at`, а не по дате запроса.** Заявка, поданная в конце месяца
и закрытая в начале следующего, иначе попала бы не в тот столбец, и месячные суммы не сошлись бы с
тем, что реально ушло с карты.

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

Ряды для графика строит `ChartSeries::monthly()` — циклом в PHP, а не через `DATE_FORMAT`: у MySQL и
sqlite (на нём идут тесты) это разные функции. Названия месяцев там же и свои, потому что локаль
Carbon в тестах не та, что на сайте.
## Ручная привязка (админка)

Реферальная ссылка ловит не всех: человека приводят живым разговором, а
регистрируется он сам, набрав адрес, — куки в этот момент нет, и связать некому.
Поэтому в карточке пользователя есть действие **«Привязать к партнёру»**.

**Видит его только владелец проекта** (пользователь №1, `User::isRootAdmin()`). Действие создаёт
обязательства по деньгам, и решать, кому они причитаются, не должен каждый администратор.

`ReferralService::attachManually()` намеренно отличается от `attach()`:

- **перезапись разрешена** — это исправление ошибки живым человеком, а не срабатывание куки;
- **уже созданные начисления не переносятся**: они привязаны к партнёру и могут быть выплачены,
  а переписывать историю денег задним числом нельзя. Прежний партнёр сохраняет своё;
- **работающие магазины начисляются сразу** — иначе партнёру пришлось бы ждать следующего импорта
  без объяснений. «Работающий» = есть товары: пустая витрина ещё не доказывает живого клиента,
  ровно поэтому обычное начисление и привязано к успешному импорту;
- **привязать к самому себе нельзя** — это отсекается в сервисе, а не в форме.

Отвязка — отдельное действие, видно только когда привязка есть.
## Дубли магазинов

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

**ИНН — один магазин.** При регистрации ИНН проверяется на уникальность среди магазинов. Сообщение
отправляет владельца в его же аккаунт или в поддержку — жёсткого запрета «навсегда» нет, случай
«одно юрлицо, два бренда» решается вручную.

**Домен выгрузки занят другим магазином** (`App\Rules\NotAnotherSellersSite`) — фид не добавить.
Сверяем по хосту, а не по строке: у одной выгрузки бывает десяток форм адреса (со слешем, с `www`,
с параметрами). Свои фиды с того же домена разрешены — продавцы нередко делят каталог на несколько
выгрузок.

Ограничения намеренно живут в валидации, а не в индексах базы: продавцу нужно понятное сообщение с
выходом, а не отказ на уровне СУБД. Гонка здесь практически невозможна — регистрацию проходит человек
руками.
## Настройки

| Ключ | Смысл |
|---|---|
| `referral.enabled` | выключено — раздел скрыт, ссылки не запоминаются |
| `referral.reward_default` | сумма за магазин по умолчанию — всем, кто не сотрудник KORZILLA, за любой сайт (500 ₽) |
| `referral.reward_korzilla` | сотруднику KORZILLA — за клиента на KORZILLA (1000 ₽) |
| `referral.reward_other` | сотруднику KORZILLA — за клиента на другой системе (1500 ₽) |
| `referral.min_payout` | минимальная сумма вывода (1000 ₽) |
| `referral.cookie_days` | сколько дней помнится переход по ссылке (90) |
| `referral.terms` | условия программы, показываются партнёру |
| `referral.pitch_personal` / `referral.pitch_post` | заготовки текстов, `{ссылка}` подставляется |

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

## Тесты

`tests/Feature/ReferralProgramTest.php` (29), `ReferralClientsTest.php` (16),
`AdminUsersTableTest.php` (12): определение системы по восьми вариантам адреса,
запоминание кода, привязка и её неперезаписываемость, self-referral, начисление по обоим тарифам,
один раз на магазин, пропуск пустого магазина, баланс только из подтверждённых, вывод с захватом
начислений, отказ ниже минимума, шифрование реквизитов, раздел в кабинете,
свой магазин не оплачивается, участие партнёра в магазине в любой роли, определение владельца по роли,
отказ по домену другого магазина и разрешение своего второго фида с того же домена.

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

Отдельный тест закрывает выгрузку, загруженную файлом: у неё пустой `url`, и запасная ветка
определения тарифа вызывала несуществующий метод — начисление падало на ровном месте.

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