Разработка кэшбэк-платформы
Кэшбэк-платформа соединяет покупателей, магазины-партнёры и платёжный процессинг. Покупатель делает покупку через партнёрскую ссылку или карту, платформа получает комиссию от магазина и возвращает часть покупателю. Технически это система трекинга кликов/транзакций, расчёта вознаграждений и управления выплатами.
Модели трекинга
Существуют два принципиально разных механизма отслеживания покупок:
Affiliate-трекинг — пользователь переходит по специальной ссылке, совершает покупку, магазин уведомляет платформу через postback или пиксель.
Card-linked — привязка платёжной карты, транзакции отслеживаются через банковские API (Visa/Mastercard CLO, СБП). Требует партнёрства с банком.
Большинство платформ начинает с affiliate.
Схема данных
CREATE TABLE partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
website VARCHAR(500) NOT NULL,
logo_url VARCHAR(500),
cashback_rate NUMERIC(5,2) NOT NULL, -- % от суммы покупки
platform_rate NUMERIC(5,2) NOT NULL, -- полная комиссия от партнёра
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','paused','terminated')),
tracking_url VARCHAR(500), -- шаблон ссылки с {click_id}
network VARCHAR(50), -- admitad, cityads, собственная
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE clicks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
partner_id UUID NOT NULL REFERENCES partners(id),
click_id VARCHAR(100) UNIQUE NOT NULL, -- передаётся в партнёрскую сеть
ip INET,
user_agent TEXT,
referrer VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
click_id UUID REFERENCES clicks(id),
user_id UUID REFERENCES users(id),
partner_id UUID NOT NULL REFERENCES partners(id),
order_id VARCHAR(200), -- ID заказа в магазине
purchase_amount NUMERIC(15,2),
commission NUMERIC(15,2), -- получено от партнёра
cashback_amount NUMERIC(15,2), -- начислено пользователю
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','cancelled','paid')),
hold_until DATE, -- дата когда можно выплатить
source VARCHAR(50), -- 'postback','pixel','api'
raw_data JSONB, -- сырые данные от партнёра
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cashback_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id),
balance NUMERIC(15,2) NOT NULL DEFAULT 0, -- доступно к выводу
pending NUMERIC(15,2) NOT NULL DEFAULT 0, -- на холде
total_earned NUMERIC(15,2) NOT NULL DEFAULT 0,
total_withdrawn NUMERIC(15,2) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE withdrawals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
amount NUMERIC(15,2) NOT NULL,
method VARCHAR(30) NOT NULL CHECK (method IN ('card','sbp','wallet','phone')),
destination VARCHAR(200) NOT NULL, -- номер карты/телефона
status VARCHAR(20) NOT NULL DEFAULT 'pending',
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Трекинговые ссылки
import hashlib
import base64
from django.conf import settings
def generate_click_id(user_id: str, partner_id: str) -> str:
"""Уникальный click_id для отслеживания"""
raw = f'{user_id}:{partner_id}:{timezone.now().timestamp()}'
return base64.urlsafe_b64encode(
hashlib.sha256(raw.encode()).digest()[:12]
).decode().rstrip('=')
def build_tracking_url(user, partner) -> str:
click_id = generate_click_id(str(user.id), str(partner.id))
# Сохраняем клик
Click.objects.create(
user=user,
partner=partner,
click_id=click_id,
)
# Строим ссылку с click_id
tracking_url = partner.tracking_url.replace('{click_id}', click_id)
# Пример: https://ad.admitad.com/g/abc123/?subid={click_id}
# → https://ad.admitad.com/g/abc123/?subid=xK9mNpQr
return tracking_url
Endpoint для редиректа:
def click_redirect(request, partner_slug):
partner = get_object_or_404(Partner, slug=partner_slug, status='active')
user = request.user
if not user.is_authenticated:
# Сохраняем намерение, редиректим на логин
request.session['pending_cashback_partner'] = partner_slug
return redirect('/login/?next=' + request.path)
url = build_tracking_url(user, partner)
# Аналитика
track_event.delay('cashback_click', {
'user_id': str(user.id),
'partner_id': str(partner.id),
})
return HttpResponseRedirect(url)
Postback от партнёрских сетей
Admitad, CityAds и большинство CPA-сетей уведомляют через GET-запрос (postback):
# GET /postback/admitad/?click_id={click_id}&order_id={order_id}&
# sale_amount={sale_amount}&commission={commission}&status={status}
def admitad_postback(request):
# Проверяем подпись (у каждой сети свой алгоритм)
provided_sig = request.GET.get('sig')
click_id = request.GET.get('click_id')
expected_sig = hmac.new(
settings.ADMITAD_SECRET.encode(),
click_id.encode(),
hashlib.md5
).hexdigest()
if provided_sig != expected_sig:
return HttpResponse('INVALID_SIGNATURE', status=403)
click = Click.objects.filter(click_id=click_id).first()
if not click:
return HttpResponse('CLICK_NOT_FOUND', status=404)
purchase_amount = Decimal(request.GET.get('sale_amount', '0'))
commission = Decimal(request.GET.get('commission', '0'))
cashback_amount = commission * (click.partner.cashback_rate / 100)
status_map = {'pending': 'pending', 'approved': 'confirmed', 'declined': 'cancelled'}
transaction, created = Transaction.objects.get_or_create(
order_id=request.GET.get('order_id'),
partner=click.partner,
defaults={
'click': click,
'user': click.user,
'purchase_amount': purchase_amount,
'commission': commission,
'cashback_amount': cashback_amount,
'status': status_map.get(request.GET.get('status'), 'pending'),
'hold_until': date.today() + timedelta(days=click.partner.hold_days),
'source': 'postback',
'raw_data': dict(request.GET),
}
)
if not created:
# Обновление статуса (approved → cancelled)
transaction.status = status_map.get(request.GET.get('status'), transaction.status)
transaction.save()
if transaction.status == 'confirmed':
credit_cashback.delay(str(transaction.id))
return HttpResponse('OK')
Начисление и выплата кэшбэка
@shared_task
def credit_cashback(transaction_id: str):
"""Начисляем кэшбэк после подтверждения транзакции"""
with transaction_lock(transaction_id):
txn = Transaction.objects.select_for_update().get(id=transaction_id)
if txn.status != 'confirmed':
return
account, _ = CashbackAccount.objects.select_for_update().get_or_create(
user=txn.user
)
account.pending += txn.cashback_amount
account.total_earned += txn.cashback_amount
account.save()
txn.status = 'credited'
txn.save()
notify_cashback_credited.delay(str(txn.user_id), float(txn.cashback_amount))
@shared_task
def release_held_cashback():
"""Ежедневно: переводим подтверждённый кэшбэк из pending в balance"""
today = date.today()
ready = Transaction.objects.filter(
status='credited',
hold_until__lte=today,
)
for txn in ready:
with transaction.atomic():
account = CashbackAccount.objects.select_for_update().get(user=txn.user)
account.pending -= txn.cashback_amount
account.balance += txn.cashback_amount
account.save()
txn.status = 'available'
txn.save()
Выплата через СБП
def request_withdrawal_sbp(user, amount: Decimal, phone: str):
account = CashbackAccount.objects.select_for_update().get(user=user)
if account.balance < amount:
raise InsufficientBalanceError()
if amount < Decimal('100'):
raise ValidationError('Минимальная сумма вывода 100 ₽')
account.balance -= amount
account.total_withdrawn += amount
account.save()
withdrawal = Withdrawal.objects.create(
user=user,
amount=amount,
method='sbp',
destination=phone,
status='pending',
)
# Отправляем в платёжный шлюз (ЮKassa, Тинькофф, etc.)
process_sbp_payout.delay(str(withdrawal.id))
return withdrawal
Сроки
MVP с affiliate-трекингом, постбэком Admitad, личным кабинетом и выплатой на карту: 6–8 недель. Полная платформа с несколькими сетями, card-linked офферами, реферальной программой и аналитическим дашбордом для партнёров: 4–5 месяцев.







