Bonus engine
Five canonical bonus types with wagering rules + a bidirectional adapter contract for casino-platform integration.
Types
| Type | Purpose | Required config |
|---|---|---|
free_spins | N free spins on a slot list | count, value_per_spin, currency, eligible_games[] |
deposit_match | Match X% of player's next deposit, capped | match_percent, cap_amount, currency |
no_deposit | Standalone bonus credit | amount, currency |
cashback | % of losses returned in window | percent, window_days, min_loss |
reload | Match deposit but for re-deposits | same as deposit_match |
All types support:
- Wagering requirement —
wagering_requirement_multiplier× bonus amount must be wagered before withdrawal - Wagering basis —
bonus(just the bonus) orbonus_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-stacking —
max_grants_per_playerprevents 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) → returnADAPTER_REJECTEDto callerPLATFORM_UNAVAILABLE(5xx / network) → returnADAPTER_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:
| Event | Effect on bonus_grants |
|---|---|
bonus.credited | sets activated_at = ts (no status change — already 'active') |
bonus.wagered | bumps wagering_progress + total_wagered by amounts in data.wagered_amount_subunit + data.weighted_progress_subunit |
bonus.completed | status: completed, completed_at = ts |
bonus.expired | status: expired, expired_at = ts |
bonus.cancelled | status: cancelled, cancelled_at = ts, outcome_reason = data.reason |
bonus.forfeited | status: 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}.