Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1cc1232f5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
klaudia-blazyczek-blurify
left a comment
There was a problem hiding this comment.
One comment from me in addition to bot's CR
There was a problem hiding this comment.
I looked into #178 and deleted my comment because it is resolved there. Lgtm (apart from bot's review)
f1cc123 to
5f70926
Compare
… seams
The port was declared as a single `contribute(tx, {amount, context})`, which
only describes the bet half of the story. A bonus that can fund a stake has to
be told about the win as well, or a bonus-funded win credits entirely to the
real balance and the grant it came from can never be forfeited with its
winnings attached.
`wager` now carries the full stake, the part of it the real balance could not
cover, and the provider round. `settle` handles the win and the void, finding
the funding grant by that round. Both are placed below the wallet's duplicate
provider-reference guard, so a replayed wager cannot reach either - the dedupe
is structural rather than a second check that can drift out of sync.
`WalletDebitOutcome` gains `bonusSpent` and `completedGrantIds`, the balance
change signal gains a `bonus` reason, and the audit action union gains the
promo literals the lifecycle and configuration writes will use.
…e database Review of the stack turned up three things worth the churn before anything builds on it. The wagering port was declared with `createToken` while its own docstring said the implementation was sealed. It was not: an overlay listed after the bonus plugin could have rebound weight resolution, grant attribution and the completion threshold with one `ctx.provide` call. It is now a real sealed token under the `bonus-wagering-engine` identity, which `compliance/sealed.ts` had been holding as an untyped placeholder that nothing could ever bind. One identity instead of two. The weight bound lived in a Zod refine that no write path called, and compared a `numeric` value by parsing it to a JavaScript float. A weight above 100 credits a bet for more than it was worth and releases a bonus early, which cannot be taken back, so the bound belongs in the database where no caller can route around it. The column is `numeric(5,2)` - a percentage was never money and does not need eighteen decimal places - with a range CHECK, and the schema that describes it now compares exactly. The promo value sets existed three times: a hand-typed union on the grant port, two inline enums in the events contract, and the tuples in the module. They now live once in the isomorphic contracts zone, which is the only place all three callers can reach. Also removed: the scaffolded `GET /gamification` route, which was mounted, unauthenticated and returning a placeholder table; the generator's comment boilerplate restating lint rules; and `BonusService`, which duplicated the shared weight resolution and was bound to nothing. Indexes are named per the database standard, `updated_at` is actually bumped now, and the promo tokens are back in the core token catalog, having been dropped in a rebase.
| /** Raw vendor game identifier, unresolved. Persisted so a backfill is possible. */ | ||
| providerGameKey?: string; | ||
| /** Vendor product bucket, e.g. 'casino', 'live-casino', 'sportsbook', 'pvp'. */ | ||
| product?: string; |
There was a problem hiding this comment.
product cannot be optional for a qualifying wager. resolveContributionPercent() skips the missing scoped fields and then matches the profile default, so { provider: "x" } can advance rollover for an unknown, sportsbook, or PvP wager. Require product, or return 0 before the default fallback when it is missing.
| fromBonus: string; | ||
| context: WagerContext; | ||
| /** Provider round this bet belongs to. `settle` finds the funding grant by it. */ | ||
| externalRoundId?: string; |
There was a problem hiding this comment.
externalRoundId cannot be optional when fromBonus is positive. wager needs it to link the bonus-funded stake, while settle requires it to find that link. A permitted call without it can debit bonus funds, then credit a win or reversal entirely to the real balance. Require a stable round id for bonus-funded wagers, or persist a fallback correlation key.
Summary
First of a stacked series (#177 -> #178 -> #189). The shared setup both sides of the bonus work build on: two scaffolded modules, three command ports, the wager context, the seven
promo.*domain events, and the wagering weight tables with their resolution. BF-592.Why
The module generator writes into three shared places (
extensions.config.ts, the domain barrel, the package export map), and neither module compiles without the port interfaces the other side calls. Generating both from one hand in one commit is what stops two people conflicting on all three files, and it is why this lands before anyone starts.Three command ports, not domain events.
BONUS_GRANTS- a module that decided a player earned a bonus calls this on its own transaction handle.BONUS_WAGERING- wallet calls this from inside its debit and credit transactions.WAGER_TRACKING- the bonus engine calls gamification once it has resolved a bet's weight.The bus emits post-commit and best-effort. A dropped message here is a player's money or their standing in a competition that pays cash, so all three are synchronous ports taking the caller's
tx.BONUS_WAGERINGis a sealed token. It carries the identitybonus-wagering-engine, whichcompliance/sealed.tshad been holding as an untyped placeholder nothing could ever bind. Weight resolution, grant attribution and the completion threshold are regulated arithmetic; an operator configures the terms, never the formula. Before this it was an ordinary token and any overlay loaded after the bonus plugin could have rebound it with onectx.providecall.BONUS_WAGERINGhas two methods,wagerandsettle. A port that only hears about the bet cannot attribute the win: a bonus-funded stake that wins has to return to the grant that funded it, or the grant can never be forfeited with its winnings attached, which is what the expiry rule requires.wagercarries the full stake, the part of it the real balance could not cover, and the provider round;settlehandles the win and the void by looking that round up. Both sit below the wallet's duplicate-provider-reference guard, so a replayed wager cannot reach either - the dedupe is structural rather than a second check that drifts.WalletDebitArgs.contextcarries the provider, the raw vendor game key, the product, and the resolved game id and category. Nothing passes it yet; the aggregator seam that will is in the consumer repository.WalletDebitOutcomegainsbonusSpent,bonusBalanceandcompletedGrantIds.Wagering weights.
promo_weight_profileandpromo_weight, plus the resolution the engine calls. A bet resolves game, then category, then product, then the profile default, and scores at zero when nothing matches. Scoring at zero is the deliberate direction to fail in: the opposite mistake releases a bonus early and cannot be taken back. Arithmetic truncates toward zero for the same reason.The weight bound is a database
CHECK (0..100)onnumeric(5,2). It had been a Zod refine that no write path called, comparing anumericby parsing it to a JavaScript float. A weight above 100 credits a bet for more than it was worth, so the bound belongs where no caller can route around it - and a percentage was never money and does not need eighteen decimal places.Alternatives considered
A domain event instead of
WAGER_TRACKING. Tempting - gamification subscribes and the two modules never reference each other. Rejected because the bus is best-effort and post-commit, so a lost event is lost race progress in a competition with cash prizes. The optional-port pattern already exists here: wallet resolves the tag evaluation port optionally, so a missing module does not break boot.Keeping
BONUS_WAGERING_ENGINEas a separate sealed identity. Two sealed symbols for one thing, one of them untyped and unbindable. The port is now the identity.One unique index on
promo_weightinstead of two. The second is partial,on(profile_id) where scope = 'default'. Postgres does not treat two nulls as duplicates, so without it a profile could hold two default rows and resolution would depend on row order. Drizzle 0.45 has nonullsNotDistinct()on the index builder, hence the partial index.Resolving the game id in this layer.
gameIdandcategorySlugare optional onWagerContextbecause an aggregator without a synced catalogue can only name the product - and the product dimension is what a "casino only, PvP and sportsbook excluded" rule actually needs. The raw vendor key is persisted so the finer rows can be backfilled once a catalogue lands.Risks
The module registers no route yet and its plugin binds nothing, so nothing in this PR is reachable at runtime.
BONUS_WAGERINGandWAGER_TRACKINGare declared and unbound by design - their implementations are the next PRs in the stack and the gamification module respectively.promo/gamificationis a placeholder for another owner. Its generated router and publicGET /gamificationroute are removed here: the route was mounted, unauthenticated, and returned an empty scaffold table, and it would have accumulated per-player streak and rank data. The module keeps its migration and plugin so the shared wiring does not have to be redone.This stack introduces a second bonus model alongside wallet's
bonus_credit/wallet_bonus_rollover_config. They are not reconciled here; the next PR after #189 removes the old one and moves chat gifts and rain onto grants. Until then both exist ondev.