Promo Codes
1. Summary
Goal: Система промокодов для маркетинговых кампаний — позволяет создавать коды с различными типами наград и ограничениями.
User Value: Пользователь получает бонусы (валюту, опыт, предметы, купоны на кейсы) за активацию кодов от блогеров, партнёров или в рамках акций.
2. Business Logic
Multi-Reward System
Каждый промокод содержит 1-4 награды в JSON массиве rewards. Награды выдаются атомарно в одной $transaction.
Типы наград:
| Тип | Описание | Действие | Max на код |
|---|---|---|---|
SCRAP | Валюта | addScrap(amount) | 1 |
XP | Опыт | addUserXP(amount) | 1 |
STREAK_POINTS | SP для розыгрышей | user.streakPoints += amount | ∞ |
ITEM | Предмет (Fragment, Blueprint, Buff) | Upsert в UserInventory (sourceType: PROMO_CODE) | ∞ (разные itemId) |
CASE | Купон на кейс | Upsert в UserFreeCaseOpens (инкремент count) | ∞ (разные caseId) |
Структура награды (RewardConfig):
{ type: 'SCRAP' | 'XP' | 'STREAK_POINTS' | 'ITEM' | 'CASE', amount: number, itemId?: string, caseId?: string }
- SCRAP / XP — максимум 1 каждого типа (дубликаты бессмысленны — увеличь
amount). STREAK_POINTS валидацией уникальности не ограничен - CASE — несколько допустимы, но каждый с уникальным
caseId(нельзя 2× один кейс — увеличьamount) - ITEM — аналогично, уникальный
itemIdна каждый
Enrichment: При выдаче API-ответа бэкенд обогащает награды метаданными (EnrichedReward): itemName, itemImageUrl, itemTier, itemType, caseName, caseImageUrl — для отображения карточек на фронтенде.
Code Format
- Только латиница и цифры:
A-Z,0-9 - Case-insensitive — при вводе автоматически конвертируется в UPPERCASE
- Хранится в БД в UPPERCASE
- Промокоды НЕ публикуются в Live Feed (
FeedEvent) - При активации записывается в
UserSeasonStats(сезонная статистика)
Redemption Flow (Two-Step)
Активация промокода разделена на два API-вызова: validate (preview) → redeem (claim).
Шаги валидации (checkEligibility):
- Normalize — приводит код к UPPERCASE
- Find code — поиск в БД (rewards хранятся как JSON, без relations)
- Check isActive — код не деактивирован админом
- Check startsAt — код уже активен (не в будущем)
- Check expiresAt — код не истёк
- Check maxRedemptions — лимит не исчерпан (
totalRedemptions < maxRedemptions) - Check alreadyRedeemed — пользователь не активировал ранее (по
telegramId) - Check onlyNewUsers — если установлен флаг: пользователь < 24h И без предыдущих redemptions
grantAllRewards — итерирует массив rewards внутри $transaction, выдавая каждую награду (SCRAP, XP, SP, ITEM, CASE).
Между validate и redeem другой пользователь может исчерпать лимит. Поэтому redeem() повторно проверяет eligibility перед выдачей.
"Новый пользователь" для onlyNewUsers:
- Аккаунт создан < 24 часов назад (
NEW_USER_HOURS = 24) - И НЕТ ни одной предыдущей активации промокода
Оба условия должны выполняться одновременно.
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| validate | general (100/min)¹ | telegram + season | validatePromoCodeSchema |
| redeem | general (100/min)¹ | telegram + season | redeemPromoCodeSchema |
| get history | general | telegram | — |
| admin CRUD | mutations | admin | varies |
¹ Rate limit планируется к добавлению
Активация промокодов заблокирована между сезонами (статус COUNTDOWN). Middleware requireActiveSeason возвращает ошибку.
См. Security Matrix для полного обзора защит.
Для активации промокодов НЕ требуется верификация Steam-аккаунта. Пользователь может использовать промокод сразу после регистрации.
UTM Campaign Promo Flow
При входе нового пользователя по UTM-ссылке с привязанным промокодом:
- API
getCampaignPromoPrompt— возвращает промокод кампании сrewards(enriched) иexpiresAt - PromoCodeModal показывается ДО onboarding tour (
isHandledгейтитshouldShowPrompt) - Phase 1 (Input) — пользователь видит карточки наград с tier-стилизацией + таймер + поле ввода
- Phase 2 (Confirm) — после валидации кода показывается подтверждение + кнопка "Забрать" (backdrop заблокирован)
- После dismiss/claim —
isHandled = true→ разблокируется onboarding tour
itemTier— tier предмета (TIER_0..TIER_5) для отображения цветной рамки/свечения карточкиitemType— тип предмета (BLUEPRINT,FRAGMENT,BUFF) для описания под карточкой
Поле expiresAt вычисляется на бэкенде: user.createdAt + NEW_USER_HOURS * 3600000 (ISO datetime).
Возвращается null если промокод не onlyNewUsers. Используется для отображения таймера обратного отсчёта.
Если onlyNewUsers и 24-часовое окно истекло — getCampaignPromoPrompt возвращает null (модалка не показывается).
UTM vs REFERRAL: При активации UTM-сессии bonusAmount = 0 — автоматический scrap не начисляется, награды только через промокоды. Тост TopFloatingReward показывается только для REFERRAL-сессий (sessionType === 'REFERRAL').
Direct Entry Promo Flow
Пользователи, зашедшие напрямую (без UTM и без реферальной ссылки), могут иметь промокод от пригласившего. Для них показывается нейтральная модалка ввода промокода.
Условия показа (все должны выполняться):
- Пользователь новый — аккаунт создан < 24 часов назад
- Нет ACTIVATED UTM InviteSession
- Нет ACTIVATED REFERRAL InviteSession
- Пользователь не использовал ни один промокод ранее (0 redemptions)
- Пользователь не закрыл модалку ранее (localStorage ключ
goloot:direct-promo-dismissed)
API getDirectPrompt — возвращает { eligible: boolean, expiresAt: string | null }:
eligible: true— показать нейтральную модалкуexpiresAt— дедлайнuser.createdAt + 24hдля таймера обратного отсчёта
Flow:
NeutralPromoCodeModalпоказывает 3 инкогнито-карточки (TIER_1, TIER_3, TIER_5) + поле ввода + таймер- Пользователь вводит промокод →
validateCode(code)→ Phase 2 (реальные карточки наград) - Кнопка "Забрать" →
claim()→ награды начислены - "Пропустить" → dismiss сохраняется в localStorage → модалка не показывается повторно
Sequencing (полный порядок):
LoadingOrchestrator → bootstrap done
↓
1. usePromoCodePrompt → isPromoCodeHandled
2. useReferralWelcome → isReferralHandled
3. useReferrerRewards → isReferrerRewardsHandled
4. useDirectPromoPrompt → isDirectPromoHandled ← Direct Entry
5. OnboardingGuide (требует все выше = handled)
- UTM: rewards известны заранее из
campaign-promptответа → показываются в input phase - Direct: rewards НЕ известны заранее → input phase показывает инкогнито-карточки → rewards из
validateответа
Dismiss для direct-entry сохраняется навсегда в localStorage (goloot:direct-promo-dismissed = '1').
В отличие от UTM-модалки, direct-promo не показывается повторно даже после очистки кэша.
Edge Cases
| Ситуация | UI поведение | Код |
|---|---|---|
| ❌ Код не найден | "Промокод не найден" | NOT_FOUND |
| ❌ Код деактивирован | "Промокод деактивирован" | INACTIVE |
| ⏱️ Код ещё не активен | "Промокод ещё не активен" | NOT_STARTED |
| ⏱️ Код истёк | "Срок действия промокода истёк" | EXPIRED |
| ❌ Лимит исчерпан | "Промокод исчерпан" | EXHAUSTED |
| ❌ Уже активировали | "Вы уже использовали этот промокод" | ALREADY_REDEEMED |
| ❌ Только для новых | "Промокод только для новых пользователей (< 24ч с регистрации и без активированных промокодов)" | ONLY_NEW_USERS |
| ✅ Успех | Показать награду | — |
| 🔗 UTM вход с промокодом | PromoCodeModal показывается ДО onboarding tour | — |
| 🔕 UTM вход без тоста | bonusAmount=0, тост только для REFERRAL. Награды UTM — через промокоды | — |
| 🔕 UTM вход, onlyNewUsers + >24h | getCampaignPromoPrompt возвращает null → модалка не показывается | — |
| 🆕 Прямой вход (без UTM/Referral) | NeutralPromoCodeModal показывается после UTM/Referral/ReferrerRewards, до Onboarding | — |
| 🔕 Прямой вход, >24h с регистрации | eligible: false → модалка не показывается | — |
| 🔕 Прямой вход, уже dismiss | localStorage goloot:direct-promo-dismissed → модалка не показывается | — |
Константы и лимиты
| Константа | Значение | Описание |
|---|---|---|
MAX_REWARDS | 4 | Максимум наград на один промокод |
NEW_USER_HOURS | 24 | Порог "нового пользователя" в часах |
CODE_MIN_LENGTH (user) | 1 | Минимум символов для ввода |
CODE_MIN_LENGTH (admin) | 3 | Минимум символов для создания |
CODE_MAX_LENGTH | 50 | Максимальная длина кода |
LIST_DEFAULT_LIMIT | 20 | Пагинация по умолчанию |
LIST_MAX_LIMIT | 100 | Максимум записей в списке |
DISMISS_KEY | goloot:direct-promo-dismissed | localStorage ключ для скипа neutral модалки |
Алгоритм UTM Attribution
При активации промокода система проверяет привязку к UTM-кампании через прямой FK lookup:
SELECT * FROM UTMCampaign
WHERE promoCodeId = ?
AND isActive = true
LIMIT 1
Логика:
findLinkedCampaign(promoCodeId)вpromo-code.service.ts— прямой поиск поUTMCampaign.promoCodeId- Если найдена активная кампания —
CodeActivationServiceсоздаёт UTMInviteSessionсactivationSource: 'PROMO_CODE' - Это позволяет атрибутировать конверсию к UTM-кампании даже без перехода по UTM-ссылке
Файл: backend/src/domains/promo-codes/services/promo-code.service.ts (метод findLinkedCampaign)
3. ADR (Architectural Decisions)
ADR 1: Telegram ID как защита от мультиаккаунтов
Проблема: Пользователь может удалить аккаунт и зарегистрироваться заново, чтобы активировать промокод повторно.
Решение: Уникальный constraint (promoCodeId, telegramId) — проверка по Telegram ID, который переживает re-registration.
Альтернативы (отклонены):
userId— не переживает удаление аккаунта- IP-адрес — меняется, VPN, ненадёжно
Последствия: Один промокод = один Telegram аккаунт навсегда. Даже при создании нового аккаунта повторная активация невозможна.
Telegram ID хранится как String для совместимости с bigint значениями Telegram API.
ADR 2: Immutable Reward Snapshot
Проблема: Если админ изменит награду промокода после активации, история станет некорректной — пользователь увидит не то, что реально получил.
Решение: При активации сохраняется JSON snapshot награды (rewardSnapshot) — immutable копия того, что именно получил пользователь.
Альтернативы (отклонены):
- Ссылка на PromoCode — при изменении награды история "ломается"
- Версионирование наград — избыточная сложность
Последствия: История всегда показывает что реально получил пользователь, независимо от последующих изменений конфигурации промокода.
ADR 3: HTTP 200 для бизнес-ошибок
Проблема: Как сообщать о бизнес-ошибках (код не найден, истёк, уже активирован)?
Решение: Возвращать 200 OK с { success: false, error: "CODE", errorMessage: "..." }.
Альтернативы (отклонены):
- HTTP 400/404 — не позволяет различить типы ошибок без парсинга body
- Отдельные HTTP коды — нет стандартных кодов для "уже активирован"
Последствия: Консистентность с другими доменами (steam-verification). Фронтенд может легко обрабатывать различные типы ошибок по полю error.
ADR 4: PromoCodeModal до Onboarding Tour
Проблема: UTM-пользователь видел onboarding tour, и только потом — модалку с промокодом. Промокод терялся.
Решение: usePromoCodePrompt запускается сразу (isInitialized && displayStatus === 'active'). Флаг isHandled гейтит shouldShowPrompt для tour — тур показывается только после dismiss/claim промокода.
Альтернативы (отклонены):
- Показывать оба одновременно — UX-хаос
- Очередь модалок с приоритетами — оверинжиниринг
Последствия: Promo flow всегда приоритетнее onboarding. isHandled = true сразу если промокода нет.
ADR 5: Двухфазный Promo Flow (validate → claim)
Проблема: redeem(code) делал валидацию + выдачу награды в один шаг. Пользователь не видел подтверждения перед получением.
Решение: Разделить на validateCode(code) → Phase 2 confirm → claim(). Между фазами пользователь видит карточки наград и кнопку "Забрать". Backdrop заблокирован в Phase 2.
Альтернативы (отклонены):
- Одношаговый redeem с анимацией — пользователь не контролирует момент получения
Последствия: PromoCodeModal имеет внутренний state phase: 'input' | 'confirm'. Claim отдельный API-вызов.
ADR 6: Отдельный хук useDirectPromoPrompt (не расширение usePromoCodePrompt)
Проблема: Нужна логика промо-модалки для direct-entry пользователей. Возможно расширить существующий usePromoCodePrompt.
Решение: Отдельный хук useDirectPromoPrompt с собственным API endpoint и логикой.
Альтернативы (отклонены):
- Расширить
usePromoCodePrompt— не подходит: UTM-хук привязан кpromoCodeIdиз campaign-prompt и матчит его при validate; direct-хук принимает любой код без предварительно известного ID. Объединение усложнило бы оба хука.
Ключевые различия:
| Аспект | usePromoCodePrompt (UTM) | useDirectPromoPrompt |
|---|---|---|
| API endpoint | GET /campaign-prompt | GET /direct-prompt |
| Rewards в prompt | Да (известны заранее) | Нет (только eligible) |
| Validate: проверка promoCodeId | Да | Нет (любой валидный код) |
| Dismiss persistence | Нет (только state) | localStorage (навсегда) |
Последствия: Два отдельных хука с минимальным дублированием. localStorage dismiss для direct-entry гарантирует что модалка не будет навязчивой.
ADR 7: Отдельный компонент NeutralPromoCodeModal (не mode prop в PromoCodeModal)
Проблема: Direct-entry модалка визуально похожа на PromoCodeModal. Возможно добавить mode prop.
Решение: Отдельный компонент NeutralPromoCodeModal.
Альтернативы (отклонены):
modeprop в PromoCodeModal — не подходит: PromoCodeModal получаетrewardsв props заранее (от campaign); NeutralPromoCodeModal НЕ знает наград заранее — получает из validate response. Добавление условного кода ухудшило бы читаемость.
Последствия: PromoRewardCard переиспользуется в confirm phase NeutralPromoCodeModal напрямую. IncognitoRewardCard — отдельный компонент только для input phase neutral модалки.
ADR 8: Multi-Reward JSON Array
Проблема: Промокоды поддерживали только 1 награду (отдельные поля rewardType, rewardAmount, rewardItemId, rewardCaseId). Бизнесу нужны составные награды: "500 Scrap + 100 XP + 1 Free Case".
Решение: Заменить 4 отдельных поля на rewards Json @default("[]") — массив RewardConfig[] (1-4 наград). Enum PromoCodeRewardType удалён из Prisma — тип валидируется через Zod в runtime.
Альтернативы (отклонены):
- Отдельная таблица
PromoCodeReward(1-to-many) — избыточная нормализация для 1-4 элементов - Несколько промокодов вместо одного — плохой UX, пользователь должен вводить один код
Последствия:
- Compound key для уникальности:
(type)для SCRAP/XP/SP,(type+entityId)для CASE/ITEM - Все награды выдаются атомарно в одной
$transaction rewardSnapshotтеперь массивEnrichedReward[](с метаданными на момент выдачи)- Admin форма: multi-reward editor с max 4 строками
- Frontend:
PromoCodeRewardModalпоказывает grid наград перед claim
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| PromoCodeService | backend/src/domains/promo-codes/services/promo-code.service.ts | User-facing: validate, redeem, enrichRewards, getHistory |
| PromoCodeAdminService | backend/src/domains/promo-codes/services/promo-code-admin.service.ts | Admin CRUD + stats |
| PromoCodeRepository | backend/src/domains/promo-codes/repositories/promo-code.repository.ts | Data access layer |
| UserPromoCodeController | backend/src/domains/promo-codes/controllers/user-promo-code.controller.ts | User API controller |
| AdminPromoCodeController | backend/src/domains/promo-codes/controllers/admin-promo-code.controller.ts | Admin API controller |
| User Routes | backend/src/domains/promo-codes/routes/user-promo-codes.routes.ts | /api/promo-codes/* |
| Admin Routes | backend/src/domains/promo-codes/routes/admin-promo-codes.routes.ts | /admin/promo-codes/* |
| Base Schemas | backend/src/domains/promo-codes/schemas/promo-code-base.schemas.ts | Shared Zod schemas |
| User Schemas | backend/src/domains/promo-codes/schemas/user-promo-codes.schemas.ts | User API validation |
| Admin Schemas | backend/src/domains/promo-codes/schemas/admin-promo-codes.schemas.ts | Admin API validation |
| Types | backend/src/domains/promo-codes/types/promo-code.types.ts | Error codes, types |
| CodeActivationService | backend/src/domains/promo-codes/services/code-activation.service.ts | Unified code activation (promo + referral) |
| CodeActivationController | backend/src/domains/promo-codes/controllers/user-code-activation.controller.ts | Controller для unified code activation |
| User Codes Routes | backend/src/domains/promo-codes/routes/user-codes.routes.ts | /api/codes/* (unified endpoints) |
| User Codes Schemas | backend/src/domains/promo-codes/schemas/user-codes.schemas.ts | Zod + Swagger schemas для unified code activation |
Frontend Components
| Компонент | Путь | Описание |
|---|---|---|
| PromoCodeModal | frontend/src/components/ui/PromoCodeModal.tsx | UTM-флоу: двухфазная модалка (input → confirm) с таймером |
| PromoRewardCard | frontend/src/components/ui/PromoRewardCard.tsx | Shared карточка награды с tier-стилизацией и flip-анимацией |
| PromoCodeRewardModal | frontend/src/components/ui/PromoCodeRewardModal.tsx | Модалка наград после ввода кода из профиля |
| PromoCodeInput | frontend/src/components/ui/PromoCodeInput.tsx | Ввод промокода в профиле |
| NeutralPromoCodeModal | frontend/src/components/ui/NeutralPromoCodeModal.tsx | Direct-entry флоу: инкогнито-карточки в input phase, реальные в confirm phase |
| IncognitoRewardCard | frontend/src/components/ui/IncognitoRewardCard.tsx | Карточка-заглушка с иконкой Gift и tier-цветом — для NeutralPromoCodeModal |
| usePromoCodePrompt | frontend/src/hooks/usePromoCodePrompt.ts | Хук: isHandled, validateCode, claim, dismiss |
| useDirectPromoPrompt | frontend/src/hooks/useDirectPromoPrompt.ts | Хук для direct-entry: eligible check, localStorage dismiss, validate/claim flow |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| PromoCode | Промокод | code, rewards (Json array), maxRedemptions, onlyNewUsers, startsAt, expiresAt |
| PromoCodeRedemption | Активация | telegramId, rewardSnapshot (Json array — EnrichedReward[]), utmCampaignId, redeemedAt |
Relationships
Indexes
| Таблица | Index | Назначение |
|---|---|---|
| PromoCode | code | Быстрый поиск при активации |
| PromoCode | isActive | Фильтрация активных кодов |
| PromoCode | expiresAt | Проверка истечения |
| PromoCodeRedemption | (promoCodeId, telegramId) | UNIQUE — защита от повторной активации |
| PromoCodeRedemption | promoCodeId | Поиск активаций по промокоду |
| PromoCodeRedemption | telegramId | Поиск активаций по Telegram ID |
| PromoCodeRedemption | userId | История пользователя |
| PromoCodeRedemption | redeemedAt | Сортировка по дате активации |
| PromoCodeRedemption | utmCampaignId | Аналитика кампаний |
6. API Endpoints
- User API
- Admin API
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| POST | /api/promo-codes/validate | Preview наград (без выдачи) | → |
| POST | /api/promo-codes/redeem | Активировать промокод (по promoCodeId) | → |
| GET | /api/promo-codes/history | История активаций | → |
| GET | /api/promo-codes/campaign-prompt | UTM campaign промокод | → |
| GET | /api/promo-codes/direct-prompt | Проверить eligible для нейтральной промо-модалки | → |
Помимо promo-specific endpoints, существуют unified endpoints для ввода любого кода (промокод или реферальный код):
| Метод | Эндпоинт | Описание |
|---|---|---|
| POST | /api/codes/validate | Определяет тип кода (promo/referral), проверяет eligibility, возвращает превью наград |
| POST | /api/codes/redeem | Активация кода по codeType + codeId (после превью) |
CodeActivationService — оркестратор: сначала пробует код как промокод, если NOT_FOUND — пробует как реферальный код. При успешном redeem промокода с привязанной UTM-кампанией автоматически создаёт UTM InviteSession для атрибуции конверсии.
Request /api/codes/validate: { code: string } — input нормализуется в UPPERCASE
Response: { success, codeType: 'promo' | 'referral', promoCodeId?, referralCodeId?, referrerName?, rewards[], error?, errorMessage? }
Request /api/codes/redeem: { codeType: 'promo' | 'referral', codeId: string }
Response: { success, rewards[], error?, errorMessage? }
POST /validate
Request:
{
"code": "SUMMER2024"
}
Response (success):
{
"success": true,
"promoCodeId": "clx...",
"rewards": [
{ "type": "SCRAP", "amount": 500 },
{ "type": "XP", "amount": 100 },
{ "type": "CASE", "amount": 2, "caseId": "...", "caseName": "AK-47 Case", "caseImageUrl": "..." }
]
}
Response (error):
{
"success": false,
"error": "EXPIRED",
"errorMessage": "Срок действия промокода истёк"
}
POST /redeem
Request:
{
"promoCodeId": "clx..."
}
Response (success):
{
"success": true,
"rewards": [
{ "type": "SCRAP", "amount": 500 },
{ "type": "XP", "amount": 100 },
{ "type": "CASE", "amount": 2, "caseId": "...", "caseName": "AK-47 Case", "caseImageUrl": "..." }
]
}
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| GET | /admin/promo-codes | Список промокодов | → |
| POST | /admin/promo-codes | Создать промокод | → |
| GET | /admin/promo-codes/:id | Получить промокод | → |
| PATCH | /admin/promo-codes/:id | Обновить промокод | → |
| DELETE | /admin/promo-codes/:id | Удалить промокод | → |
| GET | /admin/promo-codes/:id/redemptions | История активаций | → |
| GET | /admin/promo-codes/:id/stats | Статистика | → |
| GET | /admin/promo-codes/check-code | Проверить уникальность | → |
| GET | /admin/promo-codes/for-utm | Активные коды для UTM | → |
POST /admin/promo-codes
Request:
{
"code": "SUMMER2024",
"description": "Летняя акция для блогера X",
"rewards": [
{ "type": "SCRAP", "amount": 500 },
{ "type": "XP", "amount": 100 },
{ "type": "CASE", "amount": 2, "caseId": "case-id-here" }
],
"maxRedemptions": 1000,
"onlyNewUsers": false,
"startsAt": "2024-06-01T00:00:00Z",
"expiresAt": "2024-08-31T23:59:59Z",
"isActive": true
}
После создания нельзя изменить: code.
Можно изменить: description, rewards, maxRedemptions, onlyNewUsers, startsAt, expiresAt, isActive.
7. Related
- Cases — награда типа CASE даёт купон на бесплатное открытие
- Inventory — награда типа ITEM добавляется с
sourceType: PROMO_CODE - UTM Tracking — атрибуция промокодов к маркетинговым кампаниям
- Seasons — активация заблокирована в статусе COUNTDOWN
- Glossary — термины: Promo Code, Reward Snapshot, Coupon