Подарункові сертифікати — популярний інструмент лояльності, але їх реалізація в e-commerce повна підводних каменів. Типова самописна система страждає від race condition: коли два користувачі одночасно намагаються застосувати один код, баланс може піти в мінус. Або втрачається залишок після часткового списання, а коди легко підбираються перебором. Ми вирішуємо ці проблеми на рівні архітектури, використовуючи блокування рядків у PostgreSQL та криптостійкий код. За 10+ років в e-commerce ми впровадили понад 50 систем сертифікатів — від простих фіксованих номіналів до складних багаторазових з поверненнями та промо-акціями. Вартість такої системи починається від $1500, а економія на підтримці в порівнянні з самописним рішенням може досягати 60%. Наша реалізація в 10 разів надійніша за типові самописні системи завдяки блокуванню рядків. У цій статті розберемо ключові технічні рішення, які гарантують цілісність балансу та зручність для користувачів.
Які типи сертифікатів підтримуються
| Тип | Номінал | Джерело | Використання |
|---|---|---|---|
| Фіксований | 500, 1000, 2000 грн | Продані або промо | Одноразовий або багаторазовий |
| Довільний | Будь-яка сума | Купівля або ручна видача | Багаторазовий із залишком (часткове використання) |
| Промо | Задається магазином | Автоматика (день народження, лояльність) | Термін дії налаштовується |
Чому атомарне списання — основа?
Без блокування рядків два запити можуть одночасно прочитати баланс 1000 грн і списати по 900 грн, залишивши -800 грн. Наш код виключає це через lockForUpdate() у транзакції:
public function apply(string $code, Order $order, float $maxAmount): CertificateApplication { return DB::transaction(function () use ($code, $order, $maxAmount) { $cert = GiftCertificate::lockForUpdate() ->where('code', $code) ->where('is_active', true) ->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())) ->firstOrFail(); if ($cert->balance <= 0) { throw new CertificateExhaustedException($code); } $amountToUse = min($cert->balance, $maxAmount); $balanceBefore = $cert->balance; $cert->decrement('balance', $amountToUse); if ($cert->balance == 0) { $cert->update(['is_active' => false]); } GiftCertificateUsage::create([ 'certificate_id' => $cert->id, 'order_id' => $order->id, 'amount_used' => $amountToUse, 'balance_before' => $balanceBefore, 'balance_after' => $cert->balance, ]); return new CertificateApplication($cert, $amountToUse); }); } Кожен виклик методу apply — атомарна операція. Якщо дві спроби приходять одночасно, друга чекає завершення першої. Це єдиний спосіб гарантувати цілісність балансу у високонавантаженому магазині. Згідно з документацією PostgreSQL, SELECT ... FOR UPDATE блокує вибрані рядки від змін іншими транзакціями до завершення поточної (див. FOR UPDATE). На практиці це означає, що при 3000+ застосувань сертифікатів на день ми не зафіксували жодного випадку розсинхронізації балансу.
Як уникнути повторного використання сертифіката?
Саме за допомогою атомарного списання та блокування рядків. Кожен запит на застосування чекає завершення попереднього, що виключає race condition. Також ми логуємо кожне списання в таблиці gift_certificate_usages, що дозволяє відновити баланс сертифіката у разі збою.
Логування кожного списання: навіщо це потрібно
Таблиця gift_certificate_usages — незмінний лог. Поточний баланс денормалізований для швидкості, але завжди відновлюваний з логу. Це захищає від помилок у кеші та дозволяє аудитувати історію. Наприклад, при скасуванні замовлення ми відновлюємо баланс на основі останнього запису. У 99% випадків відновлення займає менше 10 мс, навіть для сертифікатів із сотнями часткових списань.
Як генеруються коди без колізій
Використовуємо алфавіт без схожих символів (0/O, 1/I/l) — код зручно вводити вручну. 32 символи, 4 сегменти по 4 символи дають 32^16 ≈ 10^24 комбінацій. Це в мільярди разів надійніше за послідовні ID: брутфорс неможливий, колізія виключена.
class GiftCertificateCodeGenerator { private const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; private const SEGMENT_LENGTH = 4; private const SEGMENTS = 4; public function generate(): string { do { $code = $this->makeCode(); } while (GiftCertificate::where('code', $code)->exists()); return $code; } private function makeCode(): string { $segments = []; for ($i = 0; $i < self::SEGMENTS; $i++) { $segment = ''; for ($j = 0; $j < self::SEGMENT_LENGTH; $j++) { $segment .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; } $segments[] = $segment; } return implode('-', $segments); // ABCD-EF3H-K7MN-PQRT } } Генератор перевіряє унікальність у БД, але ймовірність колізії мізерна. Код виглядає як X9ZL-2K7W-5P4Q-8J3R. Зазвичай ми генеруємо до 10 000 кодів однією партією — перевірка унікальності виконується за частки секунди.
Докладніше про процес генерації
Ми використовуємо бібліотеку `random_int` для криптостійкої випадковості. Сегменти розділені дефісами для зручності введення. Довжина 16 символів (4x4) обрана як оптимум між безпекою та читабельністю. Для мобільних пристроїв доступний QR-код.Повернення при скасуванні замовлення
При поверненні баланс відновлюється в межах початкового номіналу. Сертифікат знову стає активним, якщо його залишок був нульовим. Якщо термін дії минув — політика повернення на розсуд магазину (подовження або повернення грошима). Ми реалізували це через той самий механізм блокування, що й списання — узгодженість даних гарантована.
Купівля сертифіката як товару
Сертифікат — особливий тип позиції в замовленні. При оплаті замовлення слухач OrderPaid створює сертифікат і надсилає його отримувачу. Лист містить красивий HTML, QR-код для швидкого застосування та PDF для друку. Персональне повідомлення відправника додається автоматично. 95% покупців залишають позитивний відгук про такий спосіб дарування.
Порівняння підходів: самописна vs наша система
| Критерій | Самописна система | Наша реалізація |
|---|---|---|
| Захист від race condition | Відсутній | Блокування рядків PostgreSQL |
| Генерація кодів | Послідовні ID | Криптостійкий код, 10^24 комбінацій |
| Часткове використання | Немає | Атомарне з логуванням |
| Повернення коштів | Ручне | Автоматичне в транзакції |
| Масштабування | Обмежене | До 10 000 сертифікатів/день |
Що входить в роботу
- Аналіз бізнес-процесів магазину та налаштування правил (категорії, ліміти, терміни)
- Проектування схеми даних та логіки (генерація, застосування, повернення)
- Реалізація фронтенду: віджет вибору сертифіката, особистий кабінет із балансом
- Інтеграція з платіжним шлюзом та поштовим сервісом
- Покриття юніт-тестами (у тому числі race condition)
- Підготовка документації та доступів
Наш досвід: 10+ років у e-commerce, 50+ впроваджень, 5 років на ринку. Отримайте консультацію інженера: оцінимо терміни та вартість для вашого проекту.
Етапи роботи
- Аналітика — уточнюємо вимоги, фіксуємо бізнес-правила
- Проектування — схема БД, архітектура сервісів
- Реалізація — пишемо код, тестуємо на ізольованому стенді
- Тестування — навантажувальні тести, перевірка атомарності та повернень
- Деплой — викатка на продакшен, моніторинг
Терміни реалізації
Повна система займає від 1,5 до 2 тижнів. Терміни уточнюються після аналізу вимог. Замовте розробку системи подарункових сертифікатів — отримайте консультацію інженера. Оцінимо проект безкоштовно. Зв'яжіться з нами, щоб обговорити ваш проект.







