Skip to main content

Referrals System

1. Summary

Goal: Механизм привлечения новых пользователей через систему реферальных ссылок с двусторонними наградами и пассивным доходом для реферера.

User Value: Возможность получить бонус за приглашение друзей и пассивный доход (10%) от заработка приглашённых пользователей до конца текущего сезона. Путь: Поделился ссылкой → Друг зарегистрировался → Оба получили бонус → Реферер получает % от заработка друга (в течение сезона).


2. Business Logic

Reward Types

Кто получает: Пользователь, который пригласил

Одноразовая награда: +100 XP (при активации реферала)

Пассивный доход: 10% от каждого заработка Scrap приглашённого друга (до конца сезона)

Сезонное ограничение

Пассивный доход работает только в том сезоне, когда был создан реферал. После смены сезона реферер перестаёт получать % от заработка приглашённого.

Дополнительно: +1 к счётчику friendsInvited, прогресс квестов SOCIAL

Invite Flow

Алгоритм приглашения (реализован в ReferralCodeService + InviteSessionService + ActivationRewardService). Подробнее о сессиях см. Activation.

1. Генерация ссылки

  • Пользователь получает уникальный реферальный код (авто-генерация или кастомный)
  • Share URL: https://start.goloot.online/ref_CODE (красивый превью в соцсетях)
  • Redirect-service перенаправляет на: https://t.me/bot/app?startapp=ref_CODE

2. Переход по ссылке

InviteSession Flow
  1. Клик по ссылке → создаётся InviteSession (state: PENDING)
  2. Записывается ReferralClickAnalytics (для аналитики конверсий)
  3. Session живёт 72 часа, затем EXPIRED

3. Регистрация и активация

  • При регистрации проверяется наличие PENDING сессии по telegramId
  • Создаётся запись Referral (связь referrer → referred)
  • Session переходит в state: ACTIVATED
  • Выдаются награды обеим сторонам

4. Passive Income (Accumulate + Claim)

Модель: 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, scrapTotalEarned += claimAmount, scrapFromReferrals += claimAmount, passiveIncomeBalance = 0
  • Обновляет сезонную статистику через updateSeasonScrap()

Season Reset Auto-Claim:

  • При смене сезона невостребованный passiveIncomeBalance автоматически зачисляется в scrapTotalEarned (lifetime stat) через raw SQL
  • Пользователь не теряет накопленный доход

Protection

ДействиеRate LimitAuthValidation
Create codegeneral (100/min)TelegramCreateReferralCodeSchema
Get code infogeneral (100/min)NoneReferralCodeParamsSchema
Get analyticsgeneral (100/min)TelegramReferralAnalyticsQuerySchema
Get my codesgeneral (100/min)Telegram-
Get my statsgeneral (100/min)TelegramGetMyReferralStatsSchema
Claim passive incomeachievementClaim (15/min)Telegram + Active Season-
Детали реализации

См. Security Matrix для полного обзора защит.

Edge Cases

Что видит пользователь (UI):

СитуацияUI поведение
СаморефералБлокируется, ошибка "Cannot create self-referral"
Повторная регистрацияРеферал уже существует, возвращается существующий
Истекшая сессия72ч прошло — пользователь регистрируется без реферера
Нет кодовПри запросе /my автоматически создаётся код
Passive income = 0Карточка claim не показывается, вместо неё — зелёная статистика (если был доход ранее)
Passive income > 0Золотая карточка с суммой и кнопкой "Забрать"
Double-click claimOptimistic locking → ошибка CONCURRENT_CLAIM, UI показывает 0
Нет активного сезонаClaim заблокирован middleware requireActiveSeason
Backend Error Codes (для API/тестов)
КодHTTPСообщение
Self-referral400"Cannot create self-referral"
Referral exists200Возвращает существующий referral
Code not found404"Referral code not found"
Access denied403"You can only view analytics for your own referral codes"

3. ADR (Architectural Decisions)

Почему InviteSession, а не прямая привязка?

Проблема: Пользователь кликает по реферальной ссылке ДО регистрации в боте. Нужно сохранить информацию о реферере до момента создания аккаунта.

Решение: Промежуточная сущность InviteSession с lifecycle:

  • PENDING → ACTIVATED (при регистрации)
  • PENDING → EXPIRED (через 72ч)

Альтернативы (отклонены):

  • Cookie/localStorage — не работает в Telegram Mini App
  • Передача кода в start_param без сессии — теряется аналитика кликов

Последствия: Полная аналитика конверсий (клик → регистрация), TTL защищает от бесконечных PENDING сессий.

Почему Passive Income в % а не фиксированный бонус?

Проблема: Как мотивировать рефереров после одноразового бонуса?

Решение: 10% от заработка реферала. Это создаёт:

  • Мотивацию приглашать активных друзей
  • Интерес к успеху приглашённых (помогать им)

Альтернативы (отклонены):

  • Фиксированный бонус за каждое действие реферала — сложно балансировать

Последствия: Простая понятная механика.

Почему Passive Income Accumulate + Claim, а не Auto-Credit?

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

Решение: Доход копится в буфере passiveIncomeBalance. Пользователь видит сумму и забирает вручную:

  • Золотая карточка в ReferralModal показывает накопленную сумму
  • Кнопка "Забрать" с haptic feedback и TopFloatingReward анимацией
  • Optimistic locking защищает от race conditions

