# Bonuses, free spins, and free bets

Apogee supports operator-granted promotional bonuses natively. Every
bonus has an independent ledger — free rounds don't touch the player's
real-money wallet, so you can run promotional campaigns without
contaminating GGR.

## Bonus types

| Type | Unit | Applies to |
|---|---|---|
| `free_spins` | count of spins | Apogee Hot 5 (slot) |
| `free_bets` | count of bets | Skyward, Contrail (crash) |
| `stake_match` | percentage of next N real bets | all |
| `cashback` | percentage of loss refund after N rounds | all |
| `jackpot_entry` | qualifies player for jackpot pool | Contrail (multiplayer) |

Every bonus has: `bonusId`, `type`, `remaining` (count or amount),
`wageringRemaining`, `expiresAt`, `maxWinMinor`, `gameId` (optional —
scope to one game).

## Lifecycle

```
AWARDED  → ACTIVE  → COMPLETED   (wagering met, winnings released)
                  → EXPIRED     (expired before wagering met)
                  → CANCELLED   (operator revoked)
                  → FORFEITED   (player withdraw locked funds)
```

## Endpoints

### `POST /v1/bonuses/award` (operator, HMAC-signed)

Grant a bonus to a player.

**Request**:

```json
{
  "playerId":    "p_1234",
  "type":        "free_spins",
  "gameId":      "apogeehot5",
  "remaining":   10,
  "wageringRequired": 0,
  "maxWinMinor": 10000,
  "stakePerSpinMinor": 20,
  "expiresAt":   "2026-05-12T00:00:00Z",
  "campaignId":  "welcome_bonus_v3"
}
```

**Response 201**:

```json
{
  "bonusId":  "bns_abc123",
  "playerId": "p_1234",
  "status":   "active",
  "createdAt": "2026-04-12T10:00:00Z"
}
```

### `GET /v1/bonuses/player/:playerId` (operator, HMAC-signed)

List active bonuses for a player.

**Response**:

```json
{
  "bonuses": [
    {
      "bonusId":  "bns_abc123",
      "type":     "free_spins",
      "gameId":   "apogeehot5",
      "remaining": 7,
      "maxWinMinor": 10000,
      "accruedWinMinor": 4200,
      "expiresAt": "2026-05-12T00:00:00Z",
      "status":   "active"
    }
  ],
  "count": 1
}
```

### `POST /v1/bonuses/:bonusId/cancel` (operator, HMAC-signed)

Cancel an active bonus. Any accrued winnings (subject to the bonus'
wagering requirement) are forfeited unless `keepWinnings: true`.

### `GET /v1/bonuses/:bonusId` (operator, HMAC-signed)

Lookup by bonus ID.

## Client-side flow

When the game client loads a session, it calls
`GET /v1/bonuses/player/:playerId?gameId=X` and caches the result.
If there's an active free-spin bonus on the current game, the UI
shows:

