Skip to content

feat: ledger prerequisites for a provider-settled sportsbook - #334

Merged
zaxovaiko merged 11 commits into
devfrom
feat/sportsbook-prerequisites
Oct 31, 2026
Merged

zaxovaiko merged 11 commits into
devfrom
feat/sportsbook-prerequisites

Conversation

@okapitula

@okapitula okapitula commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR ships the ledger prerequisites in existing modules for a vendor-neutral sportsbook whose provider settles bets. They cover wallet, bonus, gamification, compliance and analytics. ADR-0046 (Accepted) records the seam: the operator's overlay owns the provider's wire protocol, and core owns one ledger port that the sportsbook module ships in a follow-up PR. It also lets an overlay route answer with its own error body, so a provider callback can return the provider's wire error.

  • Wallet
    • A win_reversal ledger type.
    • WALLET_COMMANDS.correctionDebit collects min(balance, amount) and reports the shortfall. Its provider reference must name its round, and it never takes back more than that round paid into the real balance (WalletCorrectionExceedsRoundError). A bonus-funded win or stake reversal records its real share in wallet_transaction.real_share for that cap.
    • A correction writes its ledger row even when it collects nothing, so a replay returns the first result and one asking a different amount or round is refused.
    • A plain credit refuses win_reversal.
    • Module-owned withdrawal holds (wallet_withdrawal_hold, keyed by user and source) refuse player withdraw, admin approval and auto-approval while one exists.
    • A withdrawal a hold or withdrawal_lock sends back to the queue after the approval flip records and emits wallet.withdrawal.requeued.
    • Correction debits and hold changes write audit rows (wallet.correction.debited, wallet.withdrawal_hold.placed, wallet.withdrawal_hold.released) in the caller's transaction, filed under the player.
  • Bonus: sportsbook stakes are real-money only and never touch a grant.
  • Gamification: rakeback and the rank challenge get their own product lists, defaulting to casino, live-casino and pvp. The rank config now saves and returns periodicMinimumWager.
  • Compliance: RG wager and loss spend read the wallet ledger across products, netted per round. A win reversal counts in the window it is collected in, and RG standing nets reversals off the leg they reverse.
  • Analytics: GGR and NGR add back win_reversal.
  • Server: wireErrorResponse(status, body, { cause }) from @openora/core/server. createApp renders the carried body as is, and every other error keeps the oRPC envelope.

Why

A provider can roll back a settled bet after the player has withdrawn the win. Core had no ledger type for taking a win back, and no debit that collects what it can without overdrawing. It also had no withdrawal block that a module can place atomically with its own write; the withdrawal_lock tag is an admin-owned investigation hold. Bonus, rakeback and the rank challenge would have counted sportsbook stakes by default. RG spend counted casino rounds only, so sportsbook stakes were invisible to wager limits. A provider's callback protocol also needs its own error bodies, which a thrown ORPCError could not produce.

These changes stand on their own and ship before the sportsbook module itself.

Diagram

sequenceDiagram
  participant M as Sportsbook module (later PR)
  participant W as WALLET_COMMANDS
  participant DB as Wallet tables
  participant P as Player withdraw / admin approval
  M->>W: correctionDebit(win_reversal, amount, round) in caller tx
  W->>DB: lock wallet row FOR NO KEY UPDATE
  W->>DB: refuse more than the round paid
  W->>DB: collect min(balance, amount), no overdraft
  W->>DB: ledger row (asked and collected) + audit row
  W-->>M: collected, shortfall
  M->>W: placeWithdrawalHold(user, source) in same tx
  W->>DB: insert wallet_withdrawal_hold + audit row
  P->>DB: withdraw / approve reads holds under wallet row lock
  DB-->>P: 409 withdrawal_held
  M->>W: releaseWithdrawalHold once debt is settled
Loading

Alternatives considered

  • Use the shared withdrawal_lock tag for debt holds. Rejected because that tag is a sticky admin investigation hold. A debt hold has to be placed and released atomically with the debt row, by the module that owns the debt.
  • A multi-bound "may this player withdraw" port. Rejected in favour of a wallet-owned hold table. The withdraw path then makes no cross-module call, and no port needs several bindings.
  • Refuse a correction debit on a short balance. Rejected because a rollback after a withdrawal would then fail. The debit collects what is there and reports the rest as debt for the caller to track.
  • Let an overlay build its own OpenAPIHandler to change error bodies. Rejected because only the handler createApp builds owns error rendering. A marked error keeps everything else on the standard envelope.

Risks

