Архитектура affiliate-модуля под Obmenka CRM
Источник: Plan агент с чтением existing code, 2026-05-16
TL;DR
- Новый домен Affiliate — отдельный тип пользователя с ref-кодом и USDT-балансом
Client.referrer_affiliate_id— denorm указатель,client_affiliations— audit history- commission_entries: одна запись на (deal_id, affiliate_id, tier), UNIQUE для идемпотентности
- Multi-level через
parent_affiliate_id(≤ 3 уровня) + denormtier2/3_parent_id - Trigger расчёта = переход
Deal.status → done(в той же транзакции что сделка) - Lifecycle: pending → available (после hold) → paid (через payout_request → operation)
- RBAC новая роль
affiliateс отдельным layoutaff_base.html - MVP = single-level, плоская комиссия, ручные выплаты
Модель данных
Новые таблицы
affiliate_programs(id, code, name, base_model, tier1/2/3_pct, hold_days, min_payout, attribution_mode...)affiliates(id, user_id, external_ref, parent_affiliate_id, tier2/3_parent_id denorm, override ставки, payout_method, status)ref_codes(id, affiliate_id, code, label, clicks/signups/deals counters)client_affiliations(id, client_id, affiliate_id, ref_code_id, source, attached_at, detached_at) — partial UNIQUE на одну активнуюcommission_entries(id, deal_id, affiliate_id, tier, program_id, base_value_usdt, rate_pct, amount_usdt, status, available_at, paid_at, reverses_entry_id) — UNIQUE(deal_id, affiliate_id, tier)payout_requests(id, affiliate_id, amount, status, payout_op_id, matched_raw_tx_id)
Изменения
clients: +referrer_affiliate_id, +referrer_ref_code_id, +referrer_set_atdeals: +affiliate_snapshot_id, +commission_calculated_atusers: +totp_secret, +totp_enabled, +affiliate_idroles: новая записьaffiliate
Альтернативы отвергнуты
- Materialized view — у комиссий lifecycle, нужны транзакции
- Граф через CTE без denorm — не работает CHECK от циклов
- Только history без denorm referrer — будет JOIN на каждом списке
Алгоритм расчёта
def accrue_for_deal(deal):
if not deal.client.referrer_affiliate_id: return
aff = load(referrer_affiliate_id)
if aff.status != 'active': return
caff = active_affiliation(deal.client_id)
if caff.expires_at and deal.occurred_at > caff.expires_at: return
if caff.attribution_mode == 'first_deal_only' and deals_count > 1: return
base = compute_base(deal, program.base_model) # volume/profit/fixed/hybrid
# Tier 1
rate1 = aff.override_tier1_pct or program.tier1_pct
amt1 = base * rate1 + program.fixed_usdt_per_deal
if program.max_commission_per_deal_usdt: amt1 = min(amt1, ...)
upsert(deal_id, aff.id, tier=1, amount=amt1)
# Tier 2 (если parent есть)
# Tier 3 (если tier3_parent_id есть)
deal.affiliate_snapshot_id = aff.id
Edge cases
- aff.blocked → не начисляем, upline тоже молчит (анти-invite-fraud)
- cancel deal → reverse_entry с -amount
- recalc после edit → admin-only command, voidает pending + считает заново
- перепривязка клиента → старые комиссии за старым партнёром (snapshot)
- self-referral → check tg_username/phone match → void
- cycle в parent → защищено CHECK + BFS при назначении
Tracking
3 пути привязки клиента к партнёру:
1. Через ссылку /r/{code} → cookie 30 дней → при создании Client
2. Manual менеджером в карточке клиента
3. Self-claim через TG-бот (фаза 3)
/r/{code} — публичный endpoint, инкремент counter, signed cookie через URLSafeTimedSerializer, 302 на main page.
Перепривязка: detached_at = now() на старой, новая активная. Старые комиссии не трогаются.
Lifecycle комиссии и выплат
accrue → pending
↓ (hold_days)
available
↓ partner requests payout, admin approves
paid (linked to Operation crypto_out)
↓ reconcile.py связывает с raw_blockchain_tx
matched_raw_tx_id заполнен
Балансы партнёра = SUM(amount) WHERE status='pending'/'available'/'paid'.
Hold-cron каждый час: pending → available WHERE created_at + hold_days <= NOW() AND NOT (deal status cancelled).
RBAC
| Роль | Может |
|---|---|
| admin | CRUD programs, CRUD affiliates, approve payouts, корректировка commission |
| manager | Привязка клиента к партнёру (с reason), просмотр |
| accountant | Read-only + payouts approve |
| cashier | Только имя партнёра в карточке клиента |
| viewer | Без affiliate-инфы |
| affiliate | Свой кабинет: ссылки, клиенты (псевдонимы), заработок, выплаты, settings |
Privacy: партнёр НЕ видит ФИО/телефон клиентов, только external_ref C-XXXX + агрегаты.
2FA: TOTP в фазе 7.2 (опц на MVP, обязательно для production-выхода).
API endpoints
Публичные:
- GET /r/{code} — tracking redirect
Партнёрский кабинет (/aff/*, требует role=affiliate):
- Dashboard, links, clients, earnings, payouts, settings
Админ (/affiliates/*, /affiliate-programs/*, /payouts/*):
- CRUD affiliates, programs, attach клиента, approve payouts, void/recalc commission
Internal jobs:
- python -m app.jobs.commission_release (hourly)
- python -m app.jobs.commission_payout_auto (фаза 2)
UI
Админ — под существующим сайдбаром, новая группа «Партнёры»:
- /affiliates — список
- /affiliates/{id} — карточка с tabs (обзор/клиенты/комиссии/выплаты/даунлайн/настройки)
- /affiliate-programs — программы
- /payouts — pending выплаты
Изменения существующих:
- client_card.html — блок «Привязка к партнёру»
- clients_list.html — колонка «Партнёр»
- deal_card.html — блок «Комиссии партнёрам»
- dashboard.html — виджет аффилиатной программы
Партнёрский кабинет — отдельный layout aff_base.html (отличается визуально):
- /aff дашборд
- /aff/links мои ссылки
- /aff/clients мои клиенты (псевдонимизированно)
- /aff/earnings заработок (с экспортом CSV)
- /aff/payouts выплаты
- /aff/settings
Фазы
E7.1 MVP (3–4 нед)
- Миграция таблиц
- 1 модель:
volume_usdtс фиксированным % - БЕЗ multi-level
- attribution = lifetime
- Кабинет (без 2FA)
- Tracking
/r/{code}+ cookie + auto-attach - Manual attach менеджером
- Accrual в
deal_change_statusиdeal_new_post - Hold-cron
- Admin: CRUD + approve payouts
E7.2 (2–3 нед)
- Multi-level + tier2/3
- Override-ставки
- attribution_mode варианты
- Auto payout по threshold
- Scheduled payouts
- 2FA TOTP
- Anti-self-referral
- UI дерево даунлайна
E7.3 (отложено)
- Smart-contract выплаты
- ML/rule fraud detection
- Отдельный subdomain
partners.obmenka... - Webhooks партнёрам
- A/B-тесты программ
- Tax reporting
Риски
- Цикл parent_affiliate_id → защита: CHECK + BFS при назначении + MAX_TIER_DEPTH константа
- Hold-cron падение → APScheduler мониторинг + admin страница «холд должен был истечь»
- Race при accrue → UNIQUE (deal_id, affiliate_id, tier) защищает
- Изменение ставки задним числом → snapshot ставки в commission_entries → не меняется
- Cancel после выплаты → reverse-entry с negative, баланс уйдёт в минус, ручной разбор
Производительность
При 150 сделок/мес = 5 в день → ms. Реалистичный max 10k/мес = 30k entries/год — все запросы покрыты индексами:
- (affiliate_id, status) для балансов
- (deal_id) для лукапа
- partial available_at WHERE status='pending' для cron
- clients.referrer_affiliate_id для списка клиентов партнёра
Чего НЕ делаем в E7.1
| Что | Почему |
|---|---|
| Multi-level | Нет partners для этого |
| Auto-payouts | Нужно окно для проверки fraud |
| 2FA | Доверенные на старте |
| Self-signup | Barrier против fraud-аккаунтов |
| Несколько программ | Одна default достаточно |
| Smart-contract | Хранение приватных ключей — отдельный домен |
| Subdomain | Caddy complexity |
| Webhooks | Нет инфры |