Skip to main content

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_POINTSSP для розыгрышей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 }
Compound Key — правила уникальности
  • 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
Integration Notes
  • Промокоды НЕ публикуются в Live Feed (FeedEvent)
  • При активации записывается в UserSeasonStats (сезонная статистика)

Redemption Flow (Two-Step)

Активация промокода разделена на два API-вызова: validate (preview) → redeem (claim).

Шаги валидации (checkEligibility):

  1. Normalize — приводит код к UPPERCASE
  2. Find code — поиск в БД (rewards хранятся как JSON, без relations)
  3. Check isActive — код не деактивирован админом
  4. Check startsAt — код уже активен (не в будущем)
  5. Check expiresAt — код не истёк
  6. Check maxRedemptions — лимит не исчерпан (totalRedemptions < maxRedemptions)
  7. Check alreadyRedeemed — пользователь не активировал ранее (по telegramId)
  8. Check onlyNewUsers — если установлен флаг: пользователь < 24h И без предыдущих redemptions

grantAllRewards — итерирует массив rewards внутри $transaction, выдавая каждую награду (SCRAP, XP, SP, ITEM, CASE).

Race Condition Protection

Между validate и redeem другой пользователь может исчерпать лимит. Поэтому redeem() повторно проверяет eligibility перед выдачей.

New User Definition

"Новый пользователь" для onlyNewUsers:

  • Аккаунт создан < 24 часов назад (NEW_USER_HOURS = 24)
  • И НЕТ ни одной предыдущей активации промокода

Оба условия должны выполняться одновременно.

Protection

ДействиеRate LimitAuthValidation
validategeneral (100/min)¹telegram + seasonvalidatePromoCodeSchema
redeemgeneral (100/min)¹telegram + seasonredeemPromoCodeSchema
get historygeneraltelegram
admin CRUDmutationsadminvaries

¹ Rate limit планируется к добавлению

Season Restriction

Активация промокодов заблокирована между сезонами (статус COUNTDOWN). Middleware requireActiveSeason возвращает ошибку.

Security Details

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

No Steam Verification

Для активации промокодов НЕ требуется верификация Steam-аккаунта. Пользователь может использовать промокод сразу после регистрации.

UTM Campaign Promo Flow

При входе нового пользователя по UTM-ссылке с привязанным промокодом:

  1. API getCampaignPromoPrompt — возвращает промокод кампании с rewards (enriched) и expiresAt
  2. PromoCodeModal показывается ДО onboarding tour (isHandled гейтит shouldShowPrompt)
  3. Phase 1 (Input) — пользователь видит карточки наград с tier-стилизацией + таймер + поле ввода
  4. Phase 2 (Confirm) — после валидации кода показывается подтверждение + кнопка "Забрать" (backdrop заблокирован)
  5. После dismiss/claimisHandled = true → разблокируется onboarding tour
EnrichedReward Fields
  • itemTier — tier предмета (TIER_0..TIER_5) для отображения цветной рамки/свечения карточки
  • itemType — тип предмета (BLUEPRINT, FRAGMENT, BUFF) для описания под карточкой
CampaignPromoPrompt expiresAt

Поле 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 и без реферальной ссылки), могут иметь промокод от пригласившего. Для них показывается нейтральная модалка ввода промокода.

Условия показа (все должны выполняться):

  1. Пользователь новый — аккаунт создан < 24 часов назад
  2. Нет ACTIVATED UTM InviteSession
  3. Нет ACTIVATED REFERRAL InviteSession
  4. Пользователь не использовал ни один промокод ранее (0 redemptions)
  5. Пользователь не закрыл модалку ранее (localStorage ключ goloot:direct-promo-dismissed)

API getDirectPrompt — возвращает { eligible: boolean, expiresAt: string | null }:

  • eligible: true — показать нейтральную модалку
  • expiresAt — дедлайн user.createdAt + 24h для таймера обратного отсчёта

Flow:

  1. NeutralPromoCodeModal показывает 3 инкогнито-карточки (TIER_1, TIER_3, TIER_5) + поле ввода + таймер
  2. Пользователь вводит промокод → validateCode(code) → Phase 2 (реальные карточки наград)
  3. Кнопка "Забрать" → claim() → награды начислены
  4. "Пропустить" → dismiss сохраняется в localStorage → модалка не показывается повторно

Sequencing (полный порядок):

LoadingOrchestrator → bootstrap done

1. usePromoCodePrompt → isPromoCodeHandled
2. useReferralWelcome → isReferralHandled
3. useReferrerRewards → isReferrerRewardsHandled
4. useDirectPromoPrompt → isDirectPromoHandled ← Direct Entry
5. OnboardingGuide (требует все выше = handled)
Direct vs UTM отличия
  • UTM: rewards известны заранее из campaign-prompt ответа → показываются в input phase
  • Direct: rewards НЕ известны заранее → input phase показывает инкогнито-карточки → rewards из validate ответа
