Development of Gift Certificate System for E-Commerce
Gift certificate is prepaid credit tied to unique code. Technically hybrid of payment instrument and promo code: has balance debited on purchases, expiration date, and partial use ability. Improper implementation leads to accounting holes, double use, or balance leakage.
Certificate Types
By Nominal:
- Fixed nominal (500, 1000, 2000 rubles) — simple generation and accounting
- Custom nominal — buyer enters amount at checkout
By Source:
- Sold — customer paid, got code
- Promo/gift from store — issued manually or automatically (birthday, loyalty return)
By Use:
- Single-use — full nominal debited on first use
- Multi-use with balance — balance stored until expiration
Data Schema
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) -- order that bought it
);
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()
);
Table gift_certificate_usages — immutable log. Current balance in gift_certificates.balance — denormalized cache, always recoverable from log.
Code Generation
Code must be:
- Unique and unpredictable (not sequential ID)
- Convenient for manual entry (no similar chars: 0/O, 1/I/l)
- Short but entropy-sufficient
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-char alphabet, 4 segments of 4 chars = 32^16 ≈ 10^24 variants. Brute force impossible, collision virtually excluded.
Certificate Application at Checkout
Atomic deduction — key requirement:
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() excludes race condition on simultaneous use in two browser windows.
Refund on Order Cancellation
On return, balance restored:
public function refundTocertificate(GiftCertificateUsage $usage): void
{
DB::transaction(function () use ($usage) {
$cert = GiftCertificate::lockForUpdate()->find($usage->certificate_id);
// Don't restore more than 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]);
}
});
}
If certificate expired — refund policy at store discretion: extend or refund money.
Certificate Purchase as Product
Certificate is special order item type. On order paid status, certificate auto-generated and sent:
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);
}
}
}
Certificate Email Design
Email with certificate is product itself. Minimum:
- Beautiful HTML template with code in large font (copy-friendly)
- Nominal amount
- Expiration date
- QR code or direct link for use
- Personal message from sender
Certificate also available as PDF for self-printing. PDF generation via barryvdh/laravel-dompdf or puppeteer.
Balance in Personal Cabinet
If certificate tied to user account (not just code):
- "My Certificates" tab with balance and usage history
- Field to tie code to account
- Auto-offer to apply available balance at checkout
Application Restrictions
Optional business rules:
- Certificate applicable only to certain product categories
- Minimum order amount for use
- Can't pay for another certificate with certificate
- Only one certificate per order (or multiple — configurable)
Implementation Timeline
- Code generation + use + partial use: 3–4 days
- Certificate purchase as product + auto-issue: 2 days
- HTML/PDF email with design: 1–2 days
- Personal cabinet + usage history: 2 days
- Promo certificates (manual issue + automation): +1–2 days
Full system: 1.5–2 weeks.







