feat: wallet, rates, compliance and gamification engines (BF-568) - #222
Merged
Merged
Conversation
…failing the batch
WALLET_COMMANDS.credit had no way for a caller to say what wagering multiplier or expiry a gift/rain credit's underlying bonus grant should carry, so every such grant silently fell back to the bonus module's hardcoded default regardless of what an operator configured. Forward the terms BONUS_GRANTS.grant already accepts. Omitting it keeps today's behavior exactly.
Player-to-player transfers (a chat gift, a tip) are a generic in-app-transfer concept most operators building on the platform will want notifications for, same as the existing chat.rain.received type. Adds chat.tip.received, chat.gift.claimed and chat.gift.expired to the notification type enum, additive only.
An overlay that has decided a player earned an in-app notification had no supported way to create one - the service lived only behind the notifications plugin's own closure, with no seam a sibling module could reach without a banned deep dist import. Publishes the module's own service aggregation (already written, just unwired) at engagement/notifications, mirroring the existing engagement/contracts/notifications and engagement/schema/notifications subpaths.
Player self-service (RgSelfServiceService.upsertLimit, both the immediate write and the parked-increase path) and the admin reduce-only override (RgService.setPlayerLimit) now refuse a deposit/wager/loss limit change that would leave the daily/weekly/monthly bounds out of order for the same type, comparing through ExchangeRateReader when sibling periods differ in currency. A missing rate refuses the write rather than skipping the check. Limit removal is exempt - it only loosens ordering. New LimitOrderingViolationError (409/CONFLICT), wired into the compliance router for upsertLimit and setPlayerLimit. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…nus grant wager() early-returned before reaching WAGER_TRACKING whenever a player had no active bonus grant, so a real-money bet advanced nothing on that port - the rank ladder's lifetime wagering only moved for a player mid-bonus. It now reports the bet at its full stake on the no-grant path too. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…ing (BF-568) Adds a `cashback` wallet transaction type for operator-funded real-money credits with no wagering requirement, and a `realAmount` field on WagerTrackingArgs carrying the own-funds portion of a bet's stake so a downstream consumer (rank rakeback) can exclude bonus-funded stake. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…F-568) RakebackService credits a qualifying bet's own-money stake at the player's tier rakeback percentage plus any active streak boost, straight to real balance in the bet's own transaction - Confluence "Rank Bonuses" Scenario 3. Rank/streak wagering counters are unchanged. Adds `ranks.lookup`, a public batched rank-badge lookup by user id, and a `cash` streak reward kind that credits real money directly with no wagering requirement, replacing the `bonus`-with-0.01-multiplier workaround. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…lookup (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
… vocabulary (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…Args change (BF-568) Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…ANDS (BF-568) WALLET_COMMANDS' own factory resolves WAGER_TRACKING transitively through the bonus module's wagering service, so RakebackService resolving WALLET_COMMANDS eagerly at WAGER_TRACKING's own construction time threw "Circular dependency resolving token WALLET_COMMANDS" on boot. It now takes a getWallet thunk and defers the lookup to the first bet. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
Adds a race engine to promo/gamification: an operator-configured window of wagering volume, ranked, with a prize pool split across paid positions. Reuses the module's existing WAGER_TRACKING consumer, admin/audit, payout job, and leaderboard patterns. - Schema: promo_race, promo_race_wager, promo_race_payout, plus a new player.hideUsernameOnLeaderboards preference (pam/profile). - RaceService records real-money wagers into every open, eligible race and serves the player-facing leaderboard (masked usernames, own entry, amount to the next paid position). - RaceAdminService creates/updates a race, prospective-only, blocked once closed. - RacePayoutService closes a race once its window ends, freezes standings, pays every position idempotently, and emits promo.race.won per winner. - promo.race.won wired to an in-app + email notification. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…ith one winner per tier Each tier has a lifetime real-money wagering threshold; the first player to cross it wins its prize (cash, a physical item, or both), once. Claims are detected inline on the same wager-tracking path rank/rakeback/streak/race use, guarded against concurrent double-wins by a unique index on the claim's tier. Settlement (cash credit, win event) runs on a short payout tick, mirroring the rank level-up and streak milestone pattern. Adds an operator fulfilment queue for physical prizes and a prospective-only ladder editor.
…exposed claim row id MarkRankChallengeFulfilledInputSchema took a claimId the API's own RankChallengeClaimSchema never returns, so no caller could actually supply one. promo_rank_challenge_claim.tierId is unique per claim and is already on every claim/queue row the admin UI reads - fulfilment now keys off that instead.
promo.rankChallenge.won broke the event catalog's own namespacing convention (each dot-segment must be lowercase-kebab), caught by the existing generic contract test. Renamed to promo.rank-challenge.won across the emitter, the notification contract/plugin and the changeset describing the feature.
…usion The bonus plugin already forfeited every active grant the instant a player self-excluded or closed their account, but a cooling-off period - a lighter, time-boxed responsible-gambling control - left bonus funds live. Adds `cooling_off` to the forfeit-reason vocabulary and subscribes the same immediate forfeit path to `rg.cooling_off.activated`.
…589) GrantLifecycleService.forfeit() required an admin actor, so nothing but the admin route could take away one specific grant. A scheduled job (the Activity Bonus daily-wager check) needs exactly that: forfeit one grant by id with no admin session to assert. Make actor optional, reusing the system-actor path close() already supported for forfeitAllFor(). Adds the terms_breach forfeit reason for offer-terms breaches a job detects.
… (BF-589) A scheduled job in the consumer repo needs to forfeit one named grant with no admin session to assert (the shape forfeitAllFor cannot give it) and to credit a bonus off a computed net loss rather than a deposit. Adds BONUS_LIFECYCLE.forfeit as a thin command port over GrantLifecycleService.forfeit, and 'cashback' to BONUS_GRANT_SOURCES so that grant is not misfiled as 'deposit' or 'manual'. Also wires an email (bonusUnlocked) alongside the existing in-app promo.bonus.completed notification, matching the raceWon/rankChallengeWon pattern, and extends PromoOfferRulesSchema (jsonb, no migration) with two optional operator-tunable fields - freeSpins and periodDays - for offer mechanics core has no dedicated grant shape for yet. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
check:format was failing on pre-existing whitespace drift in two drizzle snapshot files from an earlier commit on this branch. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
zaxovaiko
requested review from
damianrzepka,
jakubfilinger-b,
klaudia-blazyczek-blurify,
marek-chmielowski-blurify,
mp-blurify and
okapitula
as code owners
September 25, 2026 10:52
…m (BF-400)
Adds createMultiplexedEventStreamGenerator to @openora/core/server,
folding several independent push-subscriptions (eg a set of per-user
realtime channels) into one SSE-servable async generator instead of
one connection per channel, tagging each event {channel, payload}.
A consumer opening several per-user streams from the same client can
now serve them over a single HTTP connection, which matters under
HTTP/1.1's per-origin connection cap.
Also re-exports each first-party module's per-user channel-name
function from its public server entrypoint (walletBalanceChannel,
kycStatusChannel, bonusBalanceChannel, notificationsChannel,
sessionEventsChannel/SessionEventsPush) - they already existed but
were only reachable from each module's internal router file, which a
consumer's own realtime route cannot import without a deep-import
boundary violation.
…r re-KYC handleDeposit summed deposits into player.currency and looked reverifyThresholds up by that same key, so a threshold configured under the platform's exchange-rate pivot (e.g. USD) only ever fired for players whose own wallet currency happened to match it. Price into resolveExchangeRatePivot(platformConfig.exchangeRate) instead, which is also the currency reverifyThresholds is configured against. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…d on credit Race prizes, rank-challenge cash tiers and streak cash rewards were credited in their own source currency (often USD) unconditionally, which could open a balance the player's wallet never otherwise holds. Price each cash credit into resolveWalletDefaultCurrency(platformConfig.wallet) before crediting (new priceForPayout helper); a missing rate throws so the settlement transaction rolls back and the payout job retries it on the next tick instead of crediting the wrong currency or losing the prize. The same rollback fixes payout status: a failed or unpriced credit previously still recorded the payout as granted and announced it. Now nothing is persisted or emitted until the credit inside the same transaction actually succeeds, so an absent payout row is the pending state and the job's existing retry loop picks it up again. Every race/rank-challenge/streak cash credit and rakeback credit now emits wallet.balance.changed after its transaction commits, following the pattern gaming.service already uses for bet/win. Rakeback's credit happens inside the bet's own wager-tracking transaction, so its result is threaded back up through WagerTrackingCommands -> BonusWagerOutcome -> WalletDebitOutcome for the caller to emit post-commit. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
…n, push notifications live The daily streak qualified on a wager's full stake instead of realAmount, so a bonus-funded bet advanced it - streak.service.ts now matches RaceService/RankChallengeService and counts only the player's own money. A ban (identity.user.deactivated) did not forfeit a player's active bonus grants, unlike self-exclusion, cooling-off and account closure. The bonus plugin now wires the same forfeitEverything path to it, with an 'admin' reason. NotificationsService.create() emits notifications.created regardless of which instance calls it, but only the notifications module's own dispatch jobs ever published the result onto the realtime channel - a caller with its own NotificationsService instance (an overlay reacting to an event outside domainEventSchemas) got no live push. The plugin now subscribes to notifications.created itself and re-reads + publishes the row, so every create() call gets the same realtime push. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A batch of platform changes across wallet, exchange rates, compliance, notifications, bonus and gamification (BF-568, BF-586, BF-589, BF-597):
NotificationsServiceexposed as a public subpath;cashbacktransaction type;Why
Alternatives considered
Risks
cashbackwallet transaction type: consumers that switch over transaction types need a case for it.