Skip to content

Жизненный цикл комиссии

Полный жизненный цикл расчёта комиссий с использованием Дифференциальной модели, от триггерного события до выплаты.

Обзор

Жизненный цикл комиссии включает:

  1. Триггерные события (7 видов дохода)
  2. Создание задания и постановка в очередь
  3. Идемпотентность и предотвращение дубликатов
  4. Получение восходящей линии (неограниченная глубина)
  5. Расчёт дифференциальной комиссии
  6. Обновление баланса и запись транзакций
  7. Переход из ожидающего в доступный статус
  8. Обработка отмены комиссий
  9. Распределение лидерских пулов
  10. Проверка права на выплату и обработка

Обзор модели комиссий

СвойствоЗначение
Тип моделиДифференциальная (не Unilevel)
ГлубинаНеограниченная
Виды дохода7 (3 активных + 3 пассивных + 1 пул)
Основная валютаUSD
Диапазон ставок3% - 20% в зависимости от ранга

Основная формула расчёта:

IF ранг_консультанта > ранг_партнёра_источника:
    комиссия = (ставка_консультанта - ставка_источника) × сумма
ELSE:
    комиссия = 0  // Нет комиссии при равном или более низком ранге

Основная диаграмма процесса


Детали этапов

1. Триггерные события (7 видов дохода)

События, запускающие расчёт комиссии:

Вид доходаТриггерное событиеТип источникаРасчёт
1. Личные продажиПрямая продажа своему клиентуORDERpersonal_rate × сумма
2. Командные продажиПартнёр даунлайна совершает продажуORDERДифференциал от суммы продажи
3. Повторные продажиСуществующий клиент совершает повторную покупкуORDERКак личные продажи
4. Доход от портфеляПрибыль от собственной инвестицииINVESTMENT_PROFITДоходность стратегии
5. Прибыль клиентовЛичный клиент получает прибыльINVESTMENT_PROFITpassive_rate × прибыль_клиента
6. Сетевая прибыльКлиент даунлайна получает прибыльINVESTMENT_PROFITДифференциал от прибыли
7. Лидерский пулЕженедельное/ежемесячное распределениеPOOL_DISTRIBUTIONРавная доля

Структура payload задания:

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;
}

Формат ключа идемпотентности:

commission:{incomeType}:{sourceId}:{timestamp}

Пример: commission:TEAM_SALES:550e8400-e29b-41d4-a716-446655440000:1705315200


2. Создание задания и постановка в очередь

Конфигурация задания pg-boss:

typescript
await boss.send("differential-commission", payload, {
  retryLimit: 5,
  retryDelay: 30000,
  retryBackoff: true,
  expireInMinutes: 60,
  singletonKey: payload.idempotencyKey,
});

Расписание повторов:

ПопыткаЗадержкаОбщее время
1Немедленно0
230 секунд30с
32 минуты2.5м
410 минут12.5м
530 минут42.5м
НеудачаН/ДПеремещение в dead-letter очередь

3. Проверка идемпотентности

Паттерн двойной проверки:

typescript
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. Получение восходящей линии (неограниченная глубина)

Запрос:

sql
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_idrank_codepersonal_sales_ratedepth
partner-A5141
partner-B3102
partner-C6163

Ключевое отличие от Unilevel:

  • Нет лимита уровней — извлекается ВСЯ восходящая линия
  • Обработка останавливается при достижении максимальной ставки (20%)
  • Комиссия основана на дифференциале ставок, а не на проценте уровня

5. Расчёт дифференциальной комиссии

Основной алгоритм:

typescript
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Алиса514%14% - 8% = 6%$600
2Борис310%0% (10% < 14%)$0
3Карина717%17% - 14% = 3%$300
4Дмитрий717%0% (та же ставка)$0
5Елена1019.5%19.5% - 17% = 2.5%$250

Итого распределено: $1,150 (11.5%)

Ключевые моменты:

  • Борис и Дмитрий получают $0, потому что их ставки не выше предыдущего заработавшего партнёра
  • Комиссия «перескакивает» к партнёрам с более высоким рангом в восходящей линии
  • Обработка продолжается до достижения максимальной ставки 20% или конца восходящей линии

6. Обновление балансов

Структура записи баланса (USD):

json
{
  "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
}

Паттерн блокировки:

sql
-- Заблокировать ВСЕ затронутые балансы в последовательном порядке
SELECT id FROM mlm.partner_balances
WHERE partner_id = ANY($1::uuid[])
ORDER BY partner_id
FOR UPDATE;

7. Переход из ожидающего в доступный

Периоды удержания по типу источника:

Тип источникаПериод удержанияПричина
Заказ продукта14 днейОкно возврата/обмена
Инвестиция7 днейВерификация средств
Пассивный доход7 днейПодтверждение прибыли
Лидерский пул0 днейНемедленное распределение

Задание подтверждения (ежедневное):

typescript
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. Отмена комиссии

Триггеры отмены:

ТриггерИсточникДействие
Возврат заказаЗапрос клиентаОтменить все связанные комиссии
ЧарджбэкПлатёжный провайдерОтменить + пометить партнёра
Обнаружение мошенничестваАдмин/СистемаОтменить и пометить
Отмена инвестицииКлиент/АдминОтменить все связанные

Процесс отмены:

typescript
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_55, 5_PRO1%Еженедельно$5,000 / $10,000 недельный объём
POOL_66, 6_PRO0.5%Еженедельно$20,000 / $30,000 недельный объём
POOL_77, 7_PRO0.5%Еженедельно$45,000 / $60,000 недельный объём
POOL_88, 8_PRO0.5%Еженедельно$90,000 / $120,000 недельный объём
POOL_99, 9_PRO1%ЕжемесячноТолько достижение ранга
POOL_1010, 10_PRO1%ЕжемесячноТолько достижение ранга
POOL_1111, 11_PRO1%ЕжемесячноТолько достижение ранга

Примечание: Пулы 9-11 не требуют отдельного квалификационного объёма. Партнёры, достигшие этих рангов, автоматически участвуют в ежемесячном распределении.

Правило 50% на ветку (только для пулов 5-8):

typescript
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;
}

Процесс распределения:

typescript
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 USDBELOW_MINIMUM
Ожидающие выплатыНет ожидающих/обрабатываемых выплатPAYOUT_PENDING
Статус партнёра= 'ACTIVE'PARTNER_INACTIVE
Способ выплатыКак минимум один настроенNO_PAYOUT_METHOD

Создание выплаты:

sql
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% комиссий

Журнал аудита:

sql
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 уровнейНеограниченная
Виды дохода37
ВалютаRUBUSD
Минимальная выплата1,000 RUB$100 USD
Лидерские пулыНет7 пулов (Ранг 5+)
Расчёт ставкиФиксированный % по уровнюДифференциал между рангами

Связанные документы