Разработка сервиса купонов и скидок
Купоны и скидки — это не просто поле «введите промокод» на странице чекаута. Это система правил, которая управляет ценообразованием: кому, когда, на что и в каком размере предоставлять скидку. Плохо спроектированная система превращается в дыры для злоупотреблений и хаос в аналитике. Хорошо спроектированная — в инструмент точечного маркетинга.
Типология скидок
Прежде чем писать код, нужно зафиксировать модель:
Купоны (промокоды) — пользователь вводит код вручную или ссылка применяет его автоматически. Код уникален или многоразов, привязан к правилу скидки.
Автоматические скидки — применяются без кода при выполнении условий: «все товары категории X со скидкой 15% в пятницу», «скидка 500 руб. от заказа на 3000».
Накопительные программы — скидка зависит от истории покупок клиента (кешбэк, баллы, уровни лояльности).
Скидки по группам — оптовые цены для B2B-клиентов, скидки для сотрудников, партнёрские условия.
Модель данных
discount_rules (
id, name, type, -- coupon | automatic | loyalty
discount_type, -- percentage | fixed_amount | free_shipping | bxgy
discount_value NUMERIC,
min_order_amount NUMERIC,
min_qty INT,
max_uses INT, -- NULL = безлимит
max_uses_per_user INT,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
is_active BOOLEAN,
stackable BOOLEAN -- можно ли совмещать с другими скидками
)
discount_conditions (
id, rule_id,
condition_type, -- product | category | tag | user_group | first_order
condition_operator, -- in | not_in | gte | lte
condition_value JSONB
)
coupons (
id, rule_id, code VARCHAR(32),
usage_count INT DEFAULT 0,
is_single_use BOOLEAN
)
coupon_uses (
id, coupon_id, order_id, user_id, used_at,
discount_amount NUMERIC -- сколько было списано в момент применения
)
Разделение discount_rules и coupons позволяет одному правилу иметь много кодов (bulk generation для email-кампаний) или один код с разными ограничениями.
Генерация купонов пачками
Для email-рассылок нужны уникальные коды — по одному на каждого получателя. Генерация:
function generateCouponBatch(int $ruleId, int $count): array {
$codes = [];
while (count($codes) < $count) {
$code = strtoupper(Str::random(8)); // A-Z0-9, 8 символов
if (!Coupon::where('code', $code)->exists()) {
$codes[] = ['rule_id' => $ruleId, 'code' => $code, 'is_single_use' => true];
}
}
Coupon::insert($codes);
return array_column($codes, 'code');
}
Для крупных рассылок (100 000+ кодов) генерируем заранее с проверкой уникальности через индекс, а не через SELECT EXISTS в цикле.
Валидация и применение купона
При вводе кода в чекауте нужно проверить:
- Код существует и активен
- Дата начала/окончания акции
- Лимит использований не превышен (
max_uses) - Пользователь не исчерпал лимит (
max_uses_per_user) - Сумма корзины >=
min_order_amount - Товары в корзине соответствуют условиям (
discount_conditions)
Проверка должна происходить атомарно при применении — race condition возможен, если два запроса одновременно применяют последний доступный купон. Решение:
UPDATE coupons
SET usage_count = usage_count + 1
WHERE code = :code
AND usage_count < max_uses -- для single-use: usage_count < 1
RETURNING id;
-- если 0 строк — купон уже использован
Обновление через UPDATE ... RETURNING внутри транзакции исключает гонку условий.
Расчёт скидки для корзины
Скидка рассчитывается на сервере, никогда не доверяем клиентскому расчёту. Алгоритм:
1. Получить применённые правила скидок (автоматические + купон)
2. Для каждого правила определить eligible позиции (с учётом условий)
3. Применить скидки в порядке приоритета
4. Если stackable=false — применяем только наибольшую скидку
5. Вернуть breakdown: какая скидка применена к каждой позиции
Breakdown важен для отображения пользователю («-500 руб. по купону SAVE500») и для аналитики.
BxGy (Buy X Get Y) — «купи 3, получи 4-й в подарок». Реализуется отдельным типом правила: при qty >= X добавляем в корзину товар Y с нулевой ценой или уменьшаем цену на N-ой единицы.
Автоматические скидки и приоритеты
Несколько автоматических правил могут срабатывать одновременно. Нужна политика:
- Первое совпавшее — применяется самое первое правило по приоритету
- Лучшая скидка — применяется то правило, которое даёт наибольшую выгоду
-
Все совместимые — применяются все правила с
stackable=true
Политика прописывается на уровне магазина и может различаться для разных типов правил.
Аналитика и отчётность
Без аналитики маркетинг летает вслепую. Базовый набор метрик:
| Метрика | SQL |
|---|---|
| Использований купона | SELECT COUNT(*) FROM coupon_uses WHERE coupon_id = ? |
| Средний размер скидки | SELECT AVG(discount_amount) FROM coupon_uses WHERE ... |
| Revenue с учётом скидки | SUM(order.total) vs SUM(order.total + discount_amount) |
| Конверсия с купоном vs без | Сравнение CR для сессий с applied_coupon и без |
Для маркетолога — дашборд с фильтрацией по периоду, типу скидки, каналу (откуда пришёл пользователь с купоном).
Предотвращение злоупотреблений
- Один купон на заказ — стандартное ограничение, но если разрешён стек, нужна явная конфигурация
- Верификация email перед применением скидки «для новых клиентов» — иначе создадут 100 аккаунтов
- Rate limiting на endpoint применения купона — защита от брутфорса кодов
- Алерты при резком росте использований одного купона — возможна утечка
Личный кабинет маркетолога
Интерфейс для управления акциями должен позволять:
- Создавать правила скидок с визуальным конструктором условий
- Генерировать и выгружать CSV пачки купонов
- Просматривать статистику по каждой акции в реальном времени
- Деактивировать акцию одним кликом (важно при ошибках в настройках)
Сроки
- Базовая система купонов (промокод, процент/сумма, дата окончания): 1–2 недели
- Полноценная система (условия по категориям/товарам, автоматические скидки, BxGy, аналитика, кабинет маркетолога): 3–5 недель
- Программа лояльности с баллами и уровнями добавляет 3–4 недели







