Affiliate program¶
Definition. A flat single-level revenue-share program. Every affiliate has up to 3 referral codes; new signups attach to a code via
User.affiliateCodeId. On every settled bet by a referral, the platform credits the affiliate with a percentage of the GGR generated. Tier (commission %) is determined by the affiliate's referrals' lifetime USD wager. Earnings accrue per-currency and are claimed by the affiliate on demand — gated by an "active referrals in the last 14 days" check.
1. Tier ladder¶
// apps/api/src/affiliate/const.ts:12
AFFILIATE_LEVELS = [
{ id: 1, name: 'Tier 1', commission: '0.1', minWageredUsdAmount: 0, minReferralCountForClaim: 0 },
{ id: 2, name: 'Tier 2', commission: '0.15', minWageredUsdAmount: 25_000, minReferralCountForClaim: 3 },
{ id: 3, name: 'Tier 3', commission: '0.2', minWageredUsdAmount: 100_000, minReferralCountForClaim: 10 },
{ id: 4, name: 'Tier 4', commission: '0.25', minWageredUsdAmount: 250_000, minReferralCountForClaim: 25 },
{ id: 5, name: 'Tier 5', commission: '0.3', minWageredUsdAmount: 1_000_000, minReferralCountForClaim: 100 },
];
| Tier | Commission | Minimum lifetime wager (referrals, USD) | Active referrals to claim |
|---|---|---|---|
| Tier 1 | 10% | $0 | 0 |
| Tier 2 | 15% | $25,000 | 3 |
| Tier 3 | 20% | $100,000 | 10 |
| Tier 4 | 25% | $250,000 | 25 |
| Tier 5 | 30% | $1,000,000 | 100 |
Tier resolution at findAffiliateLevel (affiliate/const.ts:65):
const level = AFFILIATE_LEVELS.findLast(
level => wageredUsdAmount.gte(level.minWageredUsdAmount)
);
findLast walks the array in reverse and returns the first match → the highest tier the affiliate qualifies for. Wager amount is the sum of all referrals' lifetime USD wager (getUsersStats at affiliate-user-stats.service.ts:55), not the affiliate's own wager.
1.1 Custom override¶
User.customMinAffiliateLevel (affiliate.utils.ts:17) lets an admin set a floor: if customMinAffiliateLevel.id > currentLevel.id, the player gets the higher commission immediately, without meeting the wager threshold. Used for:
- Streamer / partner contracts negotiated outside the standard ladder
- Boost windows / special incentives
// apps/api/src/affiliate/affiliate.utils.ts:24
return customMinAffiliateLevel && customMinAffiliateLevel.id > currentLevel.id
? customMinAffiliateLevel
: currentLevel;
2. Commission math¶
Reuses the same CashbackUtils as rakeback (see rakeback.md §1.2):
// apps/api/src/affiliate/stats/affiliate-user-stats.service.ts:143
const cashback = CashbackUtils.calculate({
bet: args.bet,
game: args.game,
percent: affiliateLevel.commission,
});
Algebraically — and identical in shape to rakeback:
commission is denominated in the bet's own currency. The repository writes both per-currency claim balances and a USD-aggregate row used for tier evaluation:
// apps/api/src/affiliate/stats/affiliate-user-stats.repository.ts:22
const claimableAmountUsd = ExchangeRatesService.toUsd(args.claimableAmount, args.currencyId);
await prisma.affiliateUserStatsUsd.upsert({...claimableAmount: { increment: claimableAmountUsd }});
await prisma.affiliateUserStats.upsert({...claimableAmount: { increment: args.claimableAmount }}); // per-currency
3. Worked examples¶
3.1 Tier 1 affiliate, single referral¶
- Affiliate has 0 referrals' lifetime wager → Tier 1, 10%.
- Referral wagers 1000 USDT at 99% RTP house game.
The affiliate's affiliateUserStats row for currency USDT increments claimableAmount += 1. The USD-aggregate row increments by ExchangeRatesService.toUsd(1, USDT) ≈ 1 USD.
3.2 Tier 3 affiliate, mixed-currency book¶
- Affiliate's referrals have collectively wagered $150,000 USD-equivalent → Tier 3, 20%.
- Today, one referral wagers 0.005 BTC at 98% RTP. BTC ≈ $60,000.
wagerUsd = 0.005 × 60000 = $300 USD
GGR = 0.005 × (100 − 98) / 100 = 0.0001 BTC
commission = 0.0001 × 0.20 = 0.00002 BTC ≈ $1.20 USD
The affiliate's affiliateUserStats row for BTC increments by 0.00002; the USD aggregate by ~1.20.
3.3 Tier 2 affiliate fails the active-referral gate¶
- Referrals' lifetime USD wager: $30,000 → qualifies for Tier 2 (commission 15%, requires 3 active referrals).
- Active referrals (any referral with a bet in the last 14 days): only 2.
canClaim (affiliate-user-stats.service.ts:156) returns false. claim throws AFFILIATE_CONDITIONS_NOT_MET. Earnings continue to accrue; the affiliate has to wait for a third referral to wager (or for one of the 2 active ones to wager again so the count holds).
4. Active referral definition¶
// apps/api/src/affiliate/affiliate.utils.ts:30
static isReferralActive(lastWageredAt?: Date | null) {
if (!lastWageredAt) return false;
const cutoff = DateTime.now()
.minus({ days: AFFILIATE_ACTIVE_REFERRAL_PERIOD_DAYS })
.toMillis();
return lastWageredAt.getTime() > cutoff;
}
AFFILIATE_ACTIVE_REFERRAL_PERIOD_DAYS = 14 (affiliate/const.ts:78). A referral counts as active if their most recent bet is within the last 14 days. The count is maintained on AffiliateAggregatedInfo.activeReferralsCount (see apps/api/src/affiliate/aggregated-info/); recomputed periodically. {{TBD: confirm with product team the recompute cadence and whether refunds reverse lastWageredAt.}}
5. Claim flow¶
sequenceDiagram
participant Affiliate as Affiliate (player)
participant Ctl as AffiliateController
participant Svc as AffiliateUserStatsService
participant Repo as AffiliateUserStatsRepository
participant Acc as AccountingService
participant DB as Postgres
Affiliate->>Ctl: POST /affiliate/claim
Ctl->>Svc: claim(WithUser)
Note over Svc: Mutex<br/>affiliate:stats:claim:<userId>
Svc->>Svc: getUsersStats + aggregatedInfo
Svc->>Svc: AffiliateUtils.findLevel
Svc->>Svc: canClaim(activeRefs >= minReferralCount)
alt canClaim = false
Svc-->>Ctl: AFFILIATE_CONDITIONS_NOT_MET
else
Svc->>Repo: findManyClaimable
loop per (referral, currency) with claimableAmount > 0
Svc->>Repo: claim ({userId, currencyId, amount})
Note over Repo: stats: claimableAmount → claimedAmount<br/>statsUsd: same in USD
Svc->>Acc: createTransaction (DEPOSIT, AFFILIATE_CLAIMED)
Acc->>DB: insert tx + update affiliate balance
end
end
@Mutex key affiliate:stats:claim:<userId> (affiliate-user-stats.service.ts:69) prevents concurrent claims on the same affiliate. The whole claim is wrapped in PrismaTransactional.execute — if any per-currency leg fails, the whole batch rolls back.
6. Multi-level marketing¶
There is no MLM depth. The model is single-level: an affiliate earns only on bets by users with affiliateCodeId pointing at one of the affiliate's codes (affiliate.service.ts:114). A referred user can themselves become an affiliate, but their commissions accrue independently — the original referrer does not earn a "second-level" cut.
If product wants tiered-MLM in the future, the cleanest extension point is AffiliateService.handleBet (affiliate.service.ts:112) plus a recursive walk of User.affiliateCodeId → ownerId → User. {{TBD: confirm with product team this is on the roadmap or out of scope.}}
7. Codes — generation, limits, attribution¶
| Property | Value | Source |
|---|---|---|
| Format | ^[a-zA-Z0-9]{3,38}$, lower-cased |
affiliate/code/const.ts:7 |
| Auto-generated length on signup | 10 chars | affiliate/code/const.ts:8 |
| Per-affiliate cap | 3 codes | affiliate/code/const.ts:9 (REFERRAL_CODE_MAX_COUNT) |
| Default code on KYC LEVEL_1 | username (lowercased) | kyc.service.ts:88 → affiliateService.createDefaultCode |
| Forbidden words | NO_FORBIDDEN_WORDS_REGEX |
libs/shared/src/security/word-blacklist-patterns.ts |
| Self-referral | A user with their own code as affiliateCodeId would generate self-commissions. {{TBD: confirm whether code blocks this.}} |
n/a |
The default code is created when the player crosses LEVEL_0 → LEVEL_1 (kyc.service.ts:88), so unverified accounts cannot operate as affiliates.
8. Streamer accounts¶
apps/api/src/affiliate/streamer/ — a separate program for content creators with bespoke admin tools, a streamer page, and per-stream stats. Operates alongside the standard tier system; commissions are still computed from User.customMinAffiliateLevel if set. {{TBD: product team to confirm streamer-specific commission overrides.}}
9. Edge cases¶
| Case | Behavior | Source |
|---|---|---|
Referral has no affiliateCodeId |
handleBet returns early; no commission accrued |
affiliate.service.ts:114 |
| Affiliate code deleted after attribution | User.affiliateCodeId retains the FK; commissions continue accruing |
affiliate-code.repository.ts |
| Refund of a commission-generating bet | Commission is not reversed. {{TBD: confirm with product team.}} | n/a |
| Bet status != SETTLED | handleBet only fires on SETTLED (BetQueueProcessor at bet.queue-processor.ts:108) |
bet.queue-processor.ts:100 |
| Tier downgrade after recompute | Only future commissions are at the lower rate; accrued earnings are unaffected | tier resolved per bet at affiliate-user-stats.service.ts:138 |
| Custom level higher than computed | customMinAffiliateLevel wins |
affiliate.utils.ts:24 |
| Custom level lower than computed | currentLevel wins (custom is a floor, not a ceiling) |
same |
| 100% RTP game | GGR = 0 → commission = 0 |
ggr.utils.ts:6 |
| Concurrent claim attempts | @Mutex serializes; second attempt waits |
affiliate-user-stats.service.ts:68 |
10. Admin overrides¶
| Capability | Code |
|---|---|
Set customMinAffiliateLevel on a user |
Admin user-edit; field customMinAffiliateLevel |
| Create/delete affiliate codes for any user | AffiliateCodeService.createAffiliateCodeByAdmin / deleteAffiliateCode |
| Inspect any affiliate's stats / referrals | AffiliateService.findOneAffiliateByAdmin / findManyAffiliateUsersByAdmin |
| List affiliate claim history | findManyClaims (Transaction tag = AFFILIATE_CLAIMED) |
| Adjust active-referral window | Edit AFFILIATE_ACTIVE_REFERRAL_PERIOD_DAYS (compile-time const) |
| Manually credit | Transaction { type: DEPOSIT, tag: AFFILIATE_CLAIMED, adminUserId: <admin>, payload: { userId } } |
11. Related docs¶
rakeback.md— SameCashbackUtils.calculateformula, different consumerbet-settlement.md— WhereAffiliateService.handleBetfires fromvip-program.md—customMinAffiliateLevelis a parallel admin override on the same user recordbonuses-and-promos.md—affiliateCodeToClaimgate on promos../flows/dropbet-sign-up.md— Where signup attachesaffiliateCodeId../data-model/—AffiliateCode,AffiliateUserStats,AffiliateUserStatsUsd,AffiliateAggregatedInfo