Promo Codes
1. Summary
Goal: Система промокодов для маркетинговых кампаний — позволяет создавать коды с различными типами наград и ограничениями.
User Value: Пользователь получает бонусы (валюту, опыт, предметы, купоны на кейсы) за активацию кодов от блогеров, партнёров или в рамках акций.
2. Business Logic
Reward Types
- SCRAP
- XP
- ITEM
- CASE
Валюта
Начисляет указанное количество Scrap на баланс пользователя.
Поля: rewardAmount — количество Scrap
Действие: user.scrap += rewardAmount, user.scrapTotalEarned += rewardAmount
Опыт
Начисляет указанное количество XP для продвижения в сезонном рейтинге.
Поля: rewardAmount — количество XP
Действие: user.xp += rewardAmount
Предмет
Добавляет предмет в инвентарь пользователя (Fragment, Blueprint, Buff).
Поля: rewardItemId — ID предмета из таблицы Item
Действие: Upsert в UserInventory с sourceType: PROMO_CODE
Купон на кейс
Даёт право на бесплатное открытие указанного кейса.
Поля: rewardCaseId — ID кейса
Действие: Upsert в UserFreeCaseOpens (инкремент счётчика)
См. также: Coupon
Code Format
- Только латиница и цифры:
A-Z,0-9 - Case-insensitive — при вводе автоматически конвертируется в UPPERCASE
- Хранится в БД в UPPERCASE
- Промокоды НЕ публикуются в Live Feed (
FeedEvent) - При активации записывается в
UserSeasonStats(сезонная статистика)
Redemption Flow
12 шагов валидации:
- Normalize — приводит код к UPPERCASE
- Find code — поиск в БД с relations (rewardItem, rewardCase)
- Check isActive — код не деактивирован админом
- Check startsAt — код уже активен (не в будущем)
- Check expiresAt — код не истёк
- Check maxRedemptions — лимит не исчерпан (
totalRedemptions < maxRedemptions) - Check alreadyRedeemed — пользователь не активировал ранее (по
telegramId) - Check onlyNewUsers — если установлен флаг: пользователь < 24h И без предыдущих redemptions
- Create snapshot — immutable JSON-копия награды
- Grant reward — применение награды к пользователю
- Find UTM — поиск последней UTM-сессии (7 дней) для атрибуции
- Record redemption — в транзакции: создание записи + инкремент счётчика
"Новый пользователь" для onlyNewUsers:
- Аккаунт создан < 24 часов назад (
NEW_USER_HOURS = 24) - И НЕТ ни одной предыдущей активации промокода
Оба условия должны выполняться одновременно.
Protection
| Действие | Rate Limit | Auth | Validation |
|---|---|---|---|
| redeem | general (100/min)¹ | telegram + season | redeemPromoCodeSchema |
| get history | general | telegram | — |
| admin CRUD | mutations | admin | varies |
¹ Rate limit планируется к добавлению
Активация промокодов заблокирована между сезонами (статус COUNTDOWN). Middleware requireActiveSeason возвращает ошибку.
См. Security Matrix для полного обзора защит.
Для активации промокодов НЕ требуется верификация Steam-аккаунта. Пользователь может использовать промокод сразу после регистрации.
Edge Cases
| Ситуация | UI поведение | Код |
|---|---|---|
| ❌ Код не найден | "Промокод не найден" | NOT_FOUND |
| ❌ Код деактивирован | "Промокод деактивирован" | INACTIVE |
| ⏱️ Код ещё не активен | "Промокод ещё не активен" | NOT_STARTED |
| ⏱️ Код истёк | "Промокод истёк" | EXPIRED |
| ❌ Лимит исчерпан | "Лимит использований исчерпан" | EXHAUSTED |
| ❌ Уже активировали | "Вы уже активировали этот код" | ALREADY_REDEEMED |
| ❌ Только для новых | "Только для новых пользователей" | ONLY_NEW_USERS |
| ✅ Успех | Показать награду | — |
Константы и лимиты
| Константа | Значение | Описание |
|---|---|---|
NEW_USER_HOURS | 24 | Порог "нового пользователя" в часах |
CODE_MIN_LENGTH (user) | 1 | Минимум символов для ввода |
CODE_MIN_LENGTH (admin) | 3 | Минимум символов для создания |
CODE_MAX_LENGTH | 50 | Максимальная длина кода |
UTM_LOOKUP_DAYS | 7 | Окно для UTM атрибуции |
LIST_DEFAULT_LIMIT | 20 | Пагинация по умолчанию |
LIST_MAX_LIMIT | 100 | Максимум записей в списке |
Алгоритм UTM Attribution
При активации промокода система ищет последний UTM-визит пользователя:
1. SELECT * FROM utm_tracking
WHERE userId = ?
AND createdAt > NOW() - 7 days
AND source IS NOT NULL
AND medium IS NOT NULL
AND campaign IS NOT NULL
ORDER BY createdAt DESC
LIMIT 1
2. SELECT id FROM utm_campaigns
WHERE source = ? AND medium = ? AND campaign = ?
Логика:
- Поиск в
utm_trackingпоuserIdза последние 7 дней - Сопоставление с
utm_campaignsчерез комбинациюsource+medium+campaign - Если найдено — сохраняется
utmCampaignIdвPromoCodeRedemption
Файл: backend/src/domains/promo-codes/utils/utm-lookup.ts
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.
4. Architecture
Services Overview
Key Components
| Компонент | Путь | Описание |
|---|---|---|
| PromoCodeService | backend/src/domains/promo-codes/services/promo-code.service.ts | User-facing: redeem, getHistory |
| AdminPromoCodeService | backend/src/domains/promo-codes/services/admin-promo-code.service.ts | Admin CRUD + stats |
| PromoCodeRepository | backend/src/domains/promo-codes/repositories/promo-code.repository.ts | Data access layer |
| User Routes | backend/src/domains/promo-codes/routes/promo-code.routes.ts | /api/promo-codes/* |
| Admin Routes | backend/src/domains/promo-codes/routes/admin-promo-code.routes.ts | /admin/promo-codes/* |
| Schemas | backend/src/domains/promo-codes/schemas/promo-code.schemas.ts | Zod validation |
| UTM Lookup | backend/src/domains/promo-codes/utils/utm-lookup.ts | Campaign attribution |
5. Database Schema
Models
| Модель | Описание | Ключевые поля |
|---|---|---|
| PromoCode | Промокод | code, rewardType, rewardAmount, maxRedemptions, onlyNewUsers, startsAt, expiresAt |
| PromoCodeRedemption | Активация | telegramId, rewardSnapshot, utmCampaignId, redeemedAt |
Relationships
Indexes
| Таблица | Index | Назначение |
|---|---|---|
| PromoCode | code | Быстрый поиск при активации |
| PromoCode | isActive | Фильтрация активных кодов |
| PromoCode | expiresAt | Проверка истечения |
| PromoCodeRedemption | (promoCodeId, telegramId) | UNIQUE — защита от повторной активации |
| PromoCodeRedemption | userId | История пользователя |
| PromoCodeRedemption | utmCampaignId | Аналитика кампаний |
6. API Endpoints
- User API
- Admin API
| Метод | Эндпоинт | Описание | Docs |
|---|---|---|---|
| POST | /api/promo-codes/redeem | Активировать промокод | → |
| GET | /api/promo-codes/history | История активаций | → |
POST /redeem
Request:
{
"code": "SUMMER2024"
}
Response (success):
{
"success": true,
"reward": {
"type": "SCRAP",
"amount": 500
}
}
Response (error):
{
"success": false,
"error": "EXPIRED",
"errorMessage": "Промокод истёк"
}
| Метод | Эндпоинт | Описание | 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",
"rewardType": "SCRAP",
"rewardAmount": 500,
"maxRedemptions": 1000,
"onlyNewUsers": false,
"startsAt": "2024-06-01T00:00:00Z",
"expiresAt": "2024-08-31T23:59:59Z",
"isActive": true
}
После создания нельзя изменить: code, rewardType, rewardAmount, rewardItemId, rewardCaseId.
Можно изменить: description, maxRedemptions, onlyNewUsers, startsAt, expiresAt, isActive.
7. Related
- Cases — награда типа CASE даёт купон на бесплатное открытие
- Inventory — награда типа ITEM добавляется с
sourceType: PROMO_CODE - UTM Tracking — атрибуция промокодов к маркетинговым кампаниям
- Seasons — активация заблокирована в статусе COUNTDOWN
- Glossary — термины: Promo Code, Reward Snapshot, Coupon