Расчёт комиссий (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 | Немедленно | Первая попытка |
| 2 | 30 секунд | Автоповтор |
| 3 | 2 минуты | Автоповтор |
| 4 | 10 минут | Автоповтор |
| 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% |