Skip to content

VIP program (loyalty ladder)

Definition. A wager-driven XP ladder. Every settled bet increases a player's User.exp by usdAmount × EXP_MULTIPLIER; once XP crosses the next threshold, the player's vipLevel is bumped, an optional one-time DBC bonus is auto-credited, and a notification is emitted. The level is the single input that drives the rakeback percent (see rakeback.md) and is the gating signal for the manual VIP-application program (perks negotiated 1:1 with the host).

1. Levels — the ladder

There are eight loyalty bands with a total of 31 sub-levels, generated from the configs at apps/api/src/user/loyalty/const.ts:15 (LOYALTY_LEVEL_CONFIGS). Five of the eight bands are split into five sub-levels (Metal 1-5, Bronze 1-5, …); Wood and Beast each have one threshold.

Band Sub-levels XP thresholds (USD-equivalent wagered) Level-up bonuses (DBC)
Wood 1 0 0
Metal 5 100, 200, 300, 400, 500 0.4, 0, 1.2, 0, 2
Bronze 5 1k, 2k, 3k, 4k, 5k 2, 0, 6, 0, 10
Silver 5 10k, 20k, 30k, 40k, 50k 15, 0, 45, 0, 75
Gold 5 100k, 200k, 300k, 400k, 500k 90, 0, 270, 0, 450
Platinum 5 750k, 1M, 1.25M, 1.5M, 1.75M 525, 0, 875, 0, 1225
Diamond 5 2.5M, 3M, 4M, 5M, 7.5M 2000, 0, 3200, 0, 6000
Beast 1 10M 0 (terminal)

Generation logic at apps/api/src/user/loyalty/const.ts:138 produces the linear LOYALTY_LEVELS array (id 1..31) used everywhere as the canonical level reference.

2. XP — what counts

Bet settlement increments XP:

// apps/api/src/user/user.service.ts:1065
const exp = bet.usdAmount.mul(this.configService.get('EXP_MULTIPLIER'));
await this.userRepository.incrementUserExp(bet.userId, exp, delayMs);
  • bet.usdAmount is the bet's wager converted to USD via ExchangeRatesService at the moment the bet was placed.
  • EXP_MULTIPLIER defaults to 1 (libs/shared/src/env-config/env.dto.ts:198); test env ships 1. Set > 1 for promo windows ("double XP weekend"). {{TBD: confirm with product team the canonical production value.}}
  • Refunds and rollbacks do not deduct XP. Only BetStatus.SETTLED enters UserService.handleBet from the BullMQ side-effect path (apps/api/src/bet/queue/bet.queue-processor.ts:100,105).

3. Level-up — what happens when XP crosses a threshold

Bookkeeping is in UserRepository.incrementUserExp (apps/api/src/user/user.repository.ts:432):

  1. Read the user's current exp and vipLevel.
  2. Increment exp atomically.
  3. Look up the new band via UserUtils.getLoyaltyLevel(newExp) (user.utils.ts:18).
  4. If the new band's id differs from the stored vipLevel, persist the new id.
  5. Return both old and new levels.

Then UserService.handleBet (user.service.ts:1061) calls handleNewLoyaltyLevelUp for every level skipped in a single bet — important for whales who can leap multiple thresholds with one massive wager:

// apps/api/src/user/user.service.ts:1003
for (let index = currentVipLevel.id + 1; index <= newVipLevel.id; index++) {
  const loyaltyLevel = UserUtils.getLoyaltyLevelById(index);
  ...
  if (loyaltyLevel.levelUpBonusAmount.lte(0)) {
    // notification only — no bonus
  } else {
    // creditOrFindTransaction(LOYALTY_BONUS, currencyId=DBC, amount=levelUpBonusAmount)
    // notification with amount
  }
}

The bonus transaction uses an idempotent id user-<userId>-level-up-<levelName> (user.service.ts:1026), so re-running this for the same user/level is a no-op.

4. Worked example — bet that crosses two thresholds

Player at Bronze 5 (XP = 4900, level id 11). They place a 1500 USD wager (RTP irrelevant for XP). EXP_MULTIPLIER = 1.

oldExp        = 4900      → band Bronze 5 (id 11)
addedExp      = 1500
newExp        = 6400      → band Silver 1 (id 12)

Wait — newExp = 6400 is below Silver's first threshold 10000. Player is now in Bronze beyond the band's last threshold; getLoyaltyLevel returns the same level (Bronze 5). XP > 5000 stays Bronze 5 until newExp >= 10000.

Let's bump the wager to 7000 USD instead:

oldExp = 4900
addedExp = 7000
newExp = 11900       → band Silver 1 (id 12)