localStorage Dismiss

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 + >24hgetCampaignPromoPrompt возвращает null → модалка не показывается
🆕 Прямой вход (без UTM/Referral)NeutralPromoCodeModal показывается после UTM/Referral/ReferrerRewards, до Onboarding
🔕 Прямой вход, >24h с регистрацииeligible: false → модалка не показывается
🔕 Прямой вход, уже dismisslocalStorage goloot:direct-promo-dismissed → модалка не показывается
Константы и лимиты
КонстантаЗначениеОписание
MAX_REWARDS4Максимум наград на один промокод
NEW_USER_HOURS24Порог "нового пользователя" в часах
CODE_MIN_LENGTH (user)1Минимум символов для ввода
CODE_MIN_LENGTH (admin)3Минимум символов для создания
CODE_MAX_LENGTH50Максимальная длина кода
LIST_DEFAULT_LIMIT20Пагинация по умолчанию
LIST_MAX_LIMIT100Максимум записей в списке
DISMISS_KEYgoloot:direct-promo-dismissedlocalStorage ключ для скипа 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 создаёт UTM InviteSession с 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 аккаунт навсегда. Даже при создании нового аккаунта повторная активация невозможна.

Data Consistency

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 endpointGET /campaign-promptGET /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.

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

  • mode prop в 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

КомпонентПутьОписание
PromoCodeServicebackend/src/domains/promo-codes/services/promo-code.service.tsUser-facing: validate, redeem, enrichRewards, getHistory
PromoCodeAdminServicebackend/src/domains/promo-codes/services/promo-code-admin.service.tsAdmin CRUD + stats
PromoCodeRepositorybackend/src/domains/promo-codes/repositories/promo-code.repository.tsData access layer
UserPromoCodeControllerbackend/src/domains/promo-codes/controllers/user-promo-code.controller.tsUser API controller
AdminPromoCodeControllerbackend/src/domains/promo-codes/controllers/admin-promo-code.controller.tsAdmin API controller
User Routesbackend/src/domains/promo-codes/routes/user-promo-codes.routes.ts/api/promo-codes/*
Admin Routesbackend/src/domains/promo-codes/routes/admin-promo-codes.routes.ts/admin/promo-codes/*
Base Schemasbackend/src/domains/promo-codes/schemas/promo-code-base.schemas.tsShared Zod schemas
User Schemasbackend/src/domains/promo-codes/schemas/user-promo-codes.schemas.tsUser API validation
Admin Schemasbackend/src/domains/promo-codes/schemas/admin-promo-codes.schemas.tsAdmin API validation
Typesbackend/src/domains/promo-codes/types/promo-code.types.tsError codes, types
CodeActivationServicebackend/src/domains/promo-codes/services/code-activation.service.tsUnified code activation (promo + referral)
CodeActivationControllerbackend/src/domains/promo-codes/controllers/user-code-activation.controller.tsController для unified code activation
User Codes Routesbackend/src/domains/promo-codes/routes/user-codes.routes.ts/api/codes/* (unified endpoints)
User Codes Schemasbackend/src/domains/promo-codes/schemas/user-codes.schemas.tsZod + Swagger schemas для unified code activation

Frontend Components

КомпонентПутьОписание
PromoCodeModalfrontend/src/components/ui/PromoCodeModal.tsxUTM-флоу: двухфазная модалка (input → confirm) с таймером
PromoRewardCardfrontend/src/components/ui/PromoRewardCard.tsxShared карточка награды с tier-стилизацией и flip-анимацией
PromoCodeRewardModalfrontend/src/components/ui/PromoCodeRewardModal.tsxМодалка наград после ввода кода из профиля
PromoCodeInputfrontend/src/components/ui/PromoCodeInput.tsxВвод промокода в профиле
NeutralPromoCodeModalfrontend/src/components/ui/NeutralPromoCodeModal.tsxDirect-entry флоу: инкогнито-карточки в input phase, реальные в confirm phase
IncognitoRewardCardfrontend/src/components/ui/IncognitoRewardCard.tsxКарточка-заглушка с иконкой Gift и tier-цветом — для NeutralPromoCodeModal
usePromoCodePromptfrontend/src/hooks/usePromoCodePrompt.tsХук: isHandled, validateCode, claim, dismiss
useDirectPromoPromptfrontend/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Назначение
PromoCodecodeБыстрый поиск при активации
PromoCodeisActiveФильтрация активных кодов
PromoCodeexpiresAtПроверка истечения
PromoCodeRedemption(promoCodeId, telegramId)UNIQUE — защита от повторной активации
PromoCodeRedemptionpromoCodeIdПоиск активаций по промокоду
PromoCodeRedemptiontelegramIdПоиск активаций по Telegram ID
PromoCodeRedemptionuserIdИстория пользователя
PromoCodeRedemptionredeemedAtСортировка по дате активации
PromoCodeRedemptionutmCampaignIdАналитика кампаний

6. API Endpoints

МетодЭндпоинтОписаниеDocs
POST/api/promo-codes/validatePreview наград (без выдачи)
POST/api/promo-codes/redeemАктивировать промокод (по promoCodeId)
GET/api/promo-codes/historyИстория активаций
GET/api/promo-codes/campaign-promptUTM campaign промокод
GET/api/promo-codes/direct-promptПроверить eligible для нейтральной промо-модалки
Unified Code Activation

Помимо 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": "..." }
]
}

  • Cases — награда типа CASE даёт купон на бесплатное открытие
  • Inventory — награда типа ITEM добавляется с sourceType: PROMO_CODE
  • UTM Tracking — атрибуция промокодов к маркетинговым кампаниям
  • Seasons — активация заблокирована в статусе COUNTDOWN
  • Glossary — термины: Promo Code, Reward Snapshot, Coupon