Skip to content

Commission Lifecycle Flow

Complete lifecycle of commission calculation, from triggering event to payout.

Overview

The commission lifecycle includes:

  1. Triggering events (order/investment completion)
  2. Job creation and queuing
  3. Idempotency and duplicate prevention
  4. Upline retrieval and calculation
  5. Balance updates and transaction recording
  6. Pending to available transition
  7. Commission reversal handling
  8. Payout eligibility and processing

Main Flow Diagram


Step Details

1. Triggering Events

Events that trigger commission calculation:

EventSource TypeSource IDAmount Basis
Order confirmedORDERorder.idorder.total
Investment activatedINVESTMENTparticipation.idparticipation.amount
Rank bonusRANK_BONUSpartner.idFixed bonus amount

Job Payload Structure:

typescript
interface CommissionJobPayload {
  idempotencyKey: string;    // Unique key for deduplication
  sourceType: 'ORDER' | 'INVESTMENT' | 'RANK_BONUS';
  sourceId: string;          // UUID of source record
  amount: number;            // Commission basis amount
  currency: string;          // Currency code
  referringPartnerId: string; // Partner who made the referral
}

Idempotency Key Format:

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

Example: commission:ORDER:550e8400-e29b-41d4-a716-446655440000:1705315200


2. Job Creation and Queuing

pg-boss Job Configuration:

typescript
await boss.send('commission-calculation', payload, {
  retryLimit: 5,
  retryDelay: 30000,        // 30 seconds initial delay
  retryBackoff: true,        // Exponential backoff
  expireInMinutes: 60,       // Job expires after 1 hour
  singletonKey: payload.idempotencyKey  // Prevent duplicate jobs
});

Retry Schedule:

AttemptDelayTotal Time
1Immediate0
230 seconds30s
32 minutes2.5m
410 minutes12.5m
530 minutes42.5m
FailedN/AMove to dead-letter queue

3. Idempotency Check

Double-Check Pattern:

typescript
async function processCommission(job: CommissionJobPayload) {
  const { idempotencyKey } = job;

  // 1. Fast path: Check outside transaction
  const existing = await db.idempotencyKey.findUnique({
    where: { key: idempotencyKey }
  });
  if (existing) {
    return existing.response;  // Already processed
  }

  // 2. Process in transaction with re-check
  return await db.$transaction(async (tx) => {
    // Re-check inside transaction (race condition protection)
    const existingInTx = await tx.idempotencyKey.findUnique({
      where: { key: idempotencyKey }
    });
    if (existingInTx) {
      return existingInTx.response;
    }

    // ... process commission ...

    // Save idempotency key
    await tx.idempotencyKey.create({
      data: {
        key: idempotencyKey,
        response: result,
        expiresAt: addDays(new Date(), 30)
      }
    });

    return result;
  });
}

4. Source Validation

Lock Source Record:

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

Validation Checks:

CheckQueryAction on Fail
Source existsSELECT 1 WHERE id = ?Fail job permanently
Not already processedCheck idempotencyReturn existing result
Source is valid statestatus = 'CONFIRMED'Fail job permanently
Has referring partnerreferring_partner_id IS NOT NULLComplete with no commissions

5. Upline Retrieval (Closure Table)

Query:

sql
SELECT
    ancestor_id,
    depth
FROM mlm.partner_tree_paths
WHERE descendant_id = $1  -- referring partner
  AND depth BETWEEN 1 AND 10  -- levels 1-10
ORDER BY depth ASC;

Result Example:

ancestor_iddepth
partner-A1
partner-B2
partner-C3

Depth Interpretation:

  • Depth 1: Direct sponsor of referring partner
  • Depth 2: Sponsor's sponsor
  • Depth N: N levels up the tree

6. Commission Calculation

Commission Plan Lookup:

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;

Calculation Per Level:

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

  // Check partner status
  const partner = await tx.partner.findUnique({
    where: { id: ancestor.ancestorId },
    select: { status: true, currentRankId: true }
  });

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

  // Check rank qualification
  if (tier.minRankId && !isRankSufficient(partner.currentRankId, tier.minRankId)) {
    continue;
  }

  // Calculate amounts
  const grossAmount = amount * (tier.percentage / 100);
  const careerPoints = amount * (tier.careerPointsPercentage / 100);

  // Create commission transaction
  await tx.commissionTransaction.create({
    data: {
      partnerId: ancestor.ancestorId,
      sourceType,
      sourceId,
      sourcePartnerId: referringPartnerId,
      levelDepth: ancestor.depth,
      planId: plan.id,
      grossAmount,
      netAmount: grossAmount,  // No fees applied
      careerPoints,
      currency,
      status: 'PENDING',
      idempotencyKey: `${idempotencyKey}:${ancestor.ancestorId}:${ancestor.depth}`
    }
  });

  // Update balance
  await tx.partnerBalance.update({
    where: { partnerId: ancestor.ancestorId },
    data: {
      pendingBalance: { increment: grossAmount },
      careerPointsPeriod: { increment: careerPoints },
      careerPointsTotal: { increment: careerPoints },
      version: { increment: 1 }
    }
  });
}

