Движок комиссий (Дифференциальная модель)
Платформа IWM использует Дифференциальную комиссионную модель с 7 типами дохода. Партнёры получают комиссии на основе разницы между своей ставкой ранга и ставкой ранга нижестоящего партнёра, с неограниченной глубиной.
Обзор комиссионной модели
| Свойство | Значение |
|---|---|
| Тип модели | Дифференциальная (не Unilevel) |
| Глубина | Неограниченная |
| Типы дохода | 7 (3 Активных + 3 Пассивных + 1 Пул) |
| Основная валюта | USD |
| Диапазон ставок | 3% - 20% в зависимости от ранга |
Основная формула расчёта
ЕСЛИ ранг_консультанта > ранг_партнёра_источника:
комиссия = (ставка_консультанта - ставка_источника) × сумма
ИНАЧЕ:
комиссия = 0 // Нет комиссии, если ранг равен или ниже7 типов дохода
Активный доход (Типы 1-3)
| Тип | Название | Триггер | Расчёт |
|---|---|---|---|
| 1 | Личные продажи | Прямая продажа своему клиенту | личная_ставка × сумма |
| 2 | Командные продажи | Нижестоящий партнёр совершает продажу | Дифференциал от суммы продажи |
| 3 | Повторные продажи | Существующий клиент делает повторную покупку | Аналогично личным продажам |
Пассивный доход (Типы 4-6)
| Тип | Название | Триггер | Расчёт |
|---|---|---|---|
| 4 | Доход портфеля | Собственная инвестиция приносит прибыль | Доходность по стратегии |
| 5 | Прибыль клиентов | Личный клиент получает прибыль | пассивная_ставка × прибыль_клиента |
| 6 | Прибыль сети | Клиент нижестоящего получает прибыль | Дифференциал от суммы прибыли |
Лидерский доход (Тип 7)
| Тип | Название | Триггер | Расчёт |
|---|---|---|---|
| 7 | Лидерский пул | Еженедельное/ежемесячное распределение | Равная доля среди квалифицированных |
Поток расчёта
Транзакция завершена
│
▼
┌─────────────────────┐
│ Определить тип │
│ дохода (1-7) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Получить реферальную│
│ атрибуцию │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Получить верхнюю │
│ линию (без лимита) │
└──────────┬──────────┘
│
▼
┌────────────────────────────────┐
│ Для каждого партнёра в линии: │
│ 1. Получить ранг и ставку │
│ 2. Сравнить со ставкой источн.│
│ 3. Рассчитать дифференциал │
│ 4. Создать запись транзакции │
│ 5. Обновить ожидающий баланс │
└────────────────────────────────┘
│
▼
┌─────────────────────┐
│ Отправить события │
│ расчёта комиссий │
└─────────────────────┘Процессор дифференциальных комиссий
typescript
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;
}
async function processDifferentialCommission(
job: DifferentialCommissionPayload,
) {
const {
idempotencyKey,
sourceId,
sourcePartnerId,
sourcePartnerRate,
amountUsd,
} = job;
// 1. Проверка идемпотентности
const existing = await db.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existing) return existing.response;
// 2. Обработка с правильной блокировкой
return await db.$transaction(
async (tx) => {
// 2a. Получить ВСЮ верхнюю линию (неограниченная глубина)
const upline = await tx.$queryRaw`
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 = ${sourcePartnerId}::uuid
AND ptp.depth >= 1
ORDER BY ptp.depth ASC
`;
if (upline.length === 0) {
await saveIdempotencyKey(tx, idempotencyKey, { commissions: [] });
return { commissions: [] };
}
// 2b. Заблокировать все балансы партнёров в согласованном порядке
const partnerIds = upline.map((u) => u.partner_id).sort();
await tx.$queryRaw`
SELECT id FROM mlm.partner_balances
WHERE partner_id = ANY(${partnerIds}::uuid[])
ORDER BY partner_id
FOR UPDATE
`;
// 2c. Рассчитать дифференциальные комиссии
const commissions = [];
let currentSourceRate = 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 = amountUsd * (differentialRate / 100);
const netAmountUsd = grossAmountUsd;
// Создать запись комиссии
const commission = await tx.commissionTransaction.create({
data: {
partnerId: ancestor.partner_id,
incomeType: job.incomeType,
sourceType: job.sourceType,
sourceId,
sourcePartnerId,
ownRate: ancestorRate,
sourceRate: currentSourceRate,
differentialRate,
grossAmountUsd,
netAmountUsd,
currency: "USD",
status: "PENDING",
idempotencyKey: `${idempotencyKey}:${ancestor.partner_id}`,
},
});
// Обновить ожидающий баланс
await tx.partnerBalance.update({
where: { partnerId: ancestor.partner_id },
data: {
pendingBalanceUsd: { increment: netAmountUsd },
version: { increment: 1 },
lastCalculatedAt: new Date(),
updatedAt: new Date(),
},
});
commissions.push(commission);
// Обновить текущую ставку источника для следующей итерации
currentSourceRate = ancestorRate;
}
// Остановиться, если достигли максимальной ставки (20%)
if (currentSourceRate >= 20) break;
}
await saveIdempotencyKey(tx, idempotencyKey, {
commissionIds: commissions.map((c) => c.id),
});
return { commissions };
},
{
isolationLevel: "Serializable",
timeout: 30000,
},
);
}
function getApplicableRate(incomeType: string, partner: any): 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;
}
}Расчёт пассивного дохода
Тип 5: Прибыль клиентов
Когда прямой клиент партнёра получает прибыль от инвестиции:
typescript
async function processClientProfitCommission(
clientId: string,
profitAmountUsd: number,
investmentId: string,
) {
// Получить партнёра, который привёл этого клиента
const attribution = await db.referralAttribution.findUnique({
where: { userId: clientId },
});
if (!attribution) return;
const partner = await db.partner.findUnique({
where: { id: attribution.partnerId },
include: { rank: true },
});
if (!partner || partner.status !== "ACTIVE") return;
// Рассчитать пассивный доход
const passiveRate = partner.rank.passiveIncomeRate;
const commissionUsd = profitAmountUsd * (passiveRate / 100);
// Создать транзакцию комиссии
await createCommission({
partnerId: partner.id,
incomeType: "CLIENT_PROFITS",
sourceType: "INVESTMENT_PROFIT",
sourceId: investmentId,
grossAmountUsd: commissionUsd,
netAmountUsd: commissionUsd,
});
// Запустить расчёт сетевой прибыли для верхней линии
await triggerNetworkProfitCalculation(partner.id, commissionUsd, passiveRate);
}Тип 6: Прибыль сети (Дифференциал от пассивного дохода)
typescript
async function triggerNetworkProfitCalculation(
sourcePartnerId: string,
clientProfitAmount: number,
sourceRate: number,
) {
// Поставить задание в очередь для дифференциального расчёта сетевой прибыли
await boss.send("network-profit-calculation", {
idempotencyKey: `network-profit:${sourcePartnerId}:${Date.now()}`,
incomeType: "NETWORK_PROFITS",
sourceType: "INVESTMENT_PROFIT",
sourcePartnerId,
amountUsd: clientProfitAmount,
sourcePartnerRate: sourceRate,
});
}Распределение лидерских пулов
Конфигурация пулов
| Пул | Ранги | % от оборота | Частота | Квалификация |
|---|---|---|---|---|
| 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 не требуют отдельного квалификационного объёма. Партнёры, достигшие и поддерживающие эти ранги, автоматически участвуют в ежемесячном распределении. Требование ранга по обороту ($10M-$800M) служит квалификационным порогом.
Процесс распределения
typescript
async function distributeLeadershipPool(
poolCode: string,
periodStart: Date,
periodEnd: Date,
) {
const pool = await db.leadershipPool.findUnique({
where: { poolCode },
});
// 1. Рассчитать общий оборот компании за период
const totalTurnover = await calculatePeriodTurnover(periodStart, periodEnd);
const poolAmount = totalTurnover * pool.percentageOfTurnover;
// 2. Найти квалифицированных участников
const qualifiedPartners = await findQualifiedPartners(
pool,
periodStart,
periodEnd,
);
if (qualifiedPartners.length === 0) {
return { distributed: false, reason: "Нет квалифицированных участников" };
}
// 3. Рассчитать долю на человека
const perPersonAmount = poolAmount / qualifiedPartners.length;
// 4. Создать запись о распределении
const distribution = await db.poolDistribution.create({
data: {
poolId: pool.id,
periodStart,
periodEnd,
totalTurnoverUsd: totalTurnover,
poolAmountUsd: poolAmount,
qualifiedParticipants: qualifiedPartners.length,
perPersonAmountUsd: perPersonAmount,
status: "PROCESSING",
},
});
// 5. Распределить каждому квалифицированному партнёру
for (const partner of qualifiedPartners) {
await db.$transaction(async (tx) => {
// Создать запись участия
await tx.poolParticipation.create({
data: {
distributionId: distribution.id,
partnerId: partner.id,
qualified: true,
qualificationVolumeUsd: partner.qualificationVolume,
amountReceivedUsd: perPersonAmount,
branchVolumes: partner.branchVolumes,
},
});
// Создать транзакцию комиссии
await tx.commissionTransaction.create({
data: {
partnerId: partner.id,
incomeType: "LEADERSHIP_POOL",
sourceType: "POOL_DISTRIBUTION",
sourceId: distribution.id,
grossAmountUsd: perPersonAmount,
netAmountUsd: perPersonAmount,
currency: "USD",
status: "APPROVED", // Распределения пулов мгновенны
},
});
// Добавить напрямую в доступный баланс (без периода ожидания для пулов)
await tx.partnerBalance.update({
where: { partnerId: partner.id },
data: {
availableBalanceUsd: { increment: perPersonAmount },
totalEarnedUsd: { increment: perPersonAmount },
incomeLeadershipPoolUsd: { increment: perPersonAmount },
version: { increment: 1 },
updatedAt: new Date(),
},
});
});
}
// 6. Отметить распределение как завершённое
await db.poolDistribution.update({
where: { id: distribution.id },
data: {
status: "DISTRIBUTED",
distributedAt: new Date(),
},
});
return {
distributed: true,
poolAmount,
participants: qualifiedPartners.length,
perPerson: perPersonAmount,
};
}Проверка правила 50% веток
typescript
async function findQualifiedPartners(
pool: LeadershipPool,
periodStart: Date,
periodEnd: Date,
) {
const eligibleRanks = pool.eligibleRanks;
const qualificationVolumes = pool.qualificationVolumes as Record<
string,
number
>;
// Получить всех партнёров с подходящими рангами
const candidates = await db.partner.findMany({
where: {
status: "ACTIVE",
rank: { code: { in: eligibleRanks } },
},
include: { rank: true },
});
const qualifiedPartners = [];
for (const partner of candidates) {
const requiredVolume = 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 calculateBranchVolumes(
partnerId: string,
periodStart: Date,
periodEnd: Date,
): Promise<Record<string, number>> {
// Получить прямых рефералов (корни веток)
const directReferrals = await db.partner.findMany({
where: { sponsorId: partnerId },
});
const branchVolumes: Record<string, number> = {};
for (const branch of directReferrals) {
// Получить всех потомков этой ветки
const branchDescendants = await db.partnerTreePath.findMany({
where: { ancestorId: branch.id },
});
const descendantIds = branchDescendants.map((d) => d.descendantId);
descendantIds.push(branch.id);
// Суммировать оборот по этой ветке
const turnover = await db.commissionTransaction.aggregate({
where: {
sourcePartnerId: { in: descendantIds },
createdAt: { gte: periodStart, lte: periodEnd },
incomeType: { in: ["PERSONAL_SALES", "REPEAT_SALES"] },
},
_sum: { grossAmountUsd: true },
});
branchVolumes[branch.id] = turnover._sum.grossAmountUsd || 0;
}
return branchVolumes;
}Расписание пакетной обработки
| Задание | Частота | Время | Описание |
|---|---|---|---|
| Подтверждение комиссий | Ежедневно | 02:00 UTC | Перевод из ожидающих в подтверждённые |
| Расчёт баланса | Ежедневно | 03:00 UTC | Перевод из подтверждённых в доступные |
| Распределение недельных пулов | Воскресенье | 23:00 UTC | Распределение пулов 5-8 |
| Распределение месячных пулов | 1-е число месяца | 01:00 UTC | Распределение пулов 9-11 |
| Пересчёт рангов | Ежечасно | :00 | Проверка повышений рангов |
Поток статусов комиссий
PENDING → APPROVED → PAID
↓
REVERSED/CLAWBACK (при возврате/чарджбэке)| Статус | Описание | Влияние на баланс |
|---|---|---|
| PENDING | Рассчитано, ожидает периода удержания | +ожидающий_баланс |
| APPROVED | Период удержания завершён | ожидающий → доступный |
| PAID | В доступном балансе партнёра | Уже в доступном |
| REVERSED | Заказ возвращён до выплаты | -ожидающий_баланс |
| CLAWBACK | Заказ возвращён после выплаты | -доступный_баланс |