- A "FREE SPINS" badge on the bet button
- The remaining count
- The stake is locked to `stakePerSpinMinor` (player can't change it)
- Wins are credited to the **bonus ledger**, not the real-money
  wallet, until `remaining = 0` AND `wageringRemaining = 0`
- Once wagering is complete, the accrued wins settle to the
  real-money wallet via a single `/v1/wallet/credit` call with
  `reason: "bonus_settlement"` and `refBonusId: bns_...`

## Bonus wallet separation

During a bonus round:

- **Debit** is virtual — no `/v1/wallet/debit` call
- **Credit** (win) is virtual — accrued in the bonus ledger
- **Settlement** is a single real `/v1/wallet/credit` when the bonus
  completes
- The player's real balance is untouched throughout

This is critical for operators running bonuses: your GGR calculation
doesn't see bonus-funded action, so free-spin losses don't reduce
your billable GGR.

## Webhooks

Every bonus state change emits a webhook to your `webhookUrl`:

| Event | When |
|---|---|
| `bonus.awarded` | `/award` succeeded |
| `bonus.activated` | Player used the bonus for the first time |
| `bonus.progress` | Every round the bonus is used (includes remaining count + accrued win) |
| `bonus.completed` | Wagering met, settled to real-money wallet |
| `bonus.expired` | `expiresAt` passed without completion |
| `bonus.cancelled` | Operator called `/cancel` |

Envelope shape: see `docs/WEBHOOKS.md`.

## Aviator Rain (in-game free-bet drops)

Rain is a **live, in-game promotional mechanic** where free bets drop into the chat panel and players claim them first-come-first-served. Unlike API-awarded bonuses above (which are operator-side, pre-session), Rain is real-time and in-session.

### How it works

1. **Trigger:** Rain is triggered on player join (welcome rain) and periodically (every 2 minutes on Contrail). Operators can also trigger manually via `POST /contrail/rain` with `X-Admin-Key`.
2. **Distribution:** A configurable number of free bets (default 3–5) drop into the in-game chat. Each player can claim at most one per rain event.
3. **Claiming:** Player taps CLAIM in the chat or on the promo banner. First-come-first-served — when all drops are claimed, latecomers see "All free bets have been claimed".
4. **Usage:** Claimed free bet appears as a "FB" badge near the balance. Bet buttons switch to golden "FREE BET" during the next betting window. No balance deduction — the free bet amount is staked for free.
5. **Min cashout:** Free bets enforce a minimum cashout multiplier (default 2.00×). The CASH OUT button is disabled below this threshold.
6. **Expiry:** Unclaimed rain expires after a configurable timeout (default 2 minutes). Unused free bets expire 2 minutes after claim.
7. **Cancel restore:** If a player places a free bet then cancels during the betting window, the free bet is restored — not lost.
8. **Re-trigger:** After claim or expiry, rain re-triggers automatically (15s after claim, 5s after expiry in Skyward demo mode).

### Rain endpoint (Contrail)

```
POST /contrail/rain
X-Admin-Key: <CONTRAIL_ADMIN_KEY>

{
  "count": 5,
  "amountMinor": 100,
  "minCashout": 2.0,
  "expiresIn": 120000
}
```

Response: `{ "ok": true, "rainId": "rain_1_...", "count": 5, ... }`

### Rain rules

| Parameter | Default | Range | Description |
|---|---|---|---|
| `count` | 5 | 1–50 | Number of free bets per rain event |
| `amountMinor` | 100 | 1–100,000 | Free bet amount in minor units |
| `minCashout` | 2.00 | 1.01–100 | Minimum multiplier to cash out |
| `expiresIn` | 120,000ms | 30s–10min | Time until unclaimed rain expires |

### Artwork

- Hero illustration: [`assets/games/rain.svg`](../assets/games/rain.svg) — golden coins raining from a glowing cloud
- Icon: [`assets/games/rain-icon.svg`](../assets/games/rain-icon.svg) — compact 64×64 icon for banners and chat

### Games with Rain

| Game | Trigger | Transport |
|---|---|---|
| Contrail | Welcome rain on WS connect + periodic broadcast + admin HTTP | WebSocket `rain` / `rain_claim` messages |
| Skyward | Demo-mode timer (2s after join, every 5min) | Local JS (no server needed in demo) |

## Status

🟡 **Planned — Q3 2026.** Spec is stable; endpoints are stubbed but
not implemented. Operators needing bonus functionality today should
contact `partners@apogeetech.net` for a manual-settlement workaround
using the existing `/wallet/credit` with `reason: "bonus_settlement"`
and a `refBonusId` field in the metadata.

## Caps and safety

- `maxWinMinor` is enforced **server-side**. No bonus round can pay
  out more than this, regardless of RTP drift or jackpot.
- Total active bonus liability per merchant is tracked in
  `bonus_ledger/{merchantId}` and visible in the admin panel as
  "bonus exposure". This is your current outstanding liability if
  every player cashed out their bonus balance right now.
- A bonus cannot be awarded if it would push merchant bonus exposure
  above `merchant.maxBonusExposureMinor` (default 10% of configured
  bankroll — `PATCH /v1/merchants/:id` to adjust).
