Skip to content

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

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

Обзор

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

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

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


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

1. Триггерные события

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

СобытиеТип источникаID источникаБаза суммы
Заказ подтвержденORDERorder.idorder.total
Инвестиция активированаINVESTMENTparticipation.idparticipation.amount
Ранговый бонусRANK_BONUSpartner.idФиксированная сумма бонуса

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

typescript
interface CommissionJobPayload {
  idempotencyKey: string;    // Уникальный ключ для дедупликации
  sourceType: 'ORDER' | 'INVESTMENT' | 'RANK_BONUS';
  sourceId: string;          // UUID записи-источника
  amount: number;            // Базовая сумма комиссии
  currency: string;          // Код валюты
  referringPartnerId: string; // Партнер, сделавший реферал
}

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

commission:{sourceType}:{sourceId}:{timestamp}

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


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

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

typescript
await boss.send('commission-calculation', payload, {
  retryLimit: 5,
  retryDelay: 30000,        // 30 секунд начальная задержка
  retryBackoff: true,        // Экспоненциальная задержка
  expireInMinutes: 60,       // Задание истекает через 1 час
  singletonKey: payload.idempotencyKey  // Предотвращение дубликатов заданий
});

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

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

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

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

typescript
async function processCommission(job: CommissionJobPayload) {
  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;
  });
}

4. Валидация источника

Блокировка записи источника:

sql
SELECT id FROM product.orders
WHERE id = $1::uuid
FOR UPDATE NOWAIT;

Проверки валидации:

ПроверкаЗапросДействие при неудаче
Источник существуетSELECT 1 WHERE id = ?Неудача задания навсегда
Еще не обработанПроверка идемпотентностиВозврат существующего результата
Источник в валидном состоянииstatus = 'CONFIRMED'Неудача задания навсегда
Есть реферальный партнерreferring_partner_id IS NOT NULLЗавершение без комиссий

5. Получение вышестоящей линии (таблица замыкания)

Запрос:

sql
SELECT
    ancestor_id,
    depth
FROM mlm.partner_tree_paths
WHERE descendant_id = $1  -- реферальный партнер
  AND depth BETWEEN 1 AND 10  -- уровни 1-10
ORDER BY depth ASC;

Пример результата:

ancestor_iddepth
partner-A1
partner-B2
partner-C3

Интерпретация глубины:

  • Глубина 1: Прямой спонсор реферального партнера
  • Глубина 2: Спонсор спонсора
  • Глубина N: N уровней вверх по дереву

6. Расчет комиссии

Поиск плана комиссий:

sql
SELECT cp.*, ct.*
FROM mlm.commission_plans cp
JOIN mlm.commission_tiers ct ON ct.plan_id = cp.id
WHERE cp.source_type IN ($1, 'ALL')
  AND cp.is_active = true
  AND cp.valid_from <= NOW()
  AND (cp.valid_to IS NULL OR cp.valid_to >= NOW())
ORDER BY ct.level_depth;

Расчет по каждому уровню:

typescript
for (const ancestor of upline) {
  const tier = plan.tiers.find(t => t.levelDepth === ancestor.depth);
  if (!tier) continue;

  // Проверка статуса партнера
  const partner = await tx.partner.findUnique({
    where: { id: ancestor.ancestorId },
    select: { status: true, currentRankId: true }
  });

  if (partner?.status !== 'ACTIVE') continue;

  // Проверка квалификации по рангу
  if (tier.minRankId && !isRankSufficient(partner.currentRankId, tier.minRankId)) {
    continue;
  }

  // Расчет сумм
  const grossAmount = amount * (tier.percentage / 100);
  const careerPoints = amount * (tier.careerPointsPercentage / 100);

  // Создание транзакции комиссии
  await tx.commissionTransaction.create({
    data: {
      partnerId: ancestor.ancestorId,
      sourceType,
      sourceId,
      sourcePartnerId: referringPartnerId,
      levelDepth: ancestor.depth,
      planId: plan.id,
      grossAmount,
      netAmount: grossAmount,  // Без комиссий
      careerPoints,
      currency,
      status: 'PENDING',
      idempotencyKey: `${idempotencyKey}:${ancestor.ancestorId}:${ancestor.depth}`
    }
  });

  // Обновление баланса
  await tx.partnerBalance.update({
    where: { partnerId: ancestor.ancestorId },
    data: {
      pendingBalance: { increment: grossAmount },
      careerPointsPeriod: { increment: careerPoints },
      careerPointsTotal: { increment: careerPoints },
      version: { increment: 1 }
    }
  });
}

Пример расчета:

Сумма заказа: 10,000 RUB

УровеньПроцентПартнерКомиссия
110%Alice1,000 RUB
25%Bob500 RUB
33%Carol300 RUB
42%Dave200 RUB
51%Eve100 RUB

Итого распределено: 2,100 RUB (21%)


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

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

json
{
  "partnerId": "uuid",
  "availableBalance": 45000.00,
  "pendingBalance": 15000.00,
  "totalEarned": 120000.00,
  "totalWithdrawn": 60000.00,
  "careerPointsTotal": 25000.00,
  "careerPointsPeriod": 5000.00,
  "currency": "RUB",
  "version": 42
}

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

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

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

Критерии перехода:

КритерийУсловие
Статус комиссии= 'PENDING'
Возраст> 14 дней (настраивается)
Источник не возвращенНет возврата/чарджбэка по источнику
Нет флагов мошенничестваПартнер не отмечен

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

typescript
async function confirmPendingCommissions() {
  const eligibleCommissions = await db.commissionTransaction.findMany({
    where: {
      status: 'PENDING',
      createdAt: { lt: subDays(new Date(), 14) }
    }
  });

  // Группировка по партнеру для эффективной блокировки
  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 totalAmount = commissions.reduce((sum, c) => sum + c.netAmount, 0);

      // Обновить статусы комиссий
      await tx.commissionTransaction.updateMany({
        where: { id: { in: commissions.map(c => c.id) } },
        data: { status: 'APPROVED', processedAt: new Date() }
      });

      // Перевести pending -> available
      await tx.partnerBalance.update({
        where: { partnerId },
        data: {
          pendingBalance: { decrement: totalAmount },
          availableBalance: { increment: totalAmount },
          version: { increment: 1 }
        }
      });
    });
  }
}

9. Отмена комиссии

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

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

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

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: {
            pendingBalance: { decrement: commission.netAmount },
            careerPointsPeriod: { decrement: commission.careerPoints },
            careerPointsTotal: { decrement: commission.careerPoints }
          }
        });
      } else {
        // Создать транзакцию возврата
        await tx.commissionTransaction.create({
          data: {
            partnerId: commission.partnerId,
            sourceType: 'CLAWBACK',
            sourceId,
            grossAmount: -commission.grossAmount,
            netAmount: -commission.netAmount,
            careerPoints: -commission.careerPoints,
            status: 'CLAWBACK',
            reversedFromId: commission.id,
            reversalReason: reason,
            reversedBy: adminId
          }
        });

        // Вычесть из доступного
        await tx.partnerBalance.update({
          where: { partnerId: commission.partnerId },
          data: {
            availableBalance: { decrement: commission.netAmount },
            careerPointsTotal: { decrement: commission.careerPoints }
          }
        });
      }

      // Пометить оригинал как отмененный
      await tx.commissionTransaction.update({
        where: { id: commission.id },
        data: {
          status: 'REVERSED',
          reversalReason: reason,
          reversedAt: new Date(),
          reversedBy: adminId
        }
      });
    });
  }
}

10. Право на выплату

Критерии права:

КритерийУсловиеКод ошибки
Статус KYC= 'APPROVED'KYC_REQUIRED
Доступный баланс>= запрашиваемая суммаINSUFFICIENT_BALANCE
Минимальная выплатасумма >= 1,000 RUBBELOW_MINIMUM
Ожидающие выплатыНет ожидающих/обрабатываемых выплатPAYOUT_PENDING
Статус партнера= 'ACTIVE'PARTNER_INACTIVE
Способ выплатыКак минимум один настроенNO_PAYOUT_METHOD

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

sql
-- Атомарное списание баланса и создание выплаты
SELECT * FROM mlm.create_payout_request(
    p_partner_id := $1,
    p_amount := $2,
    p_currency := $3,
    p_method_type := $4,
    p_payout_details := $5
);

Поток статусов выплаты:

PENDING -> APPROVED -> PROCESSING -> COMPLETED
    |          |            |
    v          v            v
CANCELLED  REJECTED      FAILED
                           |
                           v
                    Balance restored

Состояния транзакций комиссий

СостояниеОписаниеРасположение баланса
PENDINGРассчитано, ожидает подтвержденияpending_balance
APPROVEDПодтверждено, готово к выплатеavailable_balance
PAIDВключено в завершенную выплатуwithdrawn (total_withdrawn)
HELDНа удержании для проверкиpending_balance
REVERSEDОтменено из-за возвратаСписано
CLAWBACKОтрицательная транзакцияСписано из available
CANCELLEDОтменено до подтвержденияН/Д

Сценарии ошибок

Ошибки обработки заданий

ОшибкаОбработкаПовтор
Источник не найденЛог и окончательная неудачаНет
Партнер не найденЛог и окончательная неудачаНет
Потеряно соединение с БДПовтор с задержкойДа
Таймаут блокировкиНемедленный повторДа
План комиссий не найденЛог и окончательная неудачаНет

Ошибки операций с балансом

ОшибкаОбработка
Попытка отрицательного балансаОткат транзакции, оповещение
Несовпадение версииПовтор со свежими данными
Обнаружена взаимоблокировкаПовтор (упорядоченная блокировка предотвращает)

Мониторинг и оповещения

Ключевые метрики:

МетрикаПорог оповещения
Глубина очереди заданий> 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 DECIMAL(20,2),
    balance_before DECIMAL(20,2),
    balance_after DECIMAL(20,2),
    source_type VARCHAR(50),
    source_id UUID,
    checksum VARCHAR(64),
    previous_checksum VARCHAR(64),
    created_at TIMESTAMP DEFAULT NOW()
);

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