Жизненный цикл комиссии
Полный жизненный цикл комиссии от триггерного события до выплаты.
Обзор
Жизненный цикл комиссии включает:
- Триггерные события (завершение заказа/инвестиции)
- Создание задания и постановка в очередь
- Идемпотентность и предотвращение дубликатов
- Получение вышестоящей линии и расчет
- Обновление баланса и запись транзакций
- Переход из ожидающего в доступный статус
- Обработка отмены комиссий
- Проверка права на выплату и обработка
Основная диаграмма процесса
Детали этапов
1. Триггерные события
События, запускающие расчет комиссии:
| Событие | Тип источника | ID источника | База суммы |
|---|---|---|---|
| Заказ подтвержден | ORDER | order.id | order.total |
| Инвестиция активирована | INVESTMENT | participation.id | participation.amount |
| Ранговый бонус | RANK_BONUS | partner.id | Фиксированная сумма бонуса |
Структура payload задания:
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:
await boss.send('commission-calculation', payload, {
retryLimit: 5,
retryDelay: 30000, // 30 секунд начальная задержка
retryBackoff: true, // Экспоненциальная задержка
expireInMinutes: 60, // Задание истекает через 1 час
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 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. Валидация источника
Блокировка записи источника:
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. Получение вышестоящей линии (таблица замыкания)
Запрос:
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_id | depth |
|---|---|
| partner-A | 1 |
| partner-B | 2 |
| partner-C | 3 |
Интерпретация глубины:
- Глубина 1: Прямой спонсор реферального партнера
- Глубина 2: Спонсор спонсора
- Глубина N: N уровней вверх по дереву
6. Расчет комиссии
Поиск плана комиссий:
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;Расчет по каждому уровню:
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
| Уровень | Процент | Партнер | Комиссия |
|---|---|---|---|
| 1 | 10% | Alice | 1,000 RUB |
| 2 | 5% | Bob | 500 RUB |
| 3 | 3% | Carol | 300 RUB |
| 4 | 2% | Dave | 200 RUB |
| 5 | 1% | Eve | 100 RUB |
Итого распределено: 2,100 RUB (21%)
7. Обновление балансов
Структура записи баланса:
{
"partnerId": "uuid",
"availableBalance": 45000.00,
"pendingBalance": 15000.00,
"totalEarned": 120000.00,
"totalWithdrawn": 60000.00,
"careerPointsTotal": 25000.00,
"careerPointsPeriod": 5000.00,
"currency": "RUB",
"version": 42
}Паттерн блокировки:
-- Заблокировать ВСЕ затронутые балансы в последовательном порядке
SELECT id FROM mlm.partner_balances
WHERE partner_id = ANY($1::uuid[])
ORDER BY partner_id -- Последовательный порядок предотвращает взаимоблокировки
FOR UPDATE;8. Переход из ожидающего в доступный
Критерии перехода:
| Критерий | Условие |
|---|---|
| Статус комиссии | = 'PENDING' |
| Возраст | > 14 дней (настраивается) |
| Источник не возвращен | Нет возврата/чарджбэка по источнику |
| Нет флагов мошенничества | Партнер не отмечен |
Задание подтверждения (запланировано ежедневно):
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. Отмена комиссии
Триггеры отмены:
| Триггер | Источник | Действие |
|---|---|---|
| Возврат заказа | Запрос клиента | Отменить все связанные комиссии |
| Чарджбэк | Платежный провайдер | Отменить все связанные комиссии |
| Обнаружение мошенничества | Админ/Система | Отменить и пометить |
| Отмена инвестиции | Клиент/Админ | Отменить все связанные комиссии |
Процесс отмены:
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 RUB | BELOW_MINIMUM |
| Ожидающие выплаты | Нет ожидающих/обрабатываемых выплат | PAYOUT_PENDING |
| Статус партнера | = 'ACTIVE' | PARTNER_INACTIVE |
| Способ выплаты | Как минимум один настроен | NO_PAYOUT_METHOD |
Создание выплаты:
-- Атомарное списание баланса и создание выплаты
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% комиссий |
Журнал аудита:
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()
);