Разработка системы подарочных сертификатов для интернет-магазина
Подарочный сертификат — это предоплаченный кредит, привязанный к уникальному коду. Технически это гибрид платёжного инструмента и промокода: у него есть баланс, который списывается при покупках, срок действия и возможность частичного использования. Неправильная реализация ведёт к дырам в учёте, двойному использованию или утечке баланса.
Типы сертификатов
По номиналу:
- Фиксированный номинал (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]);
}
});
}
Если срок сертификата истёк — политика возврата на усмотрение магазина: можно продлить или вернуть деньгами.
Покупка сертификата как товара
Сертификат — особый тип позиции в заказе. При создании заказа в статусе «оплачен» автоматически генерируется сертификат и отправляется получателю:
// Слушатель события OrderPaid
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 недели.







