Skip to content

feat(promo): bonus domain foundation and wagering weights - #177

Open
zaxovaiko wants to merge 5 commits into
devfrom
feat/promo-foundation
Open

zaxovaiko wants to merge 5 commits into
devfrom
feat/promo-foundation

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 16, 2026

Copy link
Copy Markdown
Member

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_WAGERING is a sealed token. It carries the identity bonus-wagering-engine, which compliance/sealed.ts had 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 one ctx.provide call.

BONUS_WAGERING has two methods, wager and settle. 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. wager 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 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.context carries 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. WalletDebitOutcome gains bonusSpent, bonusBalance and completedGrantIds.

Wagering weights. promo_weight_profile and promo_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) on numeric(5,2). It had been a Zod refine that no write path called, comparing a numeric by 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_ENGINE as 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_weight instead 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 no nullsNotDistinct() on the index builder, hence the partial index.

Resolving the game id in this layer. gameId and categorySlug are optional on WagerContext because 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_WAGERING and WAGER_TRACKING are declared and unbound by design - their implementations are the next PRs in the stack and the gamification module respectively.

promo/gamification is a placeholder for another owner. Its generated router and public GET /gamification route 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 on dev.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread packages/core/src/contracts/adapters/bonus-wagering.ts Outdated
Comment thread packages/core/src/promo/bonus/contract/index.ts Outdated
Comment thread packages/core/src/promo/gamification/router/index.ts Outdated
Comment thread packages/core/src/promo/bonus/contract/index.ts
@zaxovaiko
zaxovaiko added this pull request to stack #179 September 16, 2026 17:34
@zaxovaiko zaxovaiko self-assigned this Sep 16, 2026

@klaudia-blazyczek-blurify klaudia-blazyczek-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.

One comment from me in addition to bot's CR

@klaudia-blazyczek-blurify klaudia-blazyczek-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.

I looked into #178 and deleted my comment because it is resolved there. Lgtm (apart from bot's review)

@zaxovaiko
zaxovaiko force-pushed the feat/promo-foundation branch from f1cc123 to 5f70926 Compare September 17, 2026 16:52
… 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.
@zaxovaiko
zaxovaiko marked this pull request as ready for review September 20, 2026 09:49
/** 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;

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.

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;

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.

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.

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.

2 participants