Breaking:

  • A custom WALLET_COMMANDS binding must implement correctionDebit, placeWithdrawalHold and releaseWithdrawalHold.
  • wallet_transaction_type gains win_reversal. wallet.balance.changed can carry it, and a subscriber that switches on the type must handle it. The wallet.activity agent tool reports wins net of it.
  • Player withdraw and admin approval can answer 409 withdrawal_held. Admin withdrawal queue items gain withdrawalHeld. A new wallet.withdrawal.requeued event reports an approved withdrawal that went back to the queue.
  • A sportsbook stake that needs bonus funds is refused with insufficient_bonus, and a sportsbook stake no longer counts toward a grant's wagering.
  • The rank config gains rakebackEligibleProducts and rankChallengeEligibleProducts, and now saves and returns periodicMinimumWager. A save that leaves one out keeps the stored value. Gamification 0018 defaults both lists to casino, live-casino and pvp, so an overlay that already records sportsbook wagers stops earning rakeback and rank challenge progress on them until the operator adds sportsbook to the lists.
  • RG wager and loss spend come from the wallet ledger across products, pvp included, instead of casino rounds. A player's measured spend can rise on deploy.

Migrations: wallet 0035 (enum value, hold table, correction_requested_amount column, round index on wallet_transaction), wallet 0036 (real_share column) and gamification 0018 (two product-list columns with defaults).

Release note: 0035 builds the (wallet_id, external_round_id) index on wallet_transaction without CONCURRENTLY, because each migration file runs in a transaction. The build blocks writes to wallet_transaction until it finishes, so an operator with a large ledger should run this upgrade in a quiet window.

Deferred: the RG loss limit stays unenforced (ADR-0046, with a pointer from ADR-0036). GGR and RG spend are cash-basis for a reopened bet.

@jakubfilinger-b jakubfilinger-b left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: a module-owned withdrawal hold can be committed after the approval flip and still be bypassed before the PSP call.

@okapitula
okapitula force-pushed the feat/sportsbook-prerequisites branch from a82a67b to 89d223d Compare October 22, 2026 15:41
@okapitula
okapitula force-pushed the feat/sportsbook-prerequisites branch 2 times, most recently from 5173a03 to e2ccc89 Compare October 26, 2026 09:11

@jakubfilinger-b jakubfilinger-b left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: a withdrawal hold can still commit after the final hold check and before the PSP call.

@okapitula
okapitula force-pushed the feat/sportsbook-prerequisites branch 4 times, most recently from 8a5523a to c863524 Compare October 28, 2026 13:04

@mp-blurify mp-blurify left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid groundwork: the correction debit's locking, idempotency and no-overdraft collection hold up, holds are only reachable through the port, wireErrorResponse is tightly scoped, and the migrations are additive. A few things need attention before merge. The main one is that a withdrawal put back in the queue by a hold leaves no audit trail. Also, origin/dev now has readPlayByWindow in packages/core/src/compliance/service/rg-standing.service.ts (added after this branch's base), which sums win but not win_reversal. After merge a reversed win would still show as a win in RG standing, while RG spend from this PR nets it. Git won't flag this as a conflict, so please rebase and net win_reversal there too, ideally through one shared definition of the play legs. Smaller notes: the correction and hold audit rows are filed under the transaction and hold rather than the player, so a player-scoped audit query won't show them. Migration 0035 builds the wallet_transaction round index without CONCURRENTLY, which is worth a release note for operators with a large ledger. The per-bet RG wager check now groups every round in the window under the wallet lock, so an EXPLAIN on opened_before for a long monthly window would be reassuring. The first commit message says gamification 0017, but the file is 0018.

@okapitula

Copy link
Copy Markdown
Collaborator Author

fd03e4b covers the threads, readPlayByWindow netting win_reversal through the shared leg list, and the correction and hold audit rows, which are now filed under the player.

  • Migration 0035: each migration file runs in a transaction, so CONCURRENTLY can't go in it. The PR description now has a release note telling operators with a large ledger to run the upgrade in a quiet window.
  • opened_before: on 1.67M ledger rows, the query reads a 30-day window in about 34 ms for a player with 7k legs, and about 430 ms for one with 86k legs over 60k rounds. opened_before is one index probe per netting round, a small share of that. Most of the time goes to scanning and grouping the window's legs, which grows linearly with them.
  • The 0017/0018 slip in the first commit message goes away when the branch is squashed.

Prepares wallet, bonus, gamification, compliance and analytics for a
vendor-neutral sportsbook module, recorded in ADR-0046.

- Wallet: a `win_reversal` ledger type; `WALLET_COMMANDS.correctionDebit`
  collects min(balance, amount), reports the shortfall and is idempotent on
  its provider reference, which must name its round; module-owned withdrawal holds refuse withdraw,
  admin approval and auto-approval while one exists, and approval reads
  them under a wallet row lock that waits for a hold still committing.
  A real-money credit, a correction and a hold lock the wallet row
  FOR NO KEY UPDATE first, so they compose in one transaction without
  blocking another transaction's ledger insert.
- Bonus: sportsbook stakes are real-money only and never touch a grant.
- Gamification: rakeback and the rank challenge get their own product
  lists, defaulting to casino, live-casino and pvp.
