Repository navigation
feat: ledger prerequisites for a provider-settled sportsbook - #334
Conversation
c3e6505 to
9639646
Compare
9639646 to
a82a67b
Compare
jakubfilinger-b
left a comment
There was a problem hiding this comment.
Changes requested: a module-owned withdrawal hold can be committed after the approval flip and still be bypassed before the PSP call.
a82a67b to
89d223d
Compare
5173a03 to
e2ccc89
Compare
jakubfilinger-b
left a comment
There was a problem hiding this comment.
Changes requested: a withdrawal hold can still commit after the final hold check and before the PSP call.
8a5523a to
c863524
Compare
mp-blurify
left a comment
There was a problem hiding this comment.
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.
c863524 to
c082f1c
Compare
|
fd03e4b covers the threads,
|
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.
fd03e4b to
278f201
Compare
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.
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.
win_reversalledger type.WALLET_COMMANDS.correctionDebitcollects 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 inwallet_transaction.real_sharefor that cap.creditrefuseswin_reversal.wallet_withdrawal_hold, keyed by user and source) refuse player withdraw, admin approval and auto-approval while one exists.withdrawal_locksends back to the queue after the approval flip records and emitswallet.withdrawal.requeued.wallet.correction.debited,wallet.withdrawal_hold.placed,wallet.withdrawal_hold.released) in the caller's transaction, filed under the player.periodicMinimumWager.win_reversal.wireErrorResponse(status, body, { cause })from@openora/core/server.createApprenders 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_locktag 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 thrownORPCErrorcould not produce.These changes stand on their own and ship before the sportsbook module itself.
Diagram
Alternatives considered
withdrawal_locktag 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.OpenAPIHandlerto change error bodies. Rejected because only the handlercreateAppbuilds owns error rendering. A marked error keeps everything else on the standard envelope.Risks
Breaking:
WALLET_COMMANDSbinding must implementcorrectionDebit,placeWithdrawalHoldandreleaseWithdrawalHold.wallet_transaction_typegainswin_reversal.wallet.balance.changedcan carry it, and a subscriber that switches on the type must handle it. Thewallet.activityagent tool reports wins net of it.withdrawal_held. Admin withdrawal queue items gainwithdrawalHeld. A newwallet.withdrawal.requeuedevent reports an approved withdrawal that went back to the queue.insufficient_bonus, and a sportsbook stake no longer counts toward a grant's wagering.rakebackEligibleProductsandrankChallengeEligibleProducts, and now saves and returnsperiodicMinimumWager. 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.Migrations: wallet 0035 (enum value, hold table,
correction_requested_amountcolumn, round index onwallet_transaction), wallet 0036 (real_sharecolumn) and gamification 0018 (two product-list columns with defaults).Release note: 0035 builds the
(wallet_id, external_round_id)index onwallet_transactionwithoutCONCURRENTLY, because each migration file runs in a transaction. The build blocks writes towallet_transactionuntil 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.