Skip to content

feat(sportsbook): provider-settled sportsbook module - #335

Open
okapitula wants to merge 3 commits into
feat/sportsbook-prerequisitesfrom
feat/sportsbook-module
Open

okapitula wants to merge 3 commits into
feat/sportsbook-prerequisitesfrom
feat/sportsbook-module

Conversation

@okapitula

@okapitula okapitula commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #334. Review that PR first; this one retargets to dev once it merges.

Summary

Adds sportsbook/betting, a provider-settled sportsbook: a third-party provider runs odds, bet acceptance and settlement in its own widget, and core holds the money behind one command port, SPORTSBOOK_LEDGER. It also backfills the gamification product lists so ladders configured before they existed keep paying rakeback on every product.

Why

Operators want to offer sports betting through a turnkey provider without core learning any vendor's wire protocol. Every provider callback moves money, has to be idempotent against provider retries, and can be reversed after the winnings were withdrawn, so the ledger step, its lock order and its debt handling belong in core. The signature check and the codec belong in the operator's overlay. ADR-0046 records the decision.

What ships:

  • Ledger port operations reserve, confirm, cancel, settle and reopen. Each runs in one transaction, is idempotent on the provider transaction id plus a request fingerprint, and follows one lock order: config, then player, then bet, then wallet and debt rows, then audit. A replay with the same content returns the first answer and moves nothing. A reused id with different content is refused and filed as a finding.
  • Confirm terms: the accepted legs may change only their odds. With decimal odds the potential payout has to move the same way as the odds product.
  • Sessions bind a bet to a player. Stakes are gated at session issue and again at reserve under the locks; settlement is never gated. The country list only blocks on top of the platform geo check. Cooling-off and self-exclusion revoke live sessions through a retried job. The reconcile cron retries any revoke the provider has not acknowledged and revokes the live sessions of any player play eligibility refuses.
  • A reopen takes back what the settlement paid without overdrawing the balance. Any shortfall becomes per-currency sportsbook debt with a withdrawal hold. A later win offsets it, shortfall by shortfall and oldest first, in the round of the bet that owed it and as the leg type its reopen used, so RG loss nets it there. A reopen asking more than the wallet paid the round is refused and filed. An admin can collect or write off under an idempotency key.
  • Reconciliation findings, admin align-with-provider (the provider's answer is applied only to the bet state it asked about), and a reconcile cron for stale reserves and overdue accepted bets.
  • RG loss-limit re-evaluation on every money leg and every debt change. Each bet leg writes its audit row in its own transaction, with the player as actor.
  • Every bet, debt, balance and restriction event is written to the outbox in the transaction that made the change (emitInTransaction). Its consumers (RG evaluation, session revoke) run once per event, so a redelivery adds no second job.
  • A test provider plugin and three E2E suites (ledger, reconcile, routes) through bootE2e.
  • fix(gamification): migration 0019 sets the rakeback and rank challenge product lists to empty (every product) on promo_rank_config rows still holding 0018's default, so a ladder that already paid on sportsbook keeps doing so. A list an operator changed is kept. New ladders keep the narrow default.

Outside the module, LanguageTagSchema (shared by the session locale and the casino category translation keys) now accepts a one-character subtag followed by another subtag, such as en-US-u-hc-h12.

Diagram

sequenceDiagram
  participant P as Provider
  participant O as Operator overlay
  participant L as SPORTSBOOK_LEDGER
  participant W as Wallet
  P->>O: signed callback
  O->>O: verify signature, decode
  O->>L: reserve / confirm / cancel / settle / reopen
  L->>W: wallet move, bet transaction row, replay answer (one tx)
  L-->>O: applied | replayed | refused(reason)
  O-->>P: provider response
Loading

Alternatives considered

  • A core callback route per provider: puts vendor protocol in core and makes a second provider a core release.
  • Settlement over domain events: money never travels over events (ADR-0017), and a lost or reordered event pays twice or not at all.
  • A port that takes the caller's transaction, like GAMING_COMMANDS: the overlay would then own the lock order and the transaction boundary.

Risks

  • Reserve does not repeat the platform geo check, which runs at session issue; a casino stake re-runs it. A country the platform blocks after a session opened keeps staking until that session expires or is revoked. The sportsbook's own blockedCountries is re-checked at reserve.
  • Deploy workers before producers: older workers reject the new sportsbook.rg-revoke queue and the new RG eval trigger values.
  • Gamification migration 0019 resets a product list only while it still holds 0018's default. A list an operator saved as exactly that default set cannot be told apart and is reset to every product too.
  • GGR and RG spend are cash-basis for a reopened bet, so an offset can land in a later period than the payout it reverses. The loss limit is still not enforced at stake time.
  • Each provider callback writes its audit_log row in its own transaction, so it holds the global audit chain lock until commit. The lock is taken last, but callbacks serialize with every other audited write while they hold it.
  • Sportsbook stakes are real money only, by platform rule. Bonus money on sports bets would need a later change to core.

Comment thread packages/core/src/sportsbook/betting/service/ledger.service.ts
Comment thread packages/core/src/sportsbook/betting/service/ledger.service.ts
Comment thread packages/core/src/sportsbook/betting/plugin.ts
Comment thread packages/testing/src/__tests__/sportsbook-ledger.e2e.test.ts
Comment thread packages/core/src/sportsbook/betting/contract/index.ts Outdated
@okapitula
okapitula force-pushed the feat/sportsbook-module branch 2 times, most recently from eda3d61 to 34224fc Compare October 25, 2026 12:18
Adds sportsbook/betting: the provider settles bets, core holds the money.

- Ledger port: reserve, confirm, cancel, settle and reopen. Each runs in
  one transaction, idempotent on the provider transaction id plus a
  request fingerprint, under one lock order
  (config -> player -> bet -> wallet/debt rows -> audit_log). Wallet
  refs are tx:<provider tx id> for a callback move,
  offset:<paying provider>:<provider tx id>:<shortfall id> for the debt
  offset a payout triggers, and collect:<idempotency key>:<shortfall id>
  for an admin collect. Provider tx ids under core-align: are refused;
  align writes its own there.
- Confirm terms: legs may change only their odds; with decimal odds the
  potential payout must move in the direction of the odds product.
- Sessions with a country blocklist; RG cooling-off or self-exclusion
  revokes live sessions through a retried job, audited in-transaction,
  that retries until the provider acknowledges each revoke; the
  reconcile cron retries any revoke still unacknowledged, least recently
  tried first, and revokes the live sessions of any player play
  eligibility refuses. An acknowledgement counts only for a revoke sent
  after core recorded the provider's open. The provider's session
  lookup refuses a restricted player.
- Reopen correction debit; any shortfall becomes sportsbook debt with
  a wallet withdrawal hold, collectable or write-off by an admin under a
  required idempotency key. Each shortfall is kept with its bet. A later
  win's offset and an admin collect both take them back oldest first,
  under the owing bet's provider and round and as the reopen's leg type,
  so RG loss nets it and a reopened void's stake still counts as wagered.
  A reopen asking more than the wallet paid its round is refused as
  invalid_amount and filed.
- Reconciliation findings, admin align-with-provider, and a reconcile
  cron for stale reserves and overdue accepted bets. Align applies the
  provider's answer only to the bet state it asked about.
- RG loss-limit re-evaluation on placed, settled, cancelled and
  reopened, and on every debt change. KYC is treated as for casino: no
  play-time check.
- Audit: each bet leg writes its audit row in its own transaction, with
  the player as actor. Every bet, debt, balance and restriction event
  goes to the outbox in its transaction; the RG eval and revoke
  consumers run once per event.
- Test provider plugin and three E2E suites (ledger, reconcile, routes)
  booted through bootE2e.

Contracts outside the module: LanguageTagSchema is shared by the
session locale and the casino category translation keys; it now also
accepts a one-character subtag that another subtag follows
(en-US-u-hc-h12).

Deploy workers before producers: older workers reject the new
sportsbook.rg-revoke queue and RG eval trigger values.
…roduct

Migration 0018 gave rakeback and the rank challenge product lists that
default to casino, live-casino and pvp. That default also landed on
ladders configured before the lists existed, so an operator who already
paid rakeback on sportsbook silently stopped doing so.

Migration 0019 sets both lists to empty (every product) on rows that
still hold 0018's default. A list an operator has changed is kept, so
0019 is safe to run after one was saved; a list saved as exactly the
default set is indistinguishable and is reset too. Ladders created
afterwards keep the narrow default. A rank challenge on an install with
no ladder settings row is out of scope here; its fallback lives with the
product lists.
The player's client navigates to the launch url the provider returns, and core passed it through
unchecked, so a javascript: or plain http url reached the player. Core now parses the provider's
session result against SportsbookProviderSessionSchema: a launch url that is not https takes the
same path as a refused issue, revoking the session at both ends and answering 503. The session
response schema is https-only too.
@okapitula
okapitula force-pushed the feat/sportsbook-module branch from 34224fc to e4e8a5e Compare October 26, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants