Розробка системи подарункових сертифікатів для E-Commerce
Подарунковий сертифікат — це передоплачений кредит, прив'язаний до унікального коду. Технічно це гібрид платіжного інструменту та промокоду: у нього є баланс, який списується при покупках, термін дії та можливість часткового використання. Неправильна реалізація веде до дір у обліку, подвійному використанню або витіку балансу.
Типи сертифікатів
По номіналу:
- Фіксований номінал (500, 1000, 2000 руб.) — проста генерація та облік
- Довільний номінал — покупатель вводить суму при оформленні
По джерелу:
- Продані — покупатель заплатив гроші, отримав код
- Промо / подарункові від магазину — виданяються вручну або автоматично (день народження, повернення лояльності)
По використанню:
- Одноразові — списується повний номінал при першому застосуванні
- Багаторазові з залишком — залишок зберігається до вичерпання терміну
Схема даних
CREATE TABLE gift_certificates (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE,
type VARCHAR(20) NOT NULL DEFAULT 'sold', -- sold | promo
initial_amount NUMERIC(12,2) NOT NULL,
balance NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
purchased_by BIGINT REFERENCES users(id),
recipient_email VARCHAR(255),
recipient_name VARCHAR(255),
personal_message TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
issued_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP,
order_id BIGINT REFERENCES orders(id) -- заказ, яким куплений
);
CREATE TABLE gift_certificate_usages (
id BIGSERIAL PRIMARY KEY,
certificate_id BIGINT NOT NULL REFERENCES gift_certificates(id),
order_id BIGINT NOT NULL REFERENCES orders(id),
amount_used NUMERIC(12,2) NOT NULL,
balance_before NUMERIC(12,2) NOT NULL,
balance_after NUMERIC(12,2) NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Таблиця gift_certificate_usages — незмінний журнал. Поточний баланс у gift_certificates.balance — денормалізований кеш, завжди відновлюємий з журналу.
Генерація кодів
Код повинен бути:
- Унікальним та непередбачуваним (не sequential ID)
- Зручним для ручного введення (без схожих символів: 0/O, 1/I/l)
- Коротким, але достатньо ентропійним
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
}
}
32 символи алфавіту, 4 сегменти по 4 символи = 32^16 ≈ 10^24 варіантів. Брутфорс неможливий, колізія практично виключена.
Застосування сертифіката при оформленні замовлення
Атомарне списання — ключова вимога:
class GiftCertificateService
{
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);
});
}
}
lockForUpdate() виключає race condition при одночасному застосуванні одного коду в двох вікнах браузера.
Повернення при скасуванні замовлення
При поверненні замовлення, оплаченого сертифікатом, баланс відновлюється:
public function refundTocertificate(GiftCertificateUsage $usage): void
{
DB::transaction(function () use ($usage) {
$cert = GiftCertificate::lockForUpdate()->find($usage->certificate_id);
// Не відновлюємо більше initial_amount
$refundAmount = min(
$usage->amount_used,
$cert->initial_amount - $cert->balance
);
$cert->increment('balance', $refundAmount);
if (!$cert->is_active && $cert->balance > 0) {
$cert->update(['is_active' => true]);
}
});
}
Якщо термін сертифіката вичерпаний — політика повернення на розсуд магазину: продовжити або повернути гроші.
Покупка сертифіката як товару
Сертифікат — особливий тип позиції у замовленні. При створенні замовлення у статусі "оплачено" автоматично генерується сертифікат та надсилається отримувачу:
class IssuePurchasedCertificates
{
public function handle(OrderPaid $event): void
{
foreach ($event->order->items as $item) {
if ($item->product->type !== 'gift_certificate') {
continue;
}
$cert = GiftCertificate::create([
'code' => $this->generator->generate(),
'initial_amount' => $item->unit_price,
'balance' => $item->unit_price,
'purchased_by' => $event->order->user_id,
'recipient_email' => $item->meta['recipient_email'] ?? null,
'recipient_name' => $item->meta['recipient_name'] ?? null,
'personal_message' => $item->meta['message'] ?? null,
'expires_at' => now()->addYear(),
'order_id' => $event->order->id,
]);
SendGiftCertificate::dispatch($cert);
}
}
}
Дизайн листа з сертифікатом
Лист з сертифікатом — це продукт сам по собі. Мінімум:
- Красивий HTML-шаблон з кодом крупним шрифтом (copy-friendly)
- Сума номіналу
- Термін дії
- QR-код або пряма ссилка для застосування
- Персональне сообщение від відправника
Сертифікат також повинен бути доступен у PDF для самостійної розпечатки. Генерація PDF через barryvdh/laravel-dompdf або puppeteer.
Баланс у особистому кабінеті
Якщо сертифікат прив'язаний до аккаунту користувача (не тільки до коду):
- Вкладка "Мої сертифікати" з балансом та історією використання
- Поле для прив'язки коду до аккаунту
- При оформленні замовлення — автоматичне пропозиція застосувати доступний баланс
Обмеження застосування
Додаткові бізнес-правила:
- Сертифікат застосовується тільки до певних категорій товарів
- Мінімальна сума замовлення для застосування
- Не можна оплатити сертифікатом покупку іншого сертифіката
- Тільки один сертифікат на замовлення (або кілька — конфігурюємо)
Сроки реалізації
- Генерація кодів + застосування + часткове використання: 3–4 дня
- Покупка сертифіката як товару + автовипуск: 2 дня
- HTML/PDF лист з дизайном: 1–2 дня
- Особистий кабінет + історія використання: 2 дня
- Промо-сертифікати (ручна видача + автоматика): +1–2 дня
Повна система: 1,5–2 тижня.







