Разработка системы управления программой лояльности
Программа лояльности — это не только «бонусные баллы». Это механизм, который меняет поведение покупателя: заставляет возвращаться, увеличивать средний чек, выбирать конкретный канал. Технически это учётная система баллов, уровней и вознаграждений, встроенная в все точки контакта с клиентом.
Архитектура: счёт, транзакции, уровни
-- Бонусный счёт пользователя
CREATE TABLE loyalty_accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT UNIQUE REFERENCES users(id),
balance DECIMAL(12,2) DEFAULT 0, -- текущий баланс
lifetime_earned DECIMAL(12,2) DEFAULT 0, -- всего заработано (для уровней)
tier_id BIGINT REFERENCES loyalty_tiers(id),
expires_at DATE, -- дата сгорания баллов
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Все движения баллов (append-only лог)
CREATE TABLE loyalty_transactions (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT REFERENCES loyalty_accounts(id),
type VARCHAR(32) NOT NULL, -- 'earn', 'redeem', 'expire', 'adjust', 'refund'
amount DECIMAL(12,2) NOT NULL, -- положительное или отрицательное
balance_after DECIMAL(12,2) NOT NULL,
reason VARCHAR(255),
source_type VARCHAR(64), -- 'order', 'manual', 'birthday', 'referral'
source_id BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Уровни программы
CREATE TABLE loyalty_tiers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL, -- Bronze, Silver, Gold, Platinum
min_lifetime DECIMAL(12,2) NOT NULL, -- порог по accumulated
earn_multiplier DECIMAL(4,2) DEFAULT 1.0, -- множитель начисления
redeem_rate DECIMAL(4,2) DEFAULT 1.0, -- 1 балл = X рублей
perks JSONB -- дополнительные привилегии
);
Транзакционный лог — принципиальное архитектурное решение. Баланс всегда считается из истории, либо хранится денормализованно и пересчитывается при расхождении. Это позволяет аудитировать любое движение.
Начисление баллов
class LoyaltyService {
public function earnPoints(User $user, float $amount, string $sourceType, int $sourceId): LoyaltyTransaction {
$account = LoyaltyAccount::firstOrCreate(['user_id' => $user->id]);
$tier = $account->tier ?? LoyaltyTier::where('min_lifetime', 0)->orderBy('min_lifetime')->first();
$points = round($amount * $tier->earn_multiplier * config('loyalty.earn_rate'));
// earn_rate: например, 0.05 = 5 баллов за каждые 100 рублей
return DB::transaction(function() use ($account, $points, $sourceType, $sourceId) {
$newBalance = $account->balance + $points;
$account->update([
'balance' => $newBalance,
'lifetime_earned' => $account->lifetime_earned + $points,
]);
// Пересчёт уровня
$newTier = LoyaltyTier::where('min_lifetime', '<=', $account->lifetime_earned)
->orderByDesc('min_lifetime')
->first();
if ($newTier && $newTier->id !== $account->tier_id) {
$account->update(['tier_id' => $newTier->id]);
event(new TierUpgraded($account->user, $newTier));
}
return LoyaltyTransaction::create([
'account_id' => $account->id,
'type' => 'earn',
'amount' => $points,
'balance_after'=> $newBalance,
'source_type' => $sourceType,
'source_id' => $sourceId,
'reason' => 'Начисление за покупку',
]);
});
}
}
Списание баллов при оплате
public function redeemPoints(User $user, float $pointsToRedeem, int $orderId): array {
$account = LoyaltyAccount::where('user_id', $user->id)->lockForUpdate()->first();
if (!$account || $account->balance < $pointsToRedeem) {
throw new InsufficientPointsException();
}
$tier = $account->tier;
$discount = $pointsToRedeem * $tier->redeem_rate; // баллы -> рубли
$newBalance = $account->balance - $pointsToRedeem;
DB::transaction(function() use ($account, $pointsToRedeem, $newBalance, $orderId, $discount) {
$account->update(['balance' => $newBalance]);
LoyaltyTransaction::create([
'account_id' => $account->id,
'type' => 'redeem',
'amount' => -$pointsToRedeem,
'balance_after' => $newBalance,
'source_type' => 'order',
'source_id' => $orderId,
'reason' => 'Оплата баллами',
]);
});
return ['discount' => $discount, 'points_used' => $pointsToRedeem, 'remaining' => $newBalance];
}
Сгорание баллов
Баллы могут иметь срок жизни. Задача в планировщике:
// Ежедневно в 02:00
Schedule::call(function() {
// Находим транзакции earn, которые истекают сегодня
$expiring = LoyaltyTransaction::where('type', 'earn')
->whereDate('expires_at', today())
->where('expired', false)
->get();
foreach ($expiring as $transaction) {
// Считаем, сколько из этих баллов ещё не было потрачено
$available = $this->loyaltyService->getAvailableFromTransaction($transaction);
if ($available > 0) {
$this->loyaltyService->expirePoints($transaction->account, $available, $transaction->id);
}
}
})->dailyAt('02:00');
Правила начисления: конфигурируемые кампании
Вместо хардкода множителей — таблица кампаний:
CREATE TABLE loyalty_campaigns (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
type VARCHAR(32), -- 'multiplier', 'fixed', 'category', 'birthday'
multiplier DECIMAL(4,2),
fixed_bonus DECIMAL(10,2),
conditions JSONB, -- {"min_order": 2000, "category_ids": [5, 12]}
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
active BOOLEAN DEFAULT TRUE
);
public function getApplicableCampaigns(Order $order): Collection {
return LoyaltyCampaign::active()
->where('starts_at', '<=', now())
->where(fn($q) => $q->whereNull('ends_at')->orWhere('ends_at', '>=', now()))
->get()
->filter(fn($campaign) => $this->campaignApplies($campaign, $order));
}
private function campaignApplies(LoyaltyCampaign $campaign, Order $order): bool {
$conditions = $campaign->conditions ?? [];
if (isset($conditions['min_order']) && $order->total < $conditions['min_order']) {
return false;
}
if (isset($conditions['category_ids'])) {
$hasCategory = $order->items->pluck('product.category_id')
->intersect($conditions['category_ids'])
->isNotEmpty();
if (!$hasCategory) return false;
}
return true;
}
UI: виджет баланса и история транзакций
const LoyaltyWidget: React.FC = () => {
const { data: account } = useQuery({
queryKey: ['loyalty', 'account'],
queryFn: () => api.get('/loyalty/account'),
});
if (!account) return null;
return (
<div className="loyalty-widget bg-gradient-to-r from-amber-400 to-orange-500 rounded-xl p-4 text-white">
<div className="flex justify-between items-center">
<div>
<p className="text-sm opacity-80">Бонусный баланс</p>
<p className="text-3xl font-bold">{account.balance.toLocaleString()}</p>
<p className="text-xs opacity-70">баллов</p>
</div>
<div className="text-right">
<p className="text-sm opacity-80">Уровень</p>
<p className="font-semibold">{account.tier.name}</p>
<p className="text-xs opacity-70">×{account.tier.earn_multiplier} к баллам</p>
</div>
</div>
{account.next_tier && (
<div className="mt-3">
<p className="text-xs mb-1">
До уровня {account.next_tier.name}: {account.next_tier.remaining.toLocaleString()} баллов
</p>
<div className="bg-white/30 rounded-full h-1.5">
<div
className="bg-white rounded-full h-1.5 transition-all"
style={{ width: `${account.tier_progress}%` }}
/>
</div>
</div>
)}
</div>
);
};
Интеграция с чекаутом
При оформлении заказа покупатель выбирает, сколько баллов применить:
// Проверка доступного дисконта
const maxRedeemable = Math.min(
loyaltyAccount.balance,
order.total * (loyaltySettings.max_redeem_percent / 100)
);
Сроки реализации
Базовая система с начислением, списанием и историей транзакций: 1,5–2 недели. Добавление уровней, кампаний с множителями, сгорания баллов и виджетов для фронтенда: 3–4 недели. Мобильная карта лояльности с QR-кодом и интеграция с POS-терминалами: плюс 2–3 недели.







