Skip to content

Расчёт комиссий (Unilevel)

Схема комиссионного плана

sql
CREATE TABLE mlm.commission_plans (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name VARCHAR(100) NOT NULL,
    code VARCHAR(50) NOT NULL UNIQUE,
    source_type VARCHAR(20) NOT NULL,  -- 'INVESTMENT', 'PRODUCT', 'ALL'
    max_levels INT NOT NULL DEFAULT 10,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE mlm.commission_tiers (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    plan_id UUID NOT NULL REFERENCES mlm.commission_plans(id),
    level_depth INT NOT NULL,           -- 1 = прямой, 2 = второй уровень и т.д.
    percentage DECIMAL(5,2) NOT NULL,   -- Процент комиссии
    min_rank_id UUID REFERENCES mlm.ranks(id),  -- Опциональное требование ранга

    UNIQUE(plan_id, level_depth)
);

Поток расчёта

                    Заказ/Инвестиция завершена


                    ┌─────────────────────┐
                    │ Получить партнёра   │
                    │ из атрибуции        │
                    └──────────┬──────────┘


                    ┌─────────────────────┐
                    │ Получить комиссион- │
                    │ ный план по типу    │
                    └──────────┬──────────┘


                    ┌─────────────────────┐
                    │ Получить верхнюю    │
                    │ линию (Closure Table)│
                    └──────────┬──────────┘


              ┌────────────────────────────────┐
              │ Для каждого уровня (1 до max)  │
              │  1. Получить конфигурацию      │
              │  2. Проверить квалификацию     │
              │  3. Рассчитать комиссию        │
              │  4. Создать запись транзакции  │
              │  5. Обновить баланс            │
              └────────────────────────────────┘


                    ┌─────────────────────┐
                    │ Отправить события   │
                    │ расчёта комиссий    │
                    └─────────────────────┘

Задание комиссий с pg-boss

typescript
// Расчёт комиссий как задание pg-boss со встроенными повторами
interface CommissionJobPayload {
  idempotencyKey: string;  // Предотвращение повторной обработки
  sourceType: 'ORDER' | 'INVESTMENT';
  sourceId: string;
  amount: number;
  currency: string;
  referringPartnerId: string;
}

// Обработчик задания с безопасной для конкурентности реализацией
async function processCommission(job: CommissionJobPayload) {
  const { idempotencyKey, sourceType, sourceId, referringPartnerId } = job;

  // 1. Быстрый путь: Проверка идемпотентности вне транзакции
  const existing = await db.idempotencyKey.findUnique({
    where: { key: idempotencyKey }
  });
  if (existing) return existing.response;

  // 2. Обработка с правильной блокировкой и повторами
  const result = await withRetry(
    () => processCommissionTransaction(job),
    RETRY_CONFIGS.commission,
    `commission:${sourceId}`
  );

  return result;
}

// Реализация транзакции, безопасная для конкурентности
async function processCommissionTransaction(job: CommissionJobPayload) {
  const { idempotencyKey, sourceType, sourceId, referringPartnerId } = job;

  return await db.$transaction(async (tx) => {
    // 2a. Блокировка исходной записи для предотвращения двойной обработки
    const sourceTable = sourceType === 'ORDER'
      ? Prisma.sql`product.orders`
      : Prisma.sql`investment.participations`;

    await tx.$queryRaw`
      SELECT id FROM ${sourceTable}
      WHERE id = ${sourceId}::uuid
      FOR UPDATE NOWAIT
    `;

    // 2b. Повторная проверка идемпотентности внутри транзакции (защита от гонки)
    const existingInTx = await tx.idempotencyKey.findUnique({
      where: { key: idempotencyKey }
    });
    if (existingInTx) return existingInTx.response;

    // 2c. Получение верхней линии (только чтение, блокировка не нужна)
    const upline = await tx.partnerTreePath.findMany({
      where: {
        descendantId: referringPartnerId,
        depth: { gte: 1, lte: 10 }
      },
      orderBy: { depth: 'asc' }
    });

    if (upline.length === 0) {
      // Нет верхней линии для выплаты - всё равно сохраняем ключ идемпотентности
      await tx.idempotencyKey.create({
        data: {
          key: idempotencyKey,
          response: { commissions: [] },
          requestHash: createHash('sha256').update(idempotencyKey).digest('hex'),
          expiresAt: addDays(new Date(), 30)
        }
      });
      return { commissions: [] };
    }

    // 2d. Блокировка ВСЕХ затронутых балансов партнёров в согласованном порядке (предотвращение deadlock)
    const partnerIds = upline.map(u => u.ancestorId).sort();

    await tx.$queryRaw`
      SELECT id FROM mlm.partner_balances
      WHERE partner_id = ANY(${partnerIds}::uuid[])
      ORDER BY partner_id
      FOR UPDATE
    `;

    // 2e. Получение активного комиссионного плана
    const activePlan = await tx.commissionPlan.findFirst({
      where: {
        sourceType: { in: [job.sourceType, 'ALL'] },
        isActive: true,
        validFrom: { lte: new Date() },
        OR: [
          { validTo: null },
          { validTo: { gte: new Date() } }
        ]
      },
      include: { tiers: true }
    });

    if (!activePlan) {
      throw new Error(`Активный комиссионный план для ${job.sourceType} не найден`);
    }

    // 2f. Расчёт и создание записей комиссий
    const commissions = [];

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

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

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

      // Проверка требования ранга, если указано в уровне
      if (tier.minRankId && partner.currentRankId !== tier.minRankId) {
        // Здесь можно добавить сравнение уровней рангов, если у рангов есть уровни
        continue;
      }

      const grossAmount = job.amount * (Number(tier.percentage) / 100);
      const netAmount = grossAmount; // Здесь можно применить комиссии при необходимости
      const careerPoints = job.amount * (Number(tier.careerPointsPercentage || 0) / 100);

      // Создание транзакции комиссии с идемпотентностью на уровне партнёра
      const commissionIdempotencyKey = `${idempotencyKey}:${ancestor.ancestorId}:${ancestor.depth}`;

      const commission = await tx.commissionTransaction.create({
        data: {
          partnerId: ancestor.ancestorId,
          sourceType: job.sourceType,
          sourceId,
          sourcePartnerId: referringPartnerId,
          levelDepth: ancestor.depth,
          planId: activePlan.id,
          grossAmount,
          netAmount,
          careerPoints,
          currency: job.currency,
          status: 'PENDING',
          idempotencyKey: commissionIdempotencyKey
        }
      });

      // Атомарное обновление баланса партнёра
      await tx.partnerBalance.update({
        where: { partnerId: ancestor.ancestorId },
        data: {
          pendingBalance: { increment: netAmount },
          careerPointsPeriod: { increment: careerPoints },
          careerPointsTotal: { increment: careerPoints },
          version: { increment: 1 },
          lastCalculatedAt: new Date(),
          updatedAt: new Date()
        }
      });

      commissions.push(commission);
    }

    // 2g. Сохранение ключа идемпотентности
    await tx.idempotencyKey.create({
      data: {
        key: idempotencyKey,
        response: { commissionIds: commissions.map(c => c.id) },
        requestHash: createHash('sha256').update(idempotencyKey).digest('hex'),
        expiresAt: addDays(new Date(), 30)
      }
    });

    return { commissions };
  }, {
    isolationLevel: 'Serializable',
    timeout: 30000  // Таймаут 30 секунд
  });
}

