Bonuses and promos¶
Definition. A unified
PromoCodemodel that covers both INSTANT bonuses (no wager required, paid on claim) and DEPOSIT match bonuses (paid on first qualifying deposit, locked behind a wagering requirement, expires on a timer). One claim per user per code; while aDEPOSITbonus isACTIVE, withdrawals and tips are blocked, and bets are restricted to a per-promo game whitelist with per-game max-bet caps.
1. Model¶
A single PromoCode row encodes both types via discriminator type (api.prisma:1782):
| Field | INSTANT | DEPOSIT | Source |
|---|---|---|---|
type |
INSTANT |
DEPOSIT |
api.prisma:1782 |
amount + currencyId |
flat bonus paid on claim | ignored | api.prisma:1794 |
bonusMultiplier |
n/a | match factor (1 = 100%) |
api.prisma:1842 |
maxBonusUsdAmount |
n/a | bonus cap | api.prisma:1843 |
minDepositUsdToActivate |
n/a | qualifying deposit floor | api.prisma:1824 |
wagerMultiplierToComplete or wagerUsdAmountToComplete |
n/a | rollover requirement | api.prisma:1856-1857 |
timeSecondsToComplete |
n/a | rollover deadline | api.prisma:1855 |
lockWithdrawOnClaimHours |
optional | optional | api.prisma:1840 |
autoVaultOnClaim |
optional | optional | api.prisma:1844 |
claimsLeft |
optional global cap | optional global cap | api.prisma:1804 |
expiresAt |
optional code expiry | optional code expiry | api.prisma:1802 |
affiliateCodeToClaim |
gate to specific referrer | gate | api.prisma:1820 |
min{KYCLevel,TotalWagerUsd,TotalDepositUsd,VipLevel}ToClaim |
claim gate | claim gate | api.prisma:1815-1818 |
onlyZeroDepositsToClaim |
"first deposit" gate | gate | api.prisma:1819 |
The complementary UserPromoCode row (api.prisma:1893) tracks the per-player lifecycle. Status enum at api.prisma:1881:
INSTANT → COMPLETED (single transition)
DEPOSIT → CLAIMED → ACTIVE → COMPLETED
↘ CANCELLED (admin or user cancel)
↘ EXPIRED (timer ran out)
2. Lifecycle¶
2.1 Claim — processOnClaim (promo-effect.service.ts:61)¶
flowchart TD
A[Player POST /promo/:id/claim] --> B[findClaimablePromoCode]
B --> C{checkClaimConditions}
C -- fail --> X[ApiException]
C -- pass --> D[decrementClaimsLeft]
D --> E{has activate conditions?}
E -- yes --> F[status=CLAIMED]
E -- no --> G{has complete conditions?}
G -- yes --> H[status=ACTIVE]
G -- no --> I[status=COMPLETED<br/>credit amount as Transaction tag=PROMO]
F --> J[autoVaultOnClaim?]
I --> J
H --> J
J -- yes --> K[move user balance to vault]
Branching rule (promo/utils/code.utils.ts:81-97):
- INSTANT codes have no activate/complete conditions → straight to COMPLETED with the bonus credited as a Transaction { type: DEPOSIT, tag: PROMO }.
- DEPOSIT codes always have both activate (deposit-floor check) and complete (wager + timer) conditions, so they end at CLAIMED waiting for the qualifying deposit.
2.2 Activate — processOnActivate (promo-effect.service.ts:148)¶
Triggered by PromoEventHandlerService.onTransaction (promo-event-handler.service.ts:44) when any Transaction { type: DEPOSIT, tag: DEPOSIT } lands while the user has a CLAIMED promo. The validator checks the deposit amount meets minDepositUsdToActivate; if it does, activate; if not, the promo is auto-cancelled.
On activate:
1. Compute the bonus amount and rollover target (see §3).
2. Persist UserPromoCodeProgress with expiresAt = now + timeSecondsToComplete.
3. Schedule a BullMQ delayed job to expire the promo at expiresAt (promo/tasks/task.service.ts).
4. Credit the bonus as Transaction { type: DEPOSIT, tag: PROMO }.
2.3 Wager progress — onBet / onProgressUpdate (promo-event-handler.service.ts:103, promo-effect.service.ts:444)¶
Per settled bet:
const progress = await this.promoEffectService.onProgressUpdate({
userPromo,
incrementWagerAmountUsd: bet.usdAmount.mul(allowedGameConfig.wagerMultiplier),
});
if (progress.wageredUsdAmount.gte(progress.wagerUsdAmountToComplete)) {
await this.promoEffectService.processOnCompleted(userPromo);
}
Per-game wagerMultiplier lives on PromoGameWhitelist rows. Live blackjack at multiplier 0.1 only contributes 10% of the wager toward rollover; a 100%-RTP game can be set to 0 to make it ineligible. {{TBD: confirm with product team the canonical multiplier matrix per game.}}
2.4 Complete — processOnCompleted (promo-effect.service.ts:470)¶
Status flips to COMPLETED, the BullMQ expiration job is cancelled, user cache cleared. Withdraw lock (if any) is released by the natural expiry of lockWithdrawOnClaimHours — completion does not automatically unblock; the lock is set on the bonus credit, not on completion. {{TBD: verify with product team.}}
2.5 Cancel / expire — processOnCancelled / processOnExpired (promo-effect.service.ts:242, 374)¶
Both clawback the bonus by emitting an offsetting Transaction { type: WITHDRAW, tag: PROMO }, but only if the player hasn't fully wagered through:
// promo-effect.service.ts:251
if (userCode.userProgress.wageredUsdAmount.lte(
userCode.userProgress.wagerUsdAmountToComplete)) {
// emit WITHDRAW for bonusUsdAmount
}
If the wager target was met, the bonus is the player's to keep. The processOnExpired variant additionally caps the clawback at the player's current balance (promo-effect.service.ts:389) — never overdraws.
processOnCancelledWithCustom (promo-effect.service.ts:301) lets an admin override the clawback amount via payload.customDecreaseAmount — used when product wants to forfeit only a portion.
3. Math¶
3.1 Match formula¶
// apps/api/src/promo/utils/code.utils.ts:99
function calculateWagerToComplete(args) {
const bonusUsdAmount = Decimal.min(
args.baseUsdAmount.mul(args.bonusMultiplier), // deposit × match%
args.maxBonusUsdAmount, // cap
);
if (args.wagerMultiplierToComplete) {
const wagerUsdAmountToComplete = bonusUsdAmount.mul(args.wagerMultiplierToComplete);
return { wagerMultiplierToComplete, wagerUsdAmountToComplete, bonusUsdAmount };
}
if (args.wagerUsdAmountToComplete) {
const wagerMultiplierToComplete = args.wagerUsdAmountToComplete.div(args.baseUsdAmount);
return { ... };
}
}
Algebraically:
bonusUsd = min(deposit × bonusMultiplier, maxBonusUsdAmount)
wagerTargetUsd = bonusUsd × wagerMultiplierToComplete # rollover from bonus
-- or --
wagerTargetUsd = wagerUsdAmountToComplete # absolute target
bonusMultiplier is a Float; everything else is Decimal.
3.2 Worked example — first-deposit 100% match, 30× rollover, 7-day timer¶
Promo config:
- type = DEPOSIT
- bonusMultiplier = 1.0 (100% match)
- maxBonusUsdAmount = $500
- minDepositUsdToActivate = $20
- wagerMultiplierToComplete = 30
- timeSecondsToComplete = 604800 (7 days)
- lockWithdrawOnClaimHours = 0 (no extra lock)
Player deposits $100 USDT.
baseUsdAmount = $100
bonusUsdAmount = min(100 × 1.0, 500) = $100
wagerTargetUsd = 100 × 30 = $3000
expiresAt = now + 7d
Player's balance after activate:
- Real money: $100 (deposit) → unchanged in real-balance terms
- Bonus credit: $100 worth of USDT (Transaction tag=PROMO)
- Total wallet: $200
Player must wager $3000 in eligible games within 7 days. Each $1 wager increments wageredUsdAmount by $1 × allowedGameConfig.wagerMultiplier.
3.3 Bonus capped by maxBonusUsdAmount¶
Same promo, $1000 deposit:
Note the rollover scales with the capped bonus, not the deposit — so depositing above the cap doesn't proportionally raise the wagering burden.
3.4 Reverse — fixed wager target¶
If the promo specifies wagerUsdAmountToComplete = 1500 (fixed) instead of wagerMultiplierToComplete, then:
bonusUsdAmount = same as above
wagerUsdAmountToComplete = 1500
wagerMultiplierToComplete = 1500 / baseUsdAmount # derived
For a $100 deposit, the derived multiplier is 15. The opaque field on UserPromoCodeProgress is the absolute USD target.
4. Withdrawal & tip lock during ACTIVE¶
While userPromoCode.isActive (status ∈ {CLAIMED, ACTIVE}):
| Action | Effect | Source |
|---|---|---|
userBalanceService.toVault |
Throws PROMO_CODE_IS_ACTIVE |
user-balance.service.ts:84 |
userBalanceService.fromVault |
Throws PROMO_CODE_IS_ACTIVE |
user-balance.service.ts:105 |
| Withdrawal | Withdrawal flow blocks via WithdrawalsBlock if lockWithdrawOnClaimHours was set |
getBlockConfigIfSet at code.utils.ts:43 |
| Bet on non-whitelisted game | BET_RESTRICTED_BY_PROMO_ACTIVE_CODE |
promo-event-handler.service.ts:96 |
Bet over per-game maxBetAmount |
Same exception with custom message | promo-event-handler.service.ts:99 |
| House games | Always allowed (skip the whitelist check entirely) | promo-event-handler.service.ts:85 |
The withdraw block is implemented as a WithdrawalsBlock row keyed by userId with blockUntil = now + lockWithdrawOnClaimHours × 60 minutes. Bug warning — the conversion uses 60 * 1000 interpreting lockWithdrawOnClaimHours as minutes, not hours, despite the field name (code.utils.ts:50):
60_000 ms = 60 seconds. So lockWithdrawOnClaimHours = 24 actually locks for 24 minutes, not 24 hours. {{TBD: confirm with product team whether this is a known bug or intentional historical naming.}}
5. Edge cases¶
| Case | Behavior | Source |
|---|---|---|
Deposit < minDepositUsdToActivate |
Promo auto-CANCELLED on first deposit | promo-event-handler.service.ts:71 |
| Multiple deposits before activation | First deposit decides — too small → cancel | same |
claimsLeft = 0 global |
Code becomes unclaimable; existing claimers unaffected | decrementPromoCodeClaimsLeft |
| Player tries to claim same code twice | findClaimablePromoCode returns null on retry |
promo.repository.ts |
bonusUsdAmount = 0 |
Cap is zero or multiplier is zero — promo is degenerate but legal | n/a |
| Expired before any wager | Full clawback (no progress made) | processOnExpired |
| Expired after meeting target | No clawback (wagered ≥ target) | processOnExpired:393 |
| Cancel after meeting target | No clawback | processOnCancelled:251 |
| Player balance < bonus on expire | Caps clawback at balance — never goes negative | processOnExpired:389 |
| House game during ACTIVE | Bypasses whitelist; counts toward wager via wagerMultiplier lookup … but a missing whitelist row logs a warning and skips the progress increment (promo-event-handler.service.ts:122) — house bets do not advance rollover by default. {{TBD: product team to confirm desired behavior.}} |
same |
6. Admin overrides¶
| Path | Effect | Code |
|---|---|---|
| Create / update / delete promo | apps/api/src/promo/controllers/admin-promo.controller.ts |
admin-promo.service.ts |
| Cancel a player's claimed promo | processOnCancelledWithCustom with optional customDecreaseAmount |
promo-effect.service.ts:301 |
Set customDecreaseAmount = 0 |
Cancel without clawback | same |
Edit claimsLeft, expiresAt, isActive |
Direct Prisma update via admin API | apps/api/src/promo/controllers/admin-promo.controller.ts |
| Per-game whitelist + max-bet + wager-multiplier | PromoGameWhitelist rows |
apps/api/src/promo/repositories/promo-game-whitelist.repository.ts |
User.affiliateCodeId change |
Affects which gated promos a user can claim | apps/api/src/affiliate/code/ |
7. Related docs¶
affiliate-program.md—affiliateCodeToClaimgatekyc-tiers.md—minKYCLevelToClaimgatevip-program.md—minVipLevelToClaimgatewallet-and-balance.md— Vault, balance, withdraw blockbet-settlement.md— Bet event source foronBetprogress updates../flows/dropbet-bet-place.md../data-model/—PromoCode,UserPromoCode,UserPromoCodeProgress,PromoGameWhitelist