The for loop at user.service.ts:1003 runs once for index = 12: - Silver 1.levelUpBonusAmount = 15 DBC - accountingService.createOrFindTransaction({ id: 'user-<userId>-level-up-Silver-1', amount: 15, type: DEPOSIT, tag: LOYALTY_BONUS, currencyId: DBC }) - Notification LevelUpWithBonus emitted with levelName='Silver 1', amount='15'.

Now consider a 25,000 USD wager from the same starting point:

oldExp = 4900
addedExp = 25000
newExp = 29900       → band Silver 3 (id 14)

The loop runs three times: id 12 (Silver 1, +15 DBC), id 13 (Silver 2, +0 DBC, notify only), id 14 (Silver 3, +45 DBC). Net DBC credit: 60 DBC. Three separate notifications emitted, three idempotent transactions written.

5. VIP application — the human-curated tier above the ladder

The auto-ladder above maxes out at Beast. The platform also runs a manually-curated "VIP program" that players opt into via a form. This is distinct from vipLevel and lives at:

  • apps/api/src/vip-program/players/vip-program.service.ts:11 (VipApplicationService) — player-side create/update/upload-photo endpoints
  • apps/api/src/vip-program/admin/vip-program-admin.service.ts — admin review, accept/reject
  • apps/api/src/vip-program/repository/vip-program.repository.ts — Prisma persistence on VipProgramApplication

Photos go to S3 under users/vip-program/ (apps/api/src/vip-program/repository/utils/vip-program.const.utils.ts:1), capped at maxFiles = 4. Per-player perks (custom rakeback boost, dedicated host, withdrawal limit raise, etc.) are negotiated 1:1 and applied via admin overrides on the user record (customMinAffiliateLevel, manual rakeback grants, etc.). {{TBD: confirm with product team the canonical perk matrix.}}

6. Rakeback × VIP — the integration

RakebackService.calculateRakeback reads the user's vipLevel and resolves the loyalty percent:

// apps/api/src/rakeback/rakeback.service.ts:71
const loyaltyPercent = UserUtils.getLoyaltyPercent(user.vipLevel);

The full mapping is in rakeback.md §1.3. A player crossing into Silver doubles their rakeback (Bronze 27.5% → Silver 40%), into Gold another +10pp, etc.

flowchart LR
    Bet[Settled bet]
    BQ[BetQueueProcessor]
    UserH[UserService.handleBet]
    Inc[incrementUserExp]
    Cmp{newVipLevel<br/>> oldVipLevel?}
    Loop[for each skipped level:<br/>credit bonus + notify]
    Rb[RakebackService.handleBet<br/>uses NEW vipLevel]

    Bet --> BQ
    BQ --> UserH
    UserH --> Inc
    Inc --> Cmp
    Cmp -- yes --> Loop
    Cmp -- no --> Rb
    Loop --> Rb

Flow notes: - UserService.handleBet runs before RakebackService.handleBet in BetQueueProcessor.processJob (bet.queue-processor.ts:105-106), so a level-up takes effect for the same bet that triggered it. - Both calls are wrapped with @HandleBetOnce — re-delivery is a no-op.

7. Edge cases

Case Behavior Source
Bet status != SETTLED XP not incremented; no level-up bet.queue-processor.ts:100
usdAmount = 0 (free spin) addedExp = 0; no level change user.service.ts:1065
Negative EXP_MULTIPLIER Logically possible but incrementUserExp does not guard; would decrease XP. Treat as misconfig. env.dto.ts:198
XP overflow past last band getLoyaltyLevel returns Beast (id 31) and stays there user.utils.ts:25
Beast → no further bonus Beast.bonuses = [], levelUpBonusAmount = 0 for the single threshold loyalty/const.ts:131-135
Multiple skipped levels in one bet Loop credits each non-zero bonus individually with distinct idempotent tx ids user.service.ts:1003
Level-up bonus retry createOrFindTransaction({ returnTransactionIfExists: true }) — second call returns existing user.service.ts:1024
Refund of an XP-granting bet XP is not reversed. {{TBD: product team to confirm.}} n/a

8. Admin overrides

Capability Path Code
Set arbitrary vipLevel for a user Admin user-edit form apps/api/src/user/user.repository.ts admin update path
Set arbitrary exp Same same
Custom min affiliate level (separate but adjacent) User.customMinAffiliateLevel apps/api/src/affiliate/affiliate.utils.ts:17
Approve/reject VIP application VipProgramAdminService apps/api/src/vip-program/admin/
Trigger LOYALTY_BONUS manually Transaction { tag: LOYALTY_BONUS } via accounting admin accounting.service.ts