Skip to content

feat: wallet, rates, compliance and gamification engines (BF-568) - #222

Merged
zaxovaiko merged 28 commits into
devfrom
fix/BF-0/qa-pass-fixes
Sep 25, 2026
Merged

zaxovaiko merged 28 commits into
devfrom
fix/BF-0/qa-pass-fixes

Conversation

@zaxovaiko

Copy link
Copy Markdown
Member

Summary

A batch of platform changes across wallet, exchange rates, compliance, notifications, bonus and gamification (BF-568, BF-586, BF-589, BF-597):

  • rates batch answers an unoffered currency with a null quote instead of failing the batch;
  • a saved display currency that is no longer offered falls back to the default resolution;
  • wallet credit accepts optional bonus grant terms;
  • player-to-player transfer notification types, and NotificationsService exposed as a public subpath;
  • daily <= weekly <= monthly ordering enforced on responsible-gambling limits, compared cross-currency;
  • a real-money wager counts toward wager tracking even with no active bonus grant;
  • rank rakeback is paid per bet on the own-money part of the stake, via a new cashback transaction type;
  • a cash (no-wagering) streak reward kind, and a batch public rank lookup;
  • a streak engine;
  • a wager challenge (leaderboard race) engine with settlement;
  • Rank Challenge: a race-to-threshold ladder with one winner per tier, cash payout and physical-prize fulfilment;
  • bonus forfeits on cooling-off, and a system actor can forfeit a single named grant;
  • a cashback grant source, free-spins and period fields on offers, and a bonus-unlocked email.

Why

  • A single retired currency in a caller's history blanked every rate in the batch.
  • Ordering violations between limit periods were accepted, so a weekly limit below the daily one never bit.
  • Wagers without a bonus grant never reached wager tracking, so rank progress stalled for most players.
  • Rakeback and the gamification mechanics were stored and displayed but never paid.
  • Operators need the gamification mechanics as generic, config-driven engines rather than per-deployment code.

Alternatives considered

  • Keeping the gamification engines downstream: rejected, because they are generic, configured per operator, and share the bonus and wallet seams.
  • Accruing rakeback into a claimable balance: rejected, because the spec credits it directly per bet.

Risks

  • New tables and enum values: migrations are additive only.
  • New cashback wallet transaction type: consumers that switch over transaction types need a case for it.
  • Rakeback is credited as real money per bet: operators should review their configured rates before enabling it.
  • Deferred: free-spins entitlements are recorded, but crediting them needs a game-provider free-spins integration.

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
…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
…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
@zaxovaiko
zaxovaiko merged commit 66d6366 into dev Sep 25, 2026
2 checks passed
@zaxovaiko
zaxovaiko deleted the fix/BF-0/qa-pass-fixes branch September 25, 2026 13:44
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.

1 participant