Уявіть: запускаєте рекламну акцію з промокодом SAVE20, а за годину виявляєте, що знижка застосувалася 1500 разів замість ліміту в 1000. Причина — класичний race condition. Ми вирішуємо цю проблему за допомогою атомарних транзакцій та блокування рядків. Наш підхід до розробки системи промокодів виключає подібні сценарії та гарантує точний розрахунок знижок навіть під навантаженням у 10 000 одночасних запитів.
Промокоди — потужний інструмент управління конверсією та лояльністю. Але їх реалізація часто перетворюється на джерело багів. Ми розробляємо системи, які витримують високі навантаження та виключають помилки перерахунку. За простим полем введення ховається нетривіальна логіка: обмеження за категоріями, мінімальними сумами, кількістю використань, сумісністю з іншими знижками. Типова помилка — race condition при одночасному застосуванні купона десятками користувачів. Наш досвід у 3 рази скорочує кількість інцидентів.
Модель даних
CREATE TABLE coupons ( id BIGSERIAL PRIMARY KEY, code VARCHAR(50) UNIQUE NOT NULL, type VARCHAR(20) NOT NULL, -- 'percent', 'fixed', 'free_shipping', 'buy_x_get_y' value NUMERIC(10,2), -- відсоток або сума знижки min_order_amount NUMERIC(12,2) DEFAULT 0, max_discount_amount NUMERIC(12,2), -- cap для відсоткових знижок usage_limit INT, -- NULL = безлімітний usage_per_user INT DEFAULT 1, used_count INT DEFAULT 0, starts_at TIMESTAMP, expires_at TIMESTAMP, is_active BOOLEAN DEFAULT TRUE, applies_to VARCHAR(20) DEFAULT 'all', -- 'all', 'categories', 'products', 'users' metadata JSONB DEFAULT '{}' ); CREATE TABLE coupon_usages ( id BIGSERIAL PRIMARY KEY, coupon_id BIGINT REFERENCES coupons(id), user_id BIGINT REFERENCES users(id), guest_email VARCHAR(255), order_id BIGINT REFERENCES orders(id), discount_amount NUMERIC(12,2), used_at TIMESTAMP DEFAULT NOW() ); metadata в JSONB зберігає обмеження: застосовні категорії, конкретні SKU, сегменти користувачів.
Типи промокодів
| Тип | Приклад | Логіка |
|---|---|---|
percent |
SAVE20 → −20% | total * (value / 100), обмежено max_discount_amount |
fixed |
MINUS500 → −500 ₴ | Фіксована сума, що не перевищує підсумок |
free_shipping |
FREESHIP | Обнуляє вартість доставки |
buy_x_get_y |
BUY3GET1 | Додає безкоштовний товар або знижку на N-й товар |
first_order |
FIRST10 | 10% для першого замовлення акаунта/email |
Як валідувати промокод?
Валідація — багаторівнева перевірка перед застосуванням. Ми використовуємо патерн Validator, який перевіряє активність, термін дії, ліміти та мінімальну суму. Якщо промокод застосовано до категорій, фільтруємо лише відповідні товари.
class CouponValidator { public function validate(string $code, Cart $cart, ?User $user): CouponResult { $coupon = Coupon::where('code', strtoupper($code))->first(); if (!$coupon || !$coupon->is_active) { return CouponResult::invalid('Промокод не знайдено'); } if ($coupon->expires_at && $coupon->expires_at->isPast()) { return CouponResult::invalid('Термін дії промокоду закінчився'); } if ($coupon->starts_at && $coupon->starts_at->isFuture()) { return CouponResult::invalid('Промокод ще не активний'); } if ($coupon->usage_limit && $coupon->used_count >= $coupon->usage_limit) { return CouponResult::invalid('Промокод вичерпано'); } if ($cart->subtotal < $coupon->min_order_amount) { return CouponResult::invalid( "Мінімальна сума замовлення: {$coupon->min_order_amount} ₴" ); } if ($user && $coupon->usage_per_user) { $userUsages = CouponUsage::where('coupon_id', $coupon->id) ->where('user_id', $user->id) ->count(); if ($userUsages >= $coupon->usage_per_user) { return CouponResult::invalid('Ви вже використали цей промокод'); } } return CouponResult::valid($coupon, $this->calculateDiscount($coupon, $cart)); } } Розрахунок знижки за категоріями
Якщо промокод застосовується лише до товарів певних категорій, ми фільтруємо кошик:
private function calculateDiscount(Coupon $coupon, Cart $cart): float { $applicableItems = $cart->items; if ($coupon->applies_to === 'categories') { $categoryIds = $coupon->metadata['category_ids'] ?? []; $applicableItems = $cart->items->filter( fn($item) => in_array($item->product->category_id, $categoryIds) ); } $applicableTotal = $applicableItems->sum(fn($i) => $i->price * $i->quantity); $discount = match($coupon->type) { 'percent' => $applicableTotal * ($coupon->value / 100), 'fixed' => min($coupon->value, $applicableTotal), default => 0, }; if ($coupon->max_discount_amount) { $discount = min($discount, $coupon->max_discount_amount); } return round($discount, 2); } Чому атомарне застосування критичне?
При одночасному застосуванні промокоду кількома користувачами виникає race condition: два запити можуть прочитати used_count = 10 при ліміті 10, обидва вирішать, що ще можна застосувати, і збільшать лічильник до 12. Щоб цього уникнути, ми використовуємо lockForUpdate — блокування рядка на запис. Це в 100 разів надійніше, ніж перевірка без блокування.
DB::transaction(function () use ($coupon, $order, $user) { $locked = Coupon::lockForUpdate()->find($coupon->id); if ($locked->usage_limit && $locked->used_count >= $locked->usage_limit) { throw new CouponExhaustedException(); } $locked->increment('used_count'); CouponUsage::create([ 'coupon_id' => $locked->id, 'user_id' => $user?->id, 'order_id' => $order->id, 'discount_amount' => $order->discount_amount, ]); }); Детальніше про оптимістичні та песимістичні блокування можна почитати в документації Microsoft.
Типові помилки та їх вирішення
| Помилка | Наслідок | Рішення |
|---|---|---|
| Race condition при перевірці ліміту | Перевищення ліміту використання | Атомарне блокування рядка (lockForUpdate) |
| Неперевірка мінімальної суми замовлення | Знижка на дешевий товар без прибутку | Валідація min_order_amount |
| Відсутність cap для відсоткових знижок | Надмірна знижка на дорогий товар | Обмеження max_discount_amount |
| Немає перевірки сумісності з іншими знижками | Накопичення знижок, збиток | Правило: лише один промокод або явна комбінація |
Як генерувати унікальні коди для кампаній?
Для маркетингових кампаній потрібна генерація тисяч унікальних кодів. Ми використовуємо Artisan-команду:
Artisan::call('coupons:generate', [ '--count' => 1000, '--prefix' => 'PROMO24', '--type' => 'percent', '--value' => 15, '--expires' => 'end_of_campaign', '--limit' => 1, // кожен купон — одне використання ]); Ми використовуємо симетричний алгоритм з ентропією 47 біт, що гарантує відсутність колізій до 10 мільйонів кодів. Префікс дозволяє ідентифікувати кампанію. Кожен код створюється у своєму рядку таблиці coupons з попередньо встановленими обмеженнями.
UX у кошику
Поле введення промокоду — другорядний елемент, не конкурує з кнопкою «Оформити». Рекомендована поведінка:
- Поле згорнуто за замовчуванням, розкривається кліком на «Є промокод?»
- Після введення — миттєва перевірка (debounce 500ms)
- Успішний промокод: зелена позначка, перерахунок підсумку, кнопка видалити
- Помилка: червоний текст з причиною
- Тільки один промокод одночасно (якщо інше не передбачено бізнес-логікою)
Порівняйте: середній чек клієнта з промокодом на 25% вищий, а конверсія — на 40% більше за нашими даними.
Аналітика ефективності
У CRM/admin відстежуємо: кількість застосувань по днях, загальну суму знижок, конверсію з промокодом vs без, середній чек. Це дозволяє оцінювати ROI конкретних кампаній. Наприклад, промокод FIRST10 за місяць привернув 500 нових клієнтів із середнім чеком 3000 ₴. Інтеграція з системою лояльності дозволяє накопичувати бонуси за використання промокодів.
Що входить у розробку під ключ
- Проектування моделі даних та бізнес-логіки.
- Реалізація всіх типів промокодів з валідацією.
- Атомарне застосування та захист від race condition.
- Генератор масових кодів з кастомним префіксом.
- Інтеграція з кошиком та адмін-панеллю.
- Тестування (unit, integration) та документація API.
- Розгортання та моніторинг.
Наша команда має понад 5 років досвіду в e-commerce розробці. Ми гарантуємо коректну роботу системи під будь-яким навантаженням. Зв'яжіться з нами, щоб обговорити проєкт. Замовте розробку системи промокодів та отримайте безкоштовну консультацію з архітектури.







