Skip to main content

Bonus engine

Five canonical bonus types with wagering rules + a bidirectional adapter contract for casino-platform integration.

Types

TypePurposeRequired config
free_spinsN free spins on a slot listcount, value_per_spin, currency, eligible_games[]
deposit_matchMatch X% of player's next deposit, cappedmatch_percent, cap_amount, currency
no_depositStandalone bonus creditamount, currency
cashback% of losses returned in windowpercent, window_days, min_loss
reloadMatch deposit but for re-depositssame as deposit_match

All types support:

  • Wagering requirementwagering_requirement_multiplier × bonus amount must be wagered before withdrawal
  • Wagering basisbonus (just the bonus) or bonus_plus_deposit (deposit_match types)
  • Game contribution — slots 100% / table 10% / live 5% / etc. — weights bet_amount towards wagering progress
  • Max bet — bet exceeding this during wagering forfeits the bonus
  • Time limit — auto-expiry after N hours
  • Anti-stackingmax_grants_per_player prevents multi-claim
  • Max win cap — total winnings from bonus capped

Lifecycle

active — issued, wagering in progress
completed — wagering requirement met → bonus + winnings released
forfeited — max_bet violation OR fraud / admin override
expired — time_limit_hours exceeded
cancelled — admin cancelled OR adapter cancellation webhook

Forward-only state machine. Once terminal, the row is immutable.

Issuance flow

1. operator/journey/campaign calls grantBonus(input)
2. engine resolves adapter (per-tenant adapter_configs)
3. engine validates anti-stacking + computes wagering_required
4. adapter.bonus.grantBonus() — synchronous HTTP to platform
5. on success — INSERT bonus_grants with platform's bonus_id
6. (later, async) bonus.credited webhook arrives → activated_at set

If the adapter call fails:

  • PLATFORM_REJECTED (4xx) → return ADAPTER_REJECTED to caller
  • PLATFORM_UNAVAILABLE (5xx / network) → return ADAPTER_UNAVAILABLE; retry queued

Multi-currency aware

Every bonus row stores DUAL: bonus_amount + bonus_currency (player wallet currency at issue) + bonus_amount_base + base_currency_code + fx_rate_locked + fx_locked_at. Reports query bonus_amount_base; player view shows native amount.

Webhook lifecycle (inbound)

POST /v1/adapters/:slug/webhooks/bonus-events accepts:

EventEffect on bonus_grants
bonus.creditedsets activated_at = ts (no status change — already 'active')
bonus.wageredbumps wagering_progress + total_wagered by amounts in data.wagered_amount_subunit + data.weighted_progress_subunit
bonus.completedstatus: completed, completed_at = ts
bonus.expiredstatus: expired, expired_at = ts
bonus.cancelledstatus: cancelled, cancelled_at = ts, outcome_reason = data.reason
bonus.forfeitedstatus: forfeited, forfeited_at = ts, outcome_reason = data.reason

Idempotent on (adapter_slug, event_id) via webhook_events_processed.

Reconciliation

pnpm --filter @ucrm/api reconcile:bonuses runs daily — walks active grants, asks each adapter for current platform view, replays missing terminal transitions through the same state-sync pipeline. Catches webhook drops + race conditions.

Bonus campaigns

Like email campaigns but for bonuses: pick (segment + bonus_template) → fan-out via worker. Anti-stacking checked per-player at issue. Status: draft → issuing → issued. Stats: {pending, granted, skipped, failed}.