Розробка системи управління програмою лояльності
Програма лояльності — це не тільки "бонусні бали". Це механізм, який змінює поведінку покупця: змушує повертатися, збільшувати середній чек, вибирати конкретний канал. Технічно це облікова система балів, рівнів і вознаграждень, вбудована у всі точки контакту з клієнтом.
Архітектура: счет, транзакції, рівні
-- Бонусний счет користувача
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, -- всього заробленo (для рівнів)
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, -- поріг за累積
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>
</div>
);
};
Інтеграція з чекаутом
Покупець вибирає, скільки балів застосувати:
const maxRedeemable = Math.min(
loyaltyAccount.balance,
order.total * (loyaltySettings.max_redeem_percent / 100)
);
Терміни реалізації
Базова система з нарахуванням, списанням та історією транзакцій: 1,5–2 тижні. Додавання рівнів, кампаній з множниками, сгорання балів і віджетів для фронтенду: 3–4 тижні. Мобільна карта лояльності з QR-кодом та інтеграція з POS-терміналами: плюс 2–3 тижні.