Альтернативы (отклонены):

  • Auto-credit при каждом заработке реферала — пользователь не замечает начислений
  • Ежедневная автовыплата — сложнее, не даёт контроль пользователю

Последствия:

  • Пользователь видит конкретную пользу от рефералов
  • Геймификация: приятный момент "забрать" награду
  • При смене сезона — auto-claim в scrapTotalEarned (ничего не теряется)

Почему Passive Income ограничен сезоном?

Баланс экономики

Бесконечный пассивный доход создаёт неконтролируемую инфляцию и снижает ценность активной игры.

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

  • Перекос в экономике (ранние игроки слишком богаты)
  • Снижение мотивации к активной игре
  • Неконтролируемую инфляцию

Решение: Пассивный доход работает только в сезоне создания реферала.

  • При создании реферала сохраняется seasonId
  • PassiveIncomeService проверяет: referral.seasonId === currentSeason.id
  • Связь реферер → приглашённый остаётся навсегда (для статистики)
  • Referral.passiveScrapEarned хранит lifetime total (не сбрасывается)
  • User.scrapFromReferrals сбрасывается каждый сезон (сезонная статистика)

Альтернативы (отклонены):

  • Passive income навсегда — создаёт инфляцию и перекос
  • Ограничение по времени (30 дней) — не привязано к игровым циклам

Последствия:

  • Чёткая привязка к игровым сезонам
  • Мотивация приглашать друзей каждый сезон
  • Контролируемая экономика
  • UI показывает isPassiveIncomeActive флаг для каждого реферала

4. Architecture

Services Overview

Key Components

КомпонентПутьОписание
ReferralServicebackend/src/domains/referrals/services/referral.service.tsCRUD для Referral связей
ReferralCodeServicebackend/src/domains/referrals/services/referral-code.service.tsУправление кодами, генерация URL
ReferralRewardServicebackend/src/domains/referrals/services/referral-reward.service.tsНачисление наград при активации
PassiveIncomeServicebackend/src/domains/referrals/services/passive-income.service.tsРасчёт и начисление пассивного дохода
ReferralCodesControllerbackend/src/domains/referrals/controllers/referral-codes.controller.tsUser API для кодов
MyReferrals Routesbackend/src/domains/referrals/routes/my-referrals.routes.tsUser API для статистики
Admin Routesbackend/src/domains/referrals/routes/admin-referrals.routes.tsAdmin API

Bootstrap Integration

Performance Optimization

Referral data is preloaded via /api/bootstrap endpoint during splash screen to eliminate loading states in UI.

Included in Bootstrap:

  • Referral Config — static configuration (bonusXp, bonusScrapReferred, 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 ProfilepassiveIncomeBalance (для отображения кнопки claim в ReferralModal)

Cache Strategy:

  • staleTime: 5-10 minutes (data rarely changes)
  • React Query cache keys: ['referralStats'], ['referralConfig'], ['myReferralCode'], ['myReferrer']
  • Hydrated via hydrateReferralData() in bootstrapHydration.ts

Backend Optimization:

  • getUserReferralStats() uses Prisma aggregation instead of findMany()
  • 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);

5. Database Schema

Models

МодельОписаниеКлючевые поля
ReferralCodeУникальный код пользователя для приглашенийuserId (unique), code (unique), clicksCount, isActive
InviteSessionСессия от клика до регистрацииtelegramId, state (PENDING/ACTIVATED/EXPIRED), metadata (JSON с referralCode), expiresAt
ReferralClickAnalyticsДетальная аналитика кликовreferralCodeId, telegramHash, inviteSessionId, clickedAt
ReferralСвязь реферер → приглашённыйreferrerId, referredId (unique), seasonId, bonusXpClaimed, bonusScrapClaimed, passiveScrapEarned
User (поля)Буфер пассивного доходаpassiveIncomeBalance (Int, default 0) — накапливается автоматически, обнуляется при claim

Relationships

Key Constraints

  • Referral.referredId — UNIQUE (один пользователь = один реферер навсегда)
  • ReferralCode.userId — UNIQUE (один пользователь = один код)
  • InviteSession — UNIQUE по [telegramId, type] (один PENDING на type)

6. API Endpoints

МетодЭндпоинтОписаниеDocs
POST/api/referral-codes/createСоздать реферальный код
GET/api/referral-codes/:codeПолучить информацию о коде
GET/api/referral-codes/myМои реферальные коды
GET/api/referral-codes/:code/analyticsАналитика кода (владелец)

7. Configuration

Конфигурация из backend/src/domains/referrals/config.ts:

ПараметрДефолтENV VariableОписание
BONUS_XP100REFERRAL_BONUS_XPXP награда рефереру
BONUS_SCRAP_REFERRED500REFERRAL_BONUS_SCRAP_REFERREDScrap награда приглашённому
PASSIVE_INCOME_PERCENT10REFERRAL_PASSIVE_INCOME_PERCENTПроцент пассивного дохода
BONUS_SCRAP_UTM200UTM_BONUS_SCRAPБонус Scrap для UTM кампаний

Изменения применяются при рестарте backend без перекомпиляции.


  • Activation — домен InviteSession (создание и активация сессий)
  • Quests — SOCIAL квесты на приглашение друзей
  • Achievements — SOCIAL достижения за рефералов
  • Live Feed — события REFERRAL_BONUS в ленте
  • Users — поле friendsInvited в профиле
  • UTM Tracking — альтернативная система отслеживания (маркетинг)