Example Calculation:

Order Amount: 10,000 RUB

LevelPercentagePartnerCommission
110%Alice1,000 RUB
25%Bob500 RUB
33%Carol300 RUB
42%Dave200 RUB
51%Eve100 RUB

Total distributed: 2,100 RUB (21%)


7. Balance Updates

Balance Record Structure:

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
}

Locking Pattern:

sql
-- Lock ALL affected balances in consistent order
SELECT id FROM mlm.partner_balances
WHERE partner_id = ANY($1::uuid[])
ORDER BY partner_id  -- Consistent order prevents deadlocks
FOR UPDATE;

8. Pending to Available Transition

Transition Criteria:

CriterionCondition
Commission status= 'PENDING'
Age> 14 days (configurable)
Source not refundedNo refund/chargeback on source
No fraud flagsPartner not flagged

Confirmation Job (Scheduled Daily):

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

  // Group by partner for efficient locking
  const byPartner = groupBy(eligibleCommissions, 'partnerId');

  for (const [partnerId, commissions] of Object.entries(byPartner)) {
    await db.$transaction(async (tx) => {
      // Lock balance
      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);

      // Update commission statuses
      await tx.commissionTransaction.updateMany({
        where: { id: { in: commissions.map(c => c.id) } },
        data: { status: 'APPROVED', processedAt: new Date() }
      });

      // Move pending -> available
      await tx.partnerBalance.update({
        where: { partnerId },
        data: {
          pendingBalance: { decrement: totalAmount },
          availableBalance: { increment: totalAmount },
          version: { increment: 1 }
        }
      });
    });
  }
}

9. Commission Reversal

Reversal Triggers:

TriggerSourceAction
Order refundCustomer requestedReverse all related commissions
ChargebackPayment providerReverse all related commissions
Fraud detectionAdmin/SystemReverse and flag
Investment cancellationCustomer/AdminReverse all related commissions

Reversal Process:

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) => {
      // Lock balance
      await tx.$queryRaw`
        SELECT 1 FROM mlm.partner_balances
        WHERE partner_id = ${commission.partnerId}::uuid
        FOR UPDATE
      `;

      if (commission.status === 'PENDING') {
        // Simply reverse - deduct from pending
        await tx.partnerBalance.update({
          where: { partnerId: commission.partnerId },
          data: {
            pendingBalance: { decrement: commission.netAmount },
            careerPointsPeriod: { decrement: commission.careerPoints },
            careerPointsTotal: { decrement: commission.careerPoints }
          }
        });
      } else {
        // Create clawback transaction
        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
          }
        });

        // Deduct from available
        await tx.partnerBalance.update({
          where: { partnerId: commission.partnerId },
          data: {
            availableBalance: { decrement: commission.netAmount },
            careerPointsTotal: { decrement: commission.careerPoints }
          }
        });
      }

      // Mark original as reversed
      await tx.commissionTransaction.update({
        where: { id: commission.id },
        data: {
          status: 'REVERSED',
          reversalReason: reason,
          reversedAt: new Date(),
          reversedBy: adminId
        }
      });
    });
  }
}

10. Payout Eligibility

Eligibility Criteria:

CriterionConditionError Code
KYC Status= 'APPROVED'KYC_REQUIRED
Available Balance>= requested amountINSUFFICIENT_BALANCE
Min Payoutamount >= 1,000 RUBBELOW_MINIMUM
Pending PayoutsNo pending/processing payoutsPAYOUT_PENDING
Partner Status= 'ACTIVE'PARTNER_INACTIVE
Payout MethodAt least one configuredNO_PAYOUT_METHOD

Payout Creation:

sql
-- Atomic balance deduction and payout creation
SELECT * FROM mlm.create_payout_request(
    p_partner_id := $1,
    p_amount := $2,
    p_currency := $3,
    p_method_type := $4,
    p_payout_details := $5
);

Payout Status Flow:

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

Commission Transaction States

StateDescriptionBalance Location
PENDINGCalculated, awaiting confirmationpending_balance
APPROVEDConfirmed, ready for payoutavailable_balance
PAIDIncluded in completed payoutwithdrawn (total_withdrawn)
HELDOn hold for reviewpending_balance
REVERSEDReversed due to refundDeducted
CLAWBACKNegative transactionDeducted from available
CANCELLEDCancelled before confirmationN/A

Error Scenarios

Job Processing Errors

ErrorHandlingRetry
Source not foundLog and fail permanentlyNo
Partner not foundLog and fail permanentlyNo
Database connection lostRetry with backoffYes
Lock timeoutRetry immediatelyYes
Commission plan not foundLog and fail permanentlyNo

Balance Operation Errors

ErrorHandling
Negative balance attemptTransaction rollback, alert
Version mismatchRetry with fresh data
Deadlock detectedRetry (ordered locking prevents)

Monitoring and Alerts

Key Metrics:

MetricAlert Threshold
Job queue depth> 1000 jobs
Failed jobs (24h)> 10
Dead letter queue> 0
Processing time> 30 seconds per job
Reversal rate> 5% of commissions

Audit Log:

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()
);