Skip to content

Bonuses and promos

Definition. A unified PromoCode model 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 a DEPOSIT bonus is ACTIVE, 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:

bonusUsdAmount   = min(1000 × 1.0, 500) = $500          # capped
wagerTargetUsd   = 500 × 30             = $15,000

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):

new Date(Date.now() + code.lockWithdrawOnClaimHours * 60 * 1000)

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/