Жизненный цикл комиссии
Полный жизненный цикл расчёта комиссий с использованием Дифференциальной модели, от триггерного события до выплаты.
Обзор
Жизненный цикл комиссии включает:
- Триггерные события (7 видов дохода)
- Создание задания и постановка в очередь
- Идемпотентность и предотвращение дубликатов
- Получение восходящей линии (неограниченная глубина)
- Расчёт дифференциальной комиссии
- Обновление баланса и запись транзакций
- Переход из ожидающего в доступный статус
- Обработка отмены комиссий
- Распределение лидерских пулов
- Проверка права на выплату и обработка
Обзор модели комиссий
| Свойство | Значение |
|---|---|
| Тип модели | Дифференциальная (не Unilevel) |
| Глубина | Неограниченная |
| Виды дохода | 7 (3 активных + 3 пассивных + 1 пул) |
| Основная валюта | USD |
| Диапазон ставок | 3% - 20% в зависимости от ранга |
Основная формула расчёта:
IF ранг_консультанта > ранг_партнёра_источника:
комиссия = (ставка_консультанта - ставка_источника) × сумма
ELSE:
комиссия = 0 // Нет комиссии при равном или более низком рангеОсновная диаграмма процесса
Детали этапов
1. Триггерные события (7 видов дохода)
События, запускающие расчёт комиссии:
| Вид дохода | Триггерное событие | Тип источника | Расчёт |
|---|---|---|---|
| 1. Личные продажи | Прямая продажа своему клиенту | ORDER | personal_rate × сумма |
| 2. Командные продажи | Партнёр даунлайна совершает продажу | ORDER | Дифференциал от суммы продажи |
| 3. Повторные продажи | Существующий клиент совершает повторную покупку | ORDER | Как личные продажи |
| 4. Доход от портфеля | Прибыль от собственной инвестиции | INVESTMENT_PROFIT | Доходность стратегии |
| 5. Прибыль клиентов | Личный клиент получает прибыль | INVESTMENT_PROFIT | passive_rate × прибыль_клиента |
| 6. Сетевая прибыль | Клиент даунлайна получает прибыль | INVESTMENT_PROFIT | Дифференциал от прибыли |
| 7. Лидерский пул | Еженедельное/ежемесячное распределение | POOL_DISTRIBUTION | Равная доля |
Структура payload задания:
interface DifferentialCommissionPayload {
idempotencyKey: string;
incomeType:
| "PERSONAL_SALES"
| "TEAM_SALES"
| "REPEAT_SALES"
| "CLIENT_PROFITS"
| "NETWORK_PROFITS"
| "LEADERSHIP_POOL";
sourceType:
| "ORDER"
| "INVESTMENT"
| "INVESTMENT_PROFIT"
| "POOL_DISTRIBUTION";
sourceId: string;
amountUsd: number;
sourcePartnerId: string;
sourcePartnerRank: string;
sourcePartnerRate: number;
}Формат ключа идемпотентности:
commission:{incomeType}:{sourceId}:{timestamp}Пример: commission:TEAM_SALES:550e8400-e29b-41d4-a716-446655440000:1705315200
2. Создание задания и постановка в очередь
Конфигурация задания pg-boss:
await boss.send("differential-commission", payload, {
retryLimit: 5,
retryDelay: 30000,
retryBackoff: true,
expireInMinutes: 60,
singletonKey: payload.idempotencyKey,
});Расписание повторов:
| Попытка | Задержка | Общее время |
|---|---|---|
| 1 | Немедленно | 0 |
| 2 | 30 секунд | 30с |
| 3 | 2 минуты | 2.5м |
| 4 | 10 минут | 12.5м |
| 5 | 30 минут | 42.5м |
| Неудача | Н/Д | Перемещение в dead-letter очередь |
3. Проверка идемпотентности
Паттерн двойной проверки:
async function processDifferentialCommission(
job: DifferentialCommissionPayload,
) {
const { idempotencyKey } = job;
// 1. Быстрый путь: Проверка вне транзакции
const existing = await db.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existing) {
return existing.response;
}
// 2. Обработка в транзакции с перепроверкой
return await db.$transaction(
async (tx) => {
const existingInTx = await tx.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existingInTx) {
return existingInTx.response;
}
// ... обработка дифференциальной комиссии ...
await tx.idempotencyKey.create({
data: {
key: idempotencyKey,
response: result,
expiresAt: addDays(new Date(), 30),
},
});
return result;
},
{
isolationLevel: "Serializable",
timeout: 30000,
},
);
}4. Получение восходящей линии (неограниченная глубина)
Запрос:
SELECT
p.id as partner_id,
p.status,
r.code as rank_code,
r.personal_sales_rate,
r.passive_income_rate,
r.entrance_fee_rate,
ptp.depth
FROM mlm.partner_tree_paths ptp
JOIN mlm.partners p ON p.id = ptp.ancestor_id
JOIN mlm.ranks r ON r.id = p.current_rank_id
WHERE ptp.descendant_id = $1::uuid
AND ptp.depth >= 1
ORDER BY ptp.depth ASC;Пример результата:
| partner_id | rank_code | personal_sales_rate | depth |
|---|---|---|---|
| partner-A | 5 | 14 | 1 |
| partner-B | 3 | 10 | 2 |
| partner-C | 6 | 16 | 3 |
Ключевое отличие от Unilevel:
- Нет лимита уровней — извлекается ВСЯ восходящая линия
- Обработка останавливается при достижении максимальной ставки (20%)
- Комиссия основана на дифференциале ставок, а не на проценте уровня
5. Расчёт дифференциальной комиссии
Основной алгоритм:
async function calculateDifferentialCommissions(
tx: Transaction,
job: DifferentialCommissionPayload,
upline: UplinePartner[],
) {
const commissions = [];
let currentSourceRate = job.sourcePartnerRate;
for (const ancestor of upline) {
if (ancestor.status !== "ACTIVE") continue;
// Получение применимой ставки в зависимости от вида дохода
const ancestorRate = getApplicableRate(job.incomeType, ancestor);
// Расчёт дифференциала
const differentialRate = ancestorRate - currentSourceRate;
if (differentialRate > 0) {
const grossAmountUsd = job.amountUsd * (differentialRate / 100);
const netAmountUsd = grossAmountUsd;
// Создание записи комиссии
const commission = await tx.commissionTransaction.create({
data: {
partnerId: ancestor.partner_id,
incomeType: job.incomeType,
sourceType: job.sourceType,
sourceId: job.sourceId,
sourcePartnerId: job.sourcePartnerId,
ownRate: ancestorRate,
sourceRate: currentSourceRate,
differentialRate,
grossAmountUsd,
netAmountUsd,
currency: "USD",
status: "PENDING",
idempotencyKey: `${job.idempotencyKey}:${ancestor.partner_id}`,
},
});
// Обновление ожидающего баланса
await tx.partnerBalance.update({
where: { partnerId: ancestor.partner_id },
data: {
pendingBalanceUsd: { increment: netAmountUsd },
version: { increment: 1 },
lastCalculatedAt: new Date(),
},
});
commissions.push(commission);
// Обновление текущей ставки источника для следующей итерации
currentSourceRate = ancestorRate;
}
// Остановка при достижении макс. ставки (20%)
if (currentSourceRate >= 20) break;
}
return commissions;
}
function getApplicableRate(incomeType: string, partner: UplinePartner): number {
switch (incomeType) {
case "PERSONAL_SALES":
case "TEAM_SALES":
case "REPEAT_SALES":
return partner.personal_sales_rate;
case "CLIENT_PROFITS":
case "NETWORK_PROFITS":
return partner.passive_income_rate;
default:
return partner.personal_sales_rate;
}
}Пример дифференциального расчёта:
Сумма продажи: $10,000 USD Партнёр-источник: Ранг 2 (ставка 8%)
| Глубина | Партнёр | Ранг | Ставка | Дифференциал | Комиссия |
|---|---|---|---|---|---|
| 1 | Алиса | 5 | 14% | 14% - 8% = 6% | $600 |
| 2 | Борис | 3 | 10% | 0% (10% < 14%) | $0 |
| 3 | Карина | 7 | 17% | 17% - 14% = 3% | $300 |
| 4 | Дмитрий | 7 | 17% | 0% (та же ставка) | $0 |
| 5 | Елена | 10 | 19.5% | 19.5% - 17% = 2.5% | $250 |
Итого распределено: $1,150 (11.5%)
Ключевые моменты:
- Борис и Дмитрий получают $0, потому что их ставки не выше предыдущего заработавшего партнёра
- Комиссия «перескакивает» к партнёрам с более высоким рангом в восходящей линии
- Обработка продолжается до достижения максимальной ставки 20% или конца восходящей линии
6. Обновление балансов
Структура записи баланса (USD):
{
"partnerId": "uuid",
"availableBalanceUsd": 4500.0,
"pendingBalanceUsd": 1500.0,
"totalEarnedUsd": 12000.0,
"totalWithdrawnUsd": 6000.0,
"incomePersonalSalesUsd": 3000.0,
"incomeTeamSalesUsd": 2500.0,
"incomeRepeatSalesUsd": 1000.0,
"incomePortfolioReturnsUsd": 800.0,
"incomeClientProfitsUsd": 1200.0,
"incomeNetworkProfitsUsd": 900.0,
"incomeLeadershipPoolUsd": 2600.0,
"currency": "USD",
"version": 42
}Паттерн блокировки:
-- Заблокировать ВСЕ затронутые балансы в последовательном порядке
SELECT id FROM mlm.partner_balances
WHERE partner_id = ANY($1::uuid[])
ORDER BY partner_id
FOR UPDATE;7. Переход из ожидающего в доступный
Периоды удержания по типу источника:
| Тип источника | Период удержания | Причина |
|---|---|---|
| Заказ продукта | 14 дней | Окно возврата/обмена |
| Инвестиция | 7 дней | Верификация средств |
| Пассивный доход | 7 дней | Подтверждение прибыли |
| Лидерский пул | 0 дней | Немедленное распределение |
Задание подтверждения (ежедневное):
async function confirmPendingCommissions() {
const eligibleCommissions = await db.commissionTransaction.findMany({
where: {
status: "PENDING",
createdAt: { lt: getHoldingPeriodCutoff() },
},
});
const byPartner = groupBy(eligibleCommissions, "partnerId");
for (const [partnerId, commissions] of Object.entries(byPartner)) {
await db.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT 1 FROM mlm.partner_balances
WHERE partner_id = ${partnerId}::uuid
FOR UPDATE
`;
const totalAmountUsd = commissions.reduce(
(sum, c) => sum + c.netAmountUsd,
0,
);
await tx.commissionTransaction.updateMany({
where: { id: { in: commissions.map((c) => c.id) } },
data: { status: "APPROVED", processedAt: new Date() },
});
await tx.partnerBalance.update({
where: { partnerId },
data: {
pendingBalanceUsd: { decrement: totalAmountUsd },
availableBalanceUsd: { increment: totalAmountUsd },
version: { increment: 1 },
},
});
});
}
}8. Отмена комиссии
Триггеры отмены:
| Триггер | Источник | Действие |
|---|---|---|
| Возврат заказа | Запрос клиента | Отменить все связанные комиссии |
| Чарджбэк | Платёжный провайдер | Отменить + пометить партнёра |
| Обнаружение мошенничества | Админ/Система | Отменить и пометить |
| Отмена инвестиции | Клиент/Админ | Отменить все связанные |
Процесс отмены:
async function reverseCommissions(
sourceType: string,
sourceId: string,
reason: string,
adminId: string,
) {
const commissions = await db.commissionTransaction.findMany({
where: {
sourceType,
sourceId,
status: { in: ["PENDING", "APPROVED", "PAID"] },
},
});
for (const commission of commissions) {
await db.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT 1 FROM mlm.partner_balances
WHERE partner_id = ${commission.partnerId}::uuid
FOR UPDATE
`;
if (commission.status === "PENDING") {
await tx.partnerBalance.update({
where: { partnerId: commission.partnerId },
data: {
pendingBalanceUsd: { decrement: commission.netAmountUsd },
},
});
} else {
await tx.commissionTransaction.create({
data: {
partnerId: commission.partnerId,
incomeType: commission.incomeType,
sourceType: "CLAWBACK",
sourceId,
grossAmountUsd: -commission.grossAmountUsd,
netAmountUsd: -commission.netAmountUsd,
status: "CLAWBACK",
reversedFromId: commission.id,
reversalReason: reason,
reversedBy: adminId,
},
});
await tx.partnerBalance.update({
where: { partnerId: commission.partnerId },
data: {
availableBalanceUsd: { decrement: commission.netAmountUsd },
},
});
}
await tx.commissionTransaction.update({
where: { id: commission.id },
data: {
status: "REVERSED",
reversalReason: reason,
reversedAt: new Date(),
reversedBy: adminId,
},
});
});
}
}9. Распределение лидерского пула
Конфигурация пулов:
| Пул | Ранги | % от оборота | Частота | Квалификация |
|---|---|---|---|---|
| POOL_5 | 5, 5_PRO | 1% | Еженедельно | $5,000 / $10,000 недельный объём |
| POOL_6 | 6, 6_PRO | 0.5% | Еженедельно | $20,000 / $30,000 недельный объём |
| POOL_7 | 7, 7_PRO | 0.5% | Еженедельно | $45,000 / $60,000 недельный объём |
| POOL_8 | 8, 8_PRO | 0.5% | Еженедельно | $90,000 / $120,000 недельный объём |
| POOL_9 | 9, 9_PRO | 1% | Ежемесячно | Только достижение ранга |
| POOL_10 | 10, 10_PRO | 1% | Ежемесячно | Только достижение ранга |
| POOL_11 | 11, 11_PRO | 1% | Ежемесячно | Только достижение ранга |
Примечание: Пулы 9-11 не требуют отдельного квалификационного объёма. Партнёры, достигшие этих рангов, автоматически участвуют в ежемесячном распределении.
Правило 50% на ветку (только для пулов 5-8):
async function findQualifiedPartners(
pool: LeadershipPool,
periodStart: Date,
periodEnd: Date,
) {
const candidates = await db.partner.findMany({
where: {
status: "ACTIVE",
rank: { code: { in: pool.eligibleRanks } },
},
});
const qualifiedPartners = [];
for (const partner of candidates) {
const requiredVolume = pool.qualificationVolumes[partner.rank.code];
const branchVolumes = await calculateBranchVolumes(
partner.id,
periodStart,
periodEnd,
);
// Применение лимита 50% на ветку
const maxPerBranch = requiredVolume * 0.5;
const cappedTotal = Object.values(branchVolumes).reduce(
(sum, vol) => sum + Math.min(vol, maxPerBranch),
0,
);
if (cappedTotal >= requiredVolume) {
qualifiedPartners.push({
...partner,
qualificationVolume: cappedTotal,
branchVolumes,
});
}
}
return qualifiedPartners;
}Процесс распределения:
async function distributeLeadershipPool(
poolCode: string,
periodStart: Date,
periodEnd: Date,
) {
const pool = await db.leadershipPool.findUnique({ where: { poolCode } });
const totalTurnover = await calculatePeriodTurnover(periodStart, periodEnd);
const poolAmountUsd = totalTurnover * pool.percentageOfTurnover;
const qualifiedPartners = await findQualifiedPartners(
pool,
periodStart,
periodEnd,
);
if (qualifiedPartners.length === 0) return { distributed: false };
const perPersonAmountUsd = poolAmountUsd / qualifiedPartners.length;
for (const partner of qualifiedPartners) {
await db.$transaction(async (tx) => {
await tx.commissionTransaction.create({
data: {
partnerId: partner.id,
incomeType: "LEADERSHIP_POOL",
sourceType: "POOL_DISTRIBUTION",
sourceId: distribution.id,
grossAmountUsd: perPersonAmountUsd,
netAmountUsd: perPersonAmountUsd,
currency: "USD",
status: "APPROVED", // Немедленно для пулов
},
});
// Добавление напрямую на доступный баланс (без периода ожидания)
await tx.partnerBalance.update({
where: { partnerId: partner.id },
data: {
availableBalanceUsd: { increment: perPersonAmountUsd },
totalEarnedUsd: { increment: perPersonAmountUsd },
incomeLeadershipPoolUsd: { increment: perPersonAmountUsd },
},
});
});
}
return {
distributed: true,
poolAmountUsd,
participants: qualifiedPartners.length,
};
}10. Право на выплату
Критерии права:
| Критерий | Условие | Код ошибки |
|---|---|---|
| Статус KYC | = 'APPROVED' | KYC_REQUIRED |
| Доступный баланс | >= запрашиваемая сумма | INSUFFICIENT_BALANCE |
| Минимальная выплата | сумма >= $100 USD | BELOW_MINIMUM |
| Ожидающие выплаты | Нет ожидающих/обрабатываемых выплат | PAYOUT_PENDING |
| Статус партнёра | = 'ACTIVE' | PARTNER_INACTIVE |
| Способ выплаты | Как минимум один настроен | NO_PAYOUT_METHOD |
Создание выплаты:
SELECT * FROM mlm.create_payout_request(
p_partner_id := $1,
p_amount_usd := $2,
p_method_type := $3,
p_payout_details := $4
);Поток статусов выплаты:
PENDING -> APPROVED -> PROCESSING -> COMPLETED
| | |
v v v
CANCELLED REJECTED FAILED
|
v
Баланс восстановленСостояния транзакций комиссий
| Состояние | Описание | Расположение баланса |
|---|---|---|
| PENDING | Рассчитано, ожидает периода удержания | pending_balance_usd |
| APPROVED | Период удержания завершён | available_balance_usd |
| PAID | Включено в завершённую выплату | total_withdrawn_usd |
| HELD | На удержании для проверки | pending_balance_usd |
| REVERSED | Отменено из-за возврата | Списано |
| CLAWBACK | Отрицательная транзакция | Списано из available |
| CANCELLED | Отменено до подтверждения | Н/Д |
Сценарии ошибок
Ошибки обработки заданий
| Ошибка | Обработка | Повтор |
|---|---|---|
| Источник не найден | Лог и окончательная неудача | Нет |
| Партнёр не найден | Лог и окончательная неудача | Нет |
| Потеряно соединение с БД | Повтор с задержкой | Да |
| Таймаут блокировки | Немедленный повтор | Да |
| Ставка для ранга не найдена | Лог и окончательная неудача | Нет |
Ошибки операций с балансом
| Ошибка | Обработка |
|---|---|
| Попытка отрицательного баланса | Откат транзакции, оповещение |
| Несовпадение версии | Повтор со свежими данными |
| Обнаружена взаимоблокировка | Повтор (упорядоченная блокировка предотвращает) |
Расписание пакетной обработки
| Задание | Частота | Время | Описание |
|---|---|---|---|
| Одобрение комиссий | Ежедневно | 02:00 UTC | Перевод pending в approved |
| Расчёт баланса | Ежедневно | 03:00 UTC | Перевод approved в available |
| Еженедельное распределение пула | Воскресенье | 23:00 UTC | Распределение пулов 5-8 |
| Ежемесячное распределение пула | 1-е число | 01:00 UTC | Распределение пулов 9-11 |
| Пересчёт рангов | Ежечасно | :00 | Проверка повышений рангов |
Мониторинг и оповещения
Ключевые метрики:
| Метрика | Порог оповещения |
|---|---|
| Глубина очереди заданий | > 1000 заданий |
| Неудачные задания (24ч) | > 10 |
| Dead letter очередь | > 0 |
| Время обработки | > 30 секунд на задание |
| Процент отмен | > 5% комиссий |
Журнал аудита:
CREATE TABLE mlm.financial_audit_log (
id UUID PRIMARY KEY,
event_type VARCHAR(50),
partner_id UUID,
amount_usd DECIMAL(20,2),
balance_before_usd DECIMAL(20,2),
balance_after_usd DECIMAL(20,2),
income_type VARCHAR(50),
source_type VARCHAR(50),
source_id UUID,
checksum VARCHAR(64),
previous_checksum VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW()
);Ключевые отличия от старой системы
| Аспект | Старая система | Новая система |
|---|---|---|
| Модель комиссий | Unilevel (% по уровню) | Дифференциальная (по рангу) |
| Глубина | Макс 10 уровней | Неограниченная |
| Виды дохода | 3 | 7 |
| Валюта | RUB | USD |
| Минимальная выплата | 1,000 RUB | $100 USD |
| Лидерские пулы | Нет | 7 пулов (Ранг 5+) |
| Расчёт ставки | Фиксированный % по уровню | Дифференциал между рангами |