Referrals System
1. Summary
Goal: Механизм привлечения новых пользователей через систему реферальных ссылок с двусторонними наградами и пассивным доходом для реферера.
User Value: Возможность получить бонус за приглашение друзей и пассивный доход (10%) от заработка приглашённых пользователей до конца текущего сезона. Путь: Поделился ссылкой → Друг зарегистрировался → Оба получили бонус → Реферер получает % от заработка друга (в течение сезона).
2. Business Logic
Reward Types
- Referrer Rewards
- Referred Rewards
Кто получает: Пользователь, который пригласил
Одноразовая награда: Конфигурируется в Season Setup Wizard (Season.joinReferrerRewards). По умолчанию +100 XP. Может включать XP, Scrap, Streak Points, кейсы или предметы. Выдаётся через Referrer Reward модалку при заходе в TMA.
Пассивный доход: 10% от каждого заработка Scrap приглашённого друга (до конца сезона)
Пассивный доход работает только в том сезоне, когда был создан реферал. После смены сезона реферер перестаёт получать % от заработка приглашённого.
XP НЕ начисляется автоматически при активации. Вместо этого при каждом открытии TMA показывается ReferrerRewardModal со списком друзей, которые активировались. Пользователь нажимает «Забрать всё» → XP зачисляется суммарно. «Позже» → модалка закрывается до следующей сессии.
Что происходит при активации реферала (сразу):
- +1 к счётчику
friendsInvited - Прогресс квестов SOCIAL (
socialActionType: 'referral') - Прогресс достижений SOCIAL
- Feed event
REFERRAL_BONUS(Live Feed) - Telegram пуш рефереру: «Заходи забрать награду 🎁»
Что дефёрится до клейма в модалке:
- XP начисление (
addUserXP) referrerRewardClaimed = true,referrerRewardClaimedAt = now()
Кто получает: Приглашённый пользователь
Одноразовая награда: Конфигурируется в Season Setup Wizard (Season.joinReferredRewards). По умолчанию +500 Scrap. Может включать XP, Scrap, Streak Points, кейсы или предметы.
Награды НЕ начисляются автоматически при активации. Вместо этого при каждом открытии приложения показывается ReferralWelcomeModal с именем пригласившего и суммой Scrap-бонуса (если настроены Scrap-награды). Пользователь нажимает «Забрать» → все joinReferredRewards зачисляются. Кнопка «Позже» закрывает модалку до следующей сессии.
Цель: Стартовый капитал для новичка + осознанное получение награды
Invite Flow
Алгоритм приглашения (реализован в ReferralCodeService + InviteSessionService + ActivationRewardService из домена activation).
Подробнее о сессиях см. Activation.
1. Генерация ссылки
- Пользователь получает уникальный реферальный код (авто-генерация или кастомный)
- Share URL:
https://start.goloot.online/ref_CODE(красивый превью в соцсетях) - Redirect-service перенаправляет на:
https://t.me/bot/app?startapp=ref_CODE
2. Переход по ссылке
- Клик по ссылке → создаётся
InviteSession(state: PENDING) - Записывается
ReferralClickAnalytics(для аналитики конверсий) - Session живёт 24 часа, затем EXPIRED
- При активации записывается
activationSource:REF_DEEPLINK(реферальная ссылка),UTM_DEEPLINK(UTM ссылка) илиPROMO_CODE(ручной ввод кода)
3. Регистрация и активация (deeplink)
- При регистрации проверяется наличие PENDING сессии по
telegramId - Создаётся запись
Referral(связь referrer → referred) - Session переходит в state: ACTIVATED,
activationSource = REF_DEEPLINK - Referrer:
handleReferrerActivation()—friendsInvited++, прогресс квестов/достижений SOCIAL, Feed event; XP НЕ начисляется — откладывается до Referrer Reward модалки - Referred: Награды НЕ начисляются автоматически — откладываются до Welcome Bonus модалки
- Telegram пуш рефереру: «{friendName} присоединился к goLoot! Заходи забрать награду 🎁»
3а. Активация по коду (promo-code-like)
- Пользователь без реферальной сессии вводит код вручную →
POST /api/referrals/activate-by-code - Создаётся
InviteSession(state: ACTIVATED,activationSource = PROMO_CODE) + записьReferral - Валидация: код существует и активен, не самореферал, пользователь ещё не приглашён
- Ответ содержит
rewards(RewardConfig[] из сезона) иreferrerName - Тип модалки: нейтральная («Код принят! Вот твои награды: ...»)
3.5. Welcome Bonus + Referrer Rewards (Claim по кнопке)
- При каждом открытии приложения
useReferralWelcomeпроверяетGET /api/referrals/welcome-bonus - Если
referralRewardClaimed === false→ показываетсяReferralWelcomeModal - Пользователь нажимает «Забрать» →
POST /api/referrals/claim-welcome-bonus→ scrap зачисляется - «Позже» → модалка закрывается (React state), появится снова при следующем открытии
Referrer Rewards Flow (после Welcome Bonus, для реферера):
- После закрытия Welcome Bonus
useReferrerRewardsпроверяетGET /api/referrals/referrer-rewards - Если
count > 0→ показываетсяReferrerRewardModalсо списком друзей (ответ:{ count: N, items: [...] }) - Пользователь нажимает «Забрать всё» →
POST /api/referrals/claim-referrer-rewards→ XP зачисляется суммарно - Если несколько друзей активировались → один батч-клейм, один вызов
addUserXP - «Позже» → модалка закрывается (React state), появится снова при следующем открытии
Sequencing в App.tsx:
ReferralWelcomeModal— для приглашённых (enabled:isInitialized && displayStatus === 'active')ReferrerRewardModal— для рефереров (enabled:... && isReferralHandled, ждёт welcome bonus)PromoCodeModal— для UTM-пользователей- OnboardingGuide —
shouldShowPromptждёт всеisHandledфлаги
4. Passive Income (Accumulate + Claim)
Пассивный доход НЕ зачисляется напрямую на баланс. Он накапливается в буфере passiveIncomeBalance и пользователь забирает его вручную через кнопку в ReferralModal.
Накопление (автоматически):
- При каждом заработке Scrap приглашённым вызывается
PassiveIncomeService.processPassiveIncome() - Проверяется:
referral.seasonId === currentSeason.id - Если сезоны совпадают:
passiveAmount = floor(scrapAmount × 10%) - Минимум 1 Scrap если исходный заработок > 0
- Если сезоны НЕ совпадают:
passiveAmount = 0(пассивный доход не начисляется) - Результат →
user.passiveIncomeBalance += passiveAmount(буфер)
Claim (по кнопке):
- Эндпоинт:
POST /api/referrals/claim-passive-income - Optimistic locking:
updateMany WHERE passiveIncomeBalance = exactValue— защита от double-click - При успехе:
scrap += claimAmount,passiveIncomeBalance = 0 - Обновляет сезонную статистику через
addScrap()(scrapTotalEarned,scrapFromReferrals— только в USS)
Season Reset Auto-Claim:
- При смене сезона невостребованный
passiveIncomeBalanceавтоматически зачисляется вUserSeasonStats.scrapTotalEarned(старый сезон) через raw SQL - Буфер
passiveIncomeBalanceобнуляется на User - Пользователь не теряет накопленный доход
Referral Leaderboard
Сезонный рейтинг рефереров, мотивирующий приглашать активных друзей.
Механика:
- Метрика ранжирования: SUM(
passiveScrapEarned) от всех рефералов за текущий сезон (DESC), при равенстве — COUNT friends (DESC) - Топ-10: 10 мест, как в сезонном XP лидерборде
- Reward tiers: Top 1 = legendary, Top 2-3 = mythical, Top 4-10 = epic
- Reward items: Конфигурируются через Season Setup Wizard → Step 3 "Referral Rewards", хранятся в
Season.referralRewards - Видимость: Секция отображается только при активном сезоне с настроенными
referralRewards - Награды: Раздаются в конце сезона через
distributeRewards()аналогично XP leaderboard
Для получения passive scrap — друг должен реально играть (квизы, кейсы, квесты). Фейковые аккаунты невыгодны: они не генерируют scrap, а значит реферер не накапливает passive scrap earned.
Строки лидерборда:
- Ранг (1-10), аватар (Telegram), анонимизированное имя
passiveScrapEarned— основная метрика (жирный шрифт, акцент)friendsCount— кол-во друзей за сезон (мелкий шрифт)- Reward tier badge (legendary/mythical/epic)
- Подсветка текущего пользователя
Current user entry: Если пользователь не в топ-10 — показывается отдельно внизу с рангом и метриками.
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| Create code | general (100/min) | Telegram | CreateReferralCodeSchema |
| Get code info | general (100/min) | None | ReferralCodeParamsSchema |
| Get analytics | general (100/min) | Telegram | ReferralAnalyticsQuerySchema |
| Get my codes | general (100/min) | Telegram | - |
| Get my stats | general (100/min) | Telegram | GetMyReferralStatsSchema |
| Claim passive income | achievementClaim (15/min) | Telegram + Active Season | - |
| Get welcome bonus | general (100/min) | Telegram | GetWelcomeBonusSchema |
| Claim welcome bonus | achievementClaim (15/min) | Telegram | ClaimWelcomeBonusSchema |
| Get referrer rewards | general (100/min) | Telegram | GetReferrerRewardsSchema |
| Claim referrer rewards | achievementClaim (15/min) | Telegram | ClaimReferrerRewardsSchema |
| Activate by code | achievementClaim (15/min) | Telegram | ActivateByCodeBodySchema |
| Get leaderboard | general (100/min) | Telegram | GetReferralLeaderboardSchema |
См. Security Matrix для полного обзора защит.
Edge Cases
Что видит пользователь (UI):
| Ситуация | UI поведение |
|---|---|
| Самореферал | Блокируется, ошибка "Cannot create self-referral" |
| Повторная регистрация | Реферал уже существует, возвращается существующий |
| Истекшая сессия | 24ч прошло — пользователь регистрируется без реферера |
| Нет кодов | При запросе /my автоматически создаётся код |
| Passive income = 0 | Карточка claim не показывается, вместо неё — зелёная статистика (если был доход ранее) |
| Passive income > 0 | Золотая карточка с суммой и кнопкой "Забрать" |
| Double-click claim | Optimistic locking → ошибка CONCURRENT_CLAIM, UI показывает 0 |
| Нет активного сезона | Claim заблокирован middleware requireActiveSeason |
| Welcome bonus: dismiss | Модалка закрывается (React state), появится снова при следующем открытии |
| Welcome bonus: already claimed | 400 "Welcome bonus already claimed" |
| Welcome bonus: no referral | 400 "No referral found for this user" — модалка не показывается |
| Welcome bonus + PromoCode | Взаимоисключающие: UTM-пользователь не имеет реферала, и наоборот |
| Welcome bonus: сезон без SCRAP-наград | bonusAmount = 0 в preview (GET /welcome-bonus) — если joinReferredRewards содержит только XP/Item/Case |
| Referrer rewards: сезон без XP-наград | xpAmountPerReferral = 0 в preview (GET /referrer-rewards) — если joinReferrerRewards содержит только Scrap/Item/Case |
| Join rewards не настроены | joinReferredRewards = [] или joinReferrerRewards = [] → claimReferredReward / claimAllReferrerRewards всё равно выставляют referralRewardClaimed/referrerRewardClaimed = true (только один раз) |
| Referrer: 0 unclaimed rewards | GET /referrer-rewards → count: 0, модалка не показывается |
| Referrer: 1 unclaimed reward | Заголовок "Друг присоединился!" (единственное число) |
| Referrer: N unclaimed rewards | Заголовок "{N} друзей присоединились!" |
| Друг активировался пока модалка открыта | claimAllReferrerRewards захватит и нового (запрос в момент клейма) |
| Referrer: двойной клик "Забрать" | isClaiming блокирует повторный клик; updateMany WHERE referrerRewardClaimed = false идемпотентен |
| Referrer: dismiss | Модалка закрывается (React state), появится снова при следующем открытии |
| Referrer: ошибка сети при claim | Показывается ошибка, кнопка «Забрать всё» остаётся активной |
| Leaderboard: нет активного сезона | Компонент <ReferralLeaderboard /> возвращает null, секция скрыта |
Leaderboard: referralRewards не настроены | Компонент возвращает null, даже если сезон активен |
| Leaderboard: пользователь не в топ-10 | Показывается отдельной строкой в футере с его фактическим рангом |
| Leaderboard: нет участников (passiveScrapEarned = 0 у всех) | Пустые слоты ("Свободно") для всех 10 позиций |
| Activate-by-code: невалидный код | 400 INVALID_CODE — «Invalid referral code» |
| Activate-by-code: самореферал | 400 SELF_REFERRAL — «Нельзя использовать свой код» |
| Activate-by-code: уже приглашён | 400 ALREADY_REFERRED — «You already have a referrer» |
| Activate-by-code: успех | Создаётся InviteSession(PROMO_CODE) + Referral, показывается нейтральная модалка с наградами |
Backend Error Codes (для API/тестов)
| Код | HTTP | Сообщение |
|---|---|---|
| Self-referral | 400 | "Cannot create self-referral" |
| Referral exists | 200 | Возвращает существующий referral |
| Code not found | 404 | "Referral code not found" |
| Access denied | 403 | "You can only view analytics for your own referral codes" |
| Welcome bonus not found | 400 | "No referral found for this user" |
| Welcome bonus claimed | 400 | "Welcome bonus already claimed" |
| Invalid referral code | 400 | INVALID_CODE — "Invalid referral code" |
| Self-referral (by code) | 400 | SELF_REFERRAL — "Cannot use your own referral code" |
| Already referred (by code) | 400 | ALREADY_REFERRED — "You already have a referrer" |
3. ADR (Architectural Decisions)
Почему InviteSession, а не прямая привязка?
Проблема: Пользователь кликает по реферальной ссылке ДО регистрации в боте. Нужно сохранить информацию о реферере до момента создания аккаунта.
Решение: Промежуточная сущность InviteSession с lifecycle:
- PENDING → ACTIVATED (при регистрации)
- PENDING → EXPIRED (через 24ч)
- ACTIVATED → USER_RESET (при сбросе данных пользователем / GDPR)
Альтернативы (отклонены):
- Cookie/localStorage — не работает в Telegram Mini App
- Передача кода в start_param без сессии — теряется аналитика кликов
Последствия: Полная аналитика конверсий (клик → регистрация), TTL защищает от бесконечных PENDING сессий.
Почему Referrer XP дефёрится в модалку, а не начисляется немедленно?
Проблема: При автоматическом начислении XP рефереру при активации пользователь получал только Telegram пуш «Вы получили +100 XP» — без визуального фидбека в TMA. Пользователь не чувствовал награду.
Решение: Дефёрить XP в агрегированную ReferrerRewardModal (по аналогии с Welcome Bonus для приглашённого):
- При активации реферала начисляется всё, кроме XP:
friendsInvited++, квесты, достижения, Feed event - XP откладывается до явного клейма в модалке
- Если активировалось несколько друзей — одна модалка, один батч-клейм
- Telegram пуш изменён: «Заходи забрать награду 🎁» (зовёт в TMA, а не объявляет готовый XP)
Альтернативы (отклонены):
- Авто-начисление + toast — пользователь легко пропускает, нет осознанного действия
- Авто-начисление + анимация в TMA при следующем открытии — сложнее реализовать без гонки состояний
Последствия:
- Симметричный паттерн с Welcome Bonus (приглашённый получает Scrap через модалку, реферер — XP через модалку)
- Агрегация: несколько друзей = одна модалка (не N всплывающих)
referrerRewardClaimedполе вReferralиспользуется для обоих целей: трекинг XP (для реферера) + идемпотентность клейма
Почему Passive Income в % а не фиксированный бонус?
Проблема: Как мотивировать рефереров после одноразового бонуса?
Решение: 10% от заработка реферала. Это создаёт:
- Мотивацию приглашать активных друзей
- Интерес к успеху приглашённых (помогать им)
Альтернативы (отклонены):
- Фиксированный бонус за каждое действие реферала — сложно балансировать
Последствия: Простая понятная механика.
Почему Passive Income Accumulate + Claim, а не Auto-Credit?
Проблема: При автоматическом зачислении пассивного дохода пользователь не видит, сколько именно приносят рефералы. Это снижает ощущение ценности реферальной программы.
Решение: Доход копится в буфере passiveIncomeBalance. Пользователь видит сумму и забирает вручную:
- Золотая карточка в ReferralModal показывает накопленную сумму
- Кнопка "Забрать" с haptic feedback и TopFloatingReward анимацией
- Optimistic locking защищает от race conditions
Альтернативы (отклонены):
- Auto-credit при каждом заработке реферала — пользователь не замечает начислений
- Ежедневная автовыплата — сложнее, не даёт контроль пользователю
Последствия:
- Пользователь видит конкретную пользу от рефералов
- Геймификация: приятный момент "забрать" награду
- При смене сезона — auto-claim в
scrapTotalEarned(ничего не теряется)
Почему Welcome Bonus модалка, а не авто-начисление?
Проблема: При автоматическом начислении scrap при активации реферальной сессии пользователь не замечал награду. Toast-уведомление легко пропустить, и новичок не понимал, что получил бонус за приглашение.
Решение: Явный UI-флоу через ReferralWelcomeModal:
- При активации начисляется только XP рефереру (немедленно)
- Scrap приглашённому откладывается до нажатия кнопки «Забрать» в модалке (
referralRewardClaimed = true,referralRewardClaimedAt = now()) - Модалка появляется при каждом открытии приложения, пока бонус не забран
Альтернативы (отклонены):
- Авто-начисление + toast — пользователь не замечает, нет осознанного действия
- Авто-начисление + splash screen — сложнее, не даёт контроль пользователю
Последствия:
- Пользователь осознанно получает бонус (геймификация)
- Симметричный паттерн с PromoCodeModal (UTM-пользователи получают награду через промокод)
- Dismiss = React state (модалка пропадает, при следующем открытии — снова)
Почему метрика лидерборда — passiveScrapEarned, а не количество друзей?
Проблема: Простой рейтинг по количеству рефералов мотивирует спам-инвайтинг неактивных пользователей (фейков).
Решение: Ранжировать по сумме passiveScrapEarned (сколько passive scrap принесли все друзья реферера за сезон):
- Passive income начисляется только при реальной активности друга (scrap earning через квизы, кейсы, квесты)
- Фейковые/неактивные аккаунты не генерируют scrap → 0 passive income → не влияют на ранг
- При равенстве passiveScrapEarned — второй критерий: количество друзей (friendsCount)
Альтернативы (отклонены):
- COUNT friends — мотивирует спам, фейки влияют напрямую
- SUM(scrap earned by friends) напрямую — сложнее получить (нужна агрегация транзакций), passiveScrapEarned уже хранится в
Referral.passiveScrapEarned
Последствия: Антигейминг встроен в механику, без дополнительных проверок. Топ отражает рефереров с действительно активными друзьями.
Почему Passive Income ограничен сезоном?
Бесконечный пассивный доход создаёт неконтролируемую инфляцию и снижает ценность активной игры.
Проблема: Пользователь, пригласивший 100 друзей год назад, получает огромный пассивный доход, не прилагая усилий. Это создаёт:
- Перекос в экономике (ранние игроки слишком богаты)
- Снижение мотивации к активной игре
- Неконтролируемую инфляцию
Решение: Пассивный доход работает только в сезоне создания реферала.
- При создании реферала сохраняется
seasonId PassiveIncomeServiceпроверяет:referral.seasonId === currentSeason.id- Связь реферер → приглашённый остаётся навсегда (для статистики)
Referral.passiveScrapEarnedхранит lifetime total (не сбрасывается)UserSeasonStats.scrapFromReferralsсбрасывается каждый сезон (новая запись USS)
Альтернативы (отклонены):
- Passive income навсегда — создаёт инфляцию и перекос
- Ограничение по времени (30 дней) — не привязано к игровым циклам
Последствия:
- Чёткая привязка к игровым сезонам
- Мотивация приглашать друзей каждый сезон
- Контролируемая экономика
- UI показывает
isPassiveIncomeActiveфлаг для каждого реферала
Почему per-season конфигурация наград, а не глобальная?
Проблема: Награды за приглашение (join rewards) были хардкоднуты в REFERRAL_CONFIG. Нужно сделать их настраиваемыми.
Решение: Per-season подход — Season.joinReferrerRewards / Season.joinReferredRewards:
Referral.seasonIdуже существует — рефералы уже привязаны к сезону- Season record иммутабелен после закрытия →
referral.seasonId → season.joinReferrerRewards— иммутабельная ссылка - Snapshot не нужен — сезон хранит конфигурацию, referral ссылается на сезон
- Copy-from-last season (уже реализован в wizard) решает проблему ручного ввода каждый раз
- Approval gate в wizard предотвращает запуск без настроенных наград
Альтернативы (отклонены):
- GlobalSettings — нет привязки к сезону, сложнее версионировать
- Snapshot полей на Referral (
referrerRewardSnapshot,referredRewardSnapshot) — избыточно, сезон уже хранит данные иммутабельно
Последствия: Семантически точнее: «какие награды за приглашение в этом сезоне» — это сезонная политика. Разные рефералы из разных сезонов получат награды по конфигурации своего сезона.
Почему activationSource на InviteSession?
Проблема: С добавлением ручного ввода реферального кода (promo-code-like) нужно различать как пользователь активировался: по ссылке (deeplink) или ввёл код вручную.
Решение: Поле InviteSession.activationSource (REF_DEEPLINK | UTM_DEEPLINK | PROMO_CODE):
REF_DEEPLINK— пользователь перешёл по реферальной ссылкеUTM_DEEPLINK— пользователь перешёл по UTM ссылкеPROMO_CODE— пользователь вручную ввёл реферальный код
Последствия:
- Тип модалки зависит от
activationSource: REF_DEEPLINK → персонализированная («{name} пригласил тебя!»), PROMO_CODE → нейтральная («Код принят!») - Аналитика конверсии по каналам привлечения
Почему timestamps в Referral, а не в FeedEvent?
Проблема: Нужно знать «когда именно» реферер и приглашённый забрали свои награды. Казалось бы, для этого можно использовать уже существующий FeedEvent.REFERRAL_BONUS.
Решение: Хранить timestamps прямо в Referral (referrerRewardClaimedAt, referralRewardClaimedAt):
FeedEvent.REFERRAL_BONUSсоздаётся при активации реферала (когда друг регистрируется)- Фактический claim происходит позже — в модалках
ReferrerRewardModal/ReferralWelcomeModal - Это два разных момента времени
Альтернативы (отклонены):
- Поиск в FeedEvent — неверное время (момент регистрации, а не клейма)
- Отдельная таблица событий claim — избыточно, лишняя сущность
Последствия: Минимальные изменения схемы (2 nullable поля), данные привязаны к записи Referral, нет N+1 при чтении.
Почему referrals вынесены в parallel query в getUserDetailed?
Проблема: Исходный prisma.user.findUnique({ include: { referrals: ... } }) возвращает User[] — поля модели User, а не модели Referral. Нужны поля именно Referral-модели: claim flags, timestamps, passiveScrapEarned, seasonId.
Решение: Выделить в отдельный parallel запрос prisma.referral.findMany({ where: { referrerId } }), по аналогии с уже существующим seasonReferralsRaw паттерном.
Альтернативы (отклонены):
- Nested include глубже — Prisma не позволяет получить поля junction-записи через
include.referrals - Добавить поля в
User.referralsrelation — нарушило бы существующий контракт
Последствия: Запросы батчируются через Promise.all, нет N+1. Структура getUserDetailed явно разделяет запросы по смыслу.
Почему reward images резолвятся на backend?
Проблема: Сезонные награды хранятся как RewardConfig[] в JSON-поле (тип CASE/ITEM с caseId/itemId). Чтобы показать иконку, нужно знать imageUrl конкретного кейса или предмета.
Решение: Backend резолвит imageUrl единым batch-запросом при построении getUserDetailed:
- Собрать все уникальные
caseIdиitemIdиз всех наград всех рефералов - Один
findManyдляCase, один дляItem - Синхронно смаппить
buildRewardsFromJson()без доп. запросов в DB
Альтернативы (отклонены):
- Frontend сам резолвит по
caseId→ N дополнительных API-запросов - Frontend хранит маппинг
caseId → imageUrl— frontend не должен знать про Static URL структуру
Последствия: Zero N+1, frontend получает готовый RewardConfigWithImage[] с полями imageUrl и name.
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| ReferralService | backend/src/domains/referrals/services/referral.service.ts | CRUD для Referral связей |
| ReferralCodeService | backend/src/domains/referrals/services/referral-code.service.ts | Управление кодами, генерация URL |
| ReferralRewardService | backend/src/domains/referrals/services/referral-reward.service.ts | Начисление наград при активации |
| PassiveIncomeService | backend/src/domains/referrals/services/passive-income.service.ts | Расчёт и начисление пассивного дохода |
| ReferralCodesController | backend/src/domains/referrals/controllers/referral-codes.controller.ts | User API для кодов |
| ReferralStatsController | backend/src/domains/referrals/controllers/referral-stats.controller.ts | Admin API: статистика, конверсия, топ-рефереры, тренды |
| UserReferrals Routes | backend/src/domains/referrals/routes/user-referrals.routes.ts | User API для статистики |
| ReferralAnalyticsService | backend/src/domains/referrals/services/referral-analytics.service.ts | Аналитика рефералов (графики, статистика по периодам) |
| ReferralAdminService | backend/src/domains/referrals/services/referral-admin.service.ts | Admin CRUD: поиск, пагинация, управление статусами рефералов |
| ReferralShortLinkService | backend/src/domains/referrals/services/referral-short-link.service.ts | Генерация коротких ссылок для рефералов |
| ReferralCodeValidator | backend/src/domains/referrals/validators/referral-code.validator.ts | Валидация формата и уникальности реферальных кодов |
| BatchLoadingUtils | backend/src/domains/referrals/utils/batch-loading.utils.ts | Batch loading для решения N+1 проблемы (загрузка referrer info) |
| ReferralUrlUtils | backend/src/domains/referrals/utils/referral-url.utils.ts | Генерация реферальных URL |
| Messages Constants | backend/src/domains/referrals/constants/messages.ts | Константы сообщений об ошибках домена |
| Referral Types | backend/src/domains/referrals/types/referral.types.ts | Типы: аналитика, создание кодов, отслеживание кликов |
| Admin Routes | backend/src/domains/referrals/routes/admin-referrals.routes.ts | Admin API |
| useReferralWelcome | frontend/src/hooks/useReferralWelcome.ts | Хук для Welcome Bonus: проверка и claim |
| ReferralWelcomeModal | frontend/src/components/ui/ReferralWelcomeModal.tsx | Модалка Welcome Bonus для реферальных пользователей |
| useReferrerRewards | frontend/src/hooks/useReferrerRewards.ts | Хук для Referrer Rewards: проверка незабранного XP и batch claim |
| ReferrerRewardModal | frontend/src/components/ui/ReferrerRewardModal.tsx | Модалка для рефереров: список друзей + «Забрать всё» |
| Referral API Client | frontend/src/services/referral.api.ts | API client для welcome-bonus и referrer-rewards endpoints |
| Referral Types (FE) | frontend/src/types/referral.types.ts | Frontend типы для Welcome Bonus, Referrer Rewards и Leaderboard API |
| ReferralLeaderboardService | backend/src/domains/referrals/services/referral-leaderboard.service.ts | Агрегация топ-рефереров по passiveScrapEarned; getLeaderboard(), getTopReferrers() для раздачи наград |
| Referral Leaderboard Types | backend/src/domains/referrals/types/referral-leaderboard.types.ts | ReferralLeaderboardEntry, ReferralLeaderboardResponse |
| ReferralLeaderboard | frontend/src/components/referrals/ReferralLeaderboard.tsx | UI компонент лидерборда (pixel-perfect по SeasonModal); скрыт если нет season.referralRewards |
| useReferralLeaderboard | frontend/src/hooks/useReferralLeaderboard.ts | React Query hook для GET /api/referrals/leaderboard, staleTime: 60s |
В домене также присутствуют файлы для alerting и metrics (referral-alerting.service.ts, referral-metrics.service.ts и соответствующие routes/controllers), но они НЕ зарегистрированы в server.ts и не активны. Эти компоненты предназначены для мониторинга аномалий (низкая конверсия, высокий error rate) и интеграции с Prometheus/OpenTelemetry, но пока не подключены.
Bootstrap Integration
Referral data is preloaded via /api/bootstrap endpoint during splash screen to eliminate loading states in UI.
Included in Bootstrap:
- Referral Config — static configuration (passiveIncomePercent)
- User Stats — totalReferrals, activeReferrals, totalPassiveScrapEarned
- My Code — user's referral code and URL (null if not created yet)
- My Referrer — information about who invited the user (null if no referrer)
- User Profile —
passiveIncomeBalance(для отображения кнопки claim в ReferralModal)
Cache Strategy:
staleTime: 5-10 minutes(data rarely changes)- React Query cache keys:
['referralStats'],['referralConfig'],['myReferralCode'],['myReferrer'] - Hydrated via
hydrateReferralData()inbootstrapHydration.ts
Backend Optimization:
getUserReferralStats()uses Prisma aggregation instead offindMany()- Performance: 50KB → 12 bytes, 20ms → 3ms
- Method:
Promise.all([count(), count(), aggregate()])
User Experience:
- Before: ReferralModal showed loading skeleton (200-400ms)
- After: Instant modal opening with 0ms delay (native app effect)
Implementation:
// Backend: BootstrapService.getReferralBootstrapData()
const [stats, userCodes, referralRelation] = await Promise.all([
this.referralService.getUserReferralStats(userId),
this.referralCodeService.getUserReferralCodes(userId),
this.referralService.getReferralByReferredId(userId),
]);
// Frontend: bootstrapHydration.ts
queryClient.setQueryData(['referralStats'], data.stats);
queryClient.setQueryData(['referralConfig'], data.config);
queryClient.setQueryData(['myReferralCode'], data.myCode);
queryClient.setQueryData(['myReferrer'], data.myReferrer);
Admin Panel: getUserDetailed Enrichment
GET /admin/users/:id (user-management) возвращает расширенные реферальные данные:
Воронка конверсии (referralCode):
| Поле | Источник | Описание |
|---|---|---|
clicksCount | ReferralCode.clicksCount | Кликнуло по ссылке |
appOpensCount | ReferralCode.appOpensCount | Открыло TMA (authenticated) |
activatedViaLink | InviteSession.count (REF_DEEPLINK + ACTIVATED) | Зарегистрировалось через реферальную ссылку |
activatedViaCode | InviteSession.count (PROMO_CODE + ACTIVATED) | Ввело реферальный код вручную |
activatedViaLink считает через InviteSession ← ReferralClickAnalytics.referralCodeId. activatedViaCode считает через InviteSession.metadata.referrerUserId = userId (PROMO_CODE не создаёт ReferralClickAnalytics).
Данные по каждому рефералу (referrals[]):
referrerRewardClaimed/referrerRewardClaimedAt— забрал ли реферер и когдаreferralRewardClaimed/referralRewardClaimedAt— забрал ли приглашённый и когдаpassiveScrapEarned— накопленный пассивный доход с этого рефералаjoinRewardsForReferrer: RewardConfigWithImage[]— награды реферера с резолвнутымиimageUrl
Данные о рефере (referredBy):
referralRewardClaimed/referralRewardClaimedAt— статус claim текущего пользователяjoinRewardsForReferred: RewardConfigWithImage[]— что получил приглашённый за вступление
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| ReferralCode | Уникальный код пользователя для приглашений | userId (unique), code (unique), clicksCount, appOpensCount, isActive |
| InviteSession | Сессия от клика до регистрации | telegramId, state (PENDING/ACTIVATED/EXPIRED/USER_RESET), metadata (JSON с referralCode), expiresAt, activationSource (REF_DEEPLINK / UTM_DEEPLINK / PROMO_CODE) |
| ReferralClickAnalytics | Детальная аналитика кликов | referralCodeId, telegramHash, inviteSessionId, clickedAt |
| Referral | Связь реферер → приглашённый | referrerId, referredId (unique), seasonId, referrerRewardClaimed, referralRewardClaimed, passiveScrapEarned, referrerRewardClaimedAt (DateTime? — когда реферер забрал), referralRewardClaimedAt (DateTime? — когда приглашённый забрал) |
| Season (поля) | Конфигурация наград при вступлении | joinReferrerRewards (JSON: RewardConfig[]) — награды рефереру, joinReferredRewards (JSON: RewardConfig[]) — награды приглашённому |
| User (поля) | Буфер пассивного дохода | passiveIncomeBalance (Int, default 0) — накапливается автоматически, обнуляется при claim |
Relationships
Key Constraints
Referral.referredId— UNIQUE (один пользователь = один реферер навсегда)ReferralCode.userId— UNIQUE (один пользователь = один код)InviteSession— UNIQUE по[telegramId, type](один PENDING на type)
6. API Endpoints
- User: Codes
- User: Stats
- Admin
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| POST | /api/referral-codes/create | Создать реферальный код | → |
| GET | /api/referral-codes/:code | Получить информацию о коде | → |
| GET | /api/referral-codes/my | Мои реферальные коды | → |
| GET | /api/referral-codes/:code/analytics | Аналитика кода (владелец) | → |
| GET | /api/referral-codes/user/codes | Коды пользователя (алиас /my) | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /api/referrals/my-stats | Моя реферальная статистика | → |
| GET | /api/referrals/my-referrals | Список приглашённых | → |
| GET | /api/referrals/my-referrer | Кто меня пригласил | → |
| GET | /api/referrals/config | Публичная конфигурация | → |
| POST | /api/referrals/claim-passive-income | Забрать накопленный пассивный доход | → |
| GET | /api/referrals/welcome-bonus | Проверить наличие незабранного Welcome Bonus | → |
| POST | /api/referrals/claim-welcome-bonus | Забрать реферальный Welcome Bonus (scrap) | → |
| GET | /api/referrals/referrer-rewards | Проверить наличие незабранных XP наград за приглашённых | → |
| POST | /api/referrals/claim-referrer-rewards | Забрать все XP награды за приглашённых друзей (batch) | → |
| POST | /api/referrals/activate-by-code | Активация реферала по коду (promo-code-like ввод) | → |
| GET | /api/referrals/leaderboard?limit=10 | Сезонный лидерборд рефереров (топ по passiveScrapEarned) | → |
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/referrals | Список всех рефералов с пагинацией | → |
| GET | /admin/referrals/stats | Статистика реферальной системы (с периодами) | → |
| GET | /admin/referrals/search | Поиск пользователей по имени/коду | → |
| GET | /admin/referrals/codes | Все реферальные коды | → |
| GET | /admin/referrals/codes/:code/analytics | Детальная аналитика по коду | → |
| PUT | /admin/referrals/codes/:code/status | Обновить статус кода (activate/deactivate) | → |
| DELETE | /admin/referrals/codes/:code | Удалить реферальный код | → |
| GET | /admin/referrals/export | Экспорт данных рефералов (CSV/JSON) | → |
Admin API в development режиме возвращает mock данные (только / и /stats).
7. Configuration
Пассивный доход (config.ts)
Конфигурация из backend/src/domains/referrals/config.ts:
| Параметр | Дефолт | ENV Variable | Описание |
|---|---|---|---|
PASSIVE_INCOME_PERCENT | 10 | REFERRAL_PASSIVE_INCOME_PERCENT | Процент пассивного дохода |
Изменения применяются при рестарте backend без перекомпиляции.
Join Rewards (Season Setup)
Награды при вступлении реферала настраиваются через Season Setup Wizard → Step "Referral Join Rewards" и хранятся в полях Season:
| Поле | Тип | Описание |
|---|---|---|
Season.joinReferrerRewards | RewardConfig[] (JSON) | Награды рефереру (тому, кто пригласил) |
Season.joinReferredRewards | RewardConfig[] (JSON) | Награды приглашённому пользователю |
RewardConfig поддерживает типы: SCRAP, XP, STREAK_POINTS, CASE (с caseId), ITEM (с itemId).
RewardConfigWithImage
При отображении наград в Admin Panel backend резолвит RewardConfig в расширенный тип RewardConfigWithImage:
| Поле | Тип | Описание |
|---|---|---|
type | 'SCRAP' | 'XP' | 'STREAK_POINTS' | 'CASE' | 'ITEM' | Тип награды |
amount | number | Количество |
itemId | string | null | ID предмета (для ITEM) |
caseId | string | null | ID кейса (для CASE) |
imageUrl | string | null | Резолвнутый полный URL изображения |
name | string | null | Название кейса или предмета |
Резолв происходит единым batch-запросом без N+1 (см. ADR выше).
Старые хардкоды REFERRAL_CONFIG.BONUS_XP и REFERRAL_CONFIG.BONUS_SCRAP_REFERRED полностью удалены из join reward логики. Все начисления читаются из Season.joinReferrerRewards / Season.joinReferredRewards. Если поля пустые ([]) — награды не выдаются, но флаги referrerRewardClaimed/referralRewardClaimed всё равно выставляются (идемпотентность). REFERRAL_CONFIG.PASSIVE_INCOME_PERCENT не затронут — это отдельный механизм.
8. Related
- Activation — домен InviteSession (создание и активация сессий)
- Quests — SOCIAL квесты на приглашение друзей
- Achievements — SOCIAL достижения за рефералов
- Live Feed — события REFERRAL_BONUS в ленте
- Users — поле
friendsInvitedв профиле - UTM Tracking — альтернативная система отслеживания (маркетинг)