- Compliance: RG wager and loss spend read the wallet ledger across
  products, netted per round, with an index on the wallet's round legs.
- Analytics: GGR and NGR add back `win_reversal`.
- Sportsbook follows casino for KYC: no check at play time.

Breaking:
- A custom `WALLET_COMMANDS` binding must implement `correctionDebit`,
  `placeWithdrawalHold` and `releaseWithdrawalHold`.
- The wallet transaction type enum gains `win_reversal`;
  `wallet.balance.changed` can carry it, and the `wallet.activity` agent
  tool reports wins net of it.
- Player withdraw and admin approval can answer 409 `withdrawal_held`;
  admin withdrawal queue items gain `withdrawalHeld`.
- A sportsbook stake that needs bonus funds is refused with
  `insufficient_bonus`, and a sportsbook stake no longer counts toward a
  grant's wagering.
- The rank config gains `rakebackEligibleProducts` and
  `rankChallengeEligibleProducts`, and now saves and returns
  `periodicMinimumWager`. A save that leaves one out keeps the stored
  value. Gamification 0017 defaults both
  lists to casino, live-casino and pvp, and the rank challenge counts the
  same default without a settings row, so an overlay that already records
  sportsbook wagers stops earning rakeback and rank challenge progress on
  them until the operator adds sportsbook to the lists.
- RG wager and loss spend come from the wallet ledger across products,
  pvp included, instead of casino rounds.

Migrations: wallet 0035, gamification 0017.
An overlay implementing a vendor's inbound callback protocol must answer
some requests with the vendor's own error body, e.g. a 400 with a vendor
code. A thrown ORPCError always rendered oRPC's error envelope, and only
the OpenAPIHandler that createApp builds can change that.

- `wireErrorResponse(status, body, { cause })` from
  `@openora/core/server` builds an ORPCError carrying a JSON body; the
  response has that status (400-599) and exactly that body.
- createApp renders the carried body through
  `customErrorResponseBodyEncoder`; every other error keeps the envelope.
- A marked 5xx is reported only through its `cause`, so a reply the route
  chose is not logged as unhandled while an underlying fault still is.
Compare the ledger type and direction without text casts, and replace the
per-round lateral with one grouped pass over the window's legs plus a single
semi-join for rounds with a bet placed before the window.
Every correction debit now writes its ledger row, a zero collection included, and stores the
amount it asked for. A retry returns the first result and one asking a different amount is
refused, so a zero first collection can no longer be collected again.
Under the wallet lock, a payout reversal is refused above the round's win credits less its
earlier reversals, and a stake correction above its stake reversals less its earlier
corrections, counting what was collected so a debt offset of the uncollected rest still fits.
A player with no wallet is refused the same way.
placeWithdrawalHold and releaseWithdrawalHold write wallet.withdrawal_hold.placed and
wallet.withdrawal_hold.released in the caller's transaction when the hold changes. Placing the
same reason again writes nothing.
Every money mutation needs an audit record joining its transaction (docs/standards/audit.md). Each row a correction debit writes, a zero collection included, records wallet.correction.debited against that ledger row with the balance before and after, the requested, collected and shortfall amounts, and the provider reference. A replay or a refused call writes nothing.
A hold committing after the processing flip but before the PSP call reached the vendor. Approval and auto-approval now re-read holds alongside the lock once the flip has committed and put the row back as queued if one landed.
The post-flip hold re-read was unlocked, so a hold committing after it and before the PSP call still reached the vendor. Dispatch now re-checks holds under a share lock on the wallet row, in the same transaction as the provider stamp: a hold either commits first and puts the row back as queued, or waits for the stamp and applies from the next withdrawal. The post-flip re-read keeps only the risk-tag lock.
…share

A withdrawal a hold or withdrawal_lock sent back to the queue after the approval flip left no trace. It now records wallet.withdrawal.requeued and emits it in the same transaction, with the reason.

A correction replay with the same reference for another round returned the first round's result; the replay check now compares the round id. A correction on a bonus-funded round could take back the full win although only its real share reached the real balance. credit() now stores that share in wallet_transaction.real_share (migration 0036) and the round cap sums it.

RG spend counts a win reversal collected in a later window than its bet, and RG standing takes reversals off the metric they reverse. Both read one shared list of play legs.

Correction and hold audit rows are filed under the player, keeping the row id in the entry.
@okapitula
okapitula force-pushed the feat/sportsbook-prerequisites branch from fd03e4b to 278f201 Compare October 30, 2026 12:32

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups are in the #344 comment.

@zaxovaiko
zaxovaiko merged commit 144f93b into dev Oct 31, 2026
7 checks passed
@zaxovaiko
zaxovaiko deleted the feat/sportsbook-prerequisites branch October 31, 2026 01:24
zaxovaiko added a commit that referenced this pull request Oct 31, 2026
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

```mermaid
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
```

## 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.
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.

6 participants