// Регистрация задания pg-boss с контролем конкурентности
boss.work('commission-calculation', {
  teamSize: 5,        // Количество параллельных воркеров
  teamConcurrency: 2  // Заданий на воркер
}, async (job) => {
  try {
    return await processCommission(job.data);
  } catch (error) {
    // Логирование для мониторинга
    logger.error('Ошибка расчёта комиссии', {
      jobId: job.id,
      sourceId: job.data.sourceId,
      error: error.message
    });
    throw error;  // pg-boss обработает повтор согласно конфигурации задания
  }
});

См. также: Паттерны конкурентности для деталей конфигурации повторов и обработки ошибок.


Стратегия повторов

ПопыткаЗадержкаДействие
1НемедленноПервая попытка
230 секундАвтоповтор
32 минутыАвтоповтор
410 минутАвтоповтор
5ПровалПеремещение в dead letter, оповещение администратора

Система двойного вознаграждения

Партнёры могут настраивать, как вознаграждения распределяются между ними и новыми рефералами.

Схема конфигурации

sql
CREATE TABLE mlm.reward_distribution_configs (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    partner_id UUID NOT NULL REFERENCES mlm.partners(id),
    reward_type VARCHAR(20) NOT NULL,  -- 'MONETARY', 'CAREER_POINTS'

    -- Куда направляются вознаграждения, когда этот партнёр приводит реферала
    to_self_percentage DECIMAL(5,2) NOT NULL DEFAULT 100,
    to_referral_percentage DECIMAL(5,2) NOT NULL DEFAULT 0,

    is_default BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),

    CONSTRAINT valid_percentages CHECK (to_self_percentage + to_referral_percentage = 100)
);

Сценарии распределения

СценарийДеньги (Себе/Рефералу)Баллы (Себе/Рефералу)
Всё себе100% / 0%100% / 0%
Делиться деньгами70% / 30%100% / 0%
Делиться баллами100% / 0%50% / 50%
Полное распределение50% / 50%50% / 50%

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