Conversation
zaxovaiko
added this pull request to stack #179
September 18, 2026 08:41
zaxovaiko
force-pushed
the
feat/promo-wallet-teardown
branch
from
September 18, 2026 08:48
162fab3 to
66924f0
Compare
zaxovaiko
force-pushed
the
feat/promo-wallet-teardown
branch
from
September 18, 2026 08:57
66924f0 to
d7c44db
Compare
Two bonus models were running side by side. Wallet's credits the money into `wallet_balance` and subtracts a proportional un-rolled share at withdrawal time; the grant engine keeps bonus funds out of the balance entirely and converts them once wagered. Two answers to "what is locked", two rollover engines, and a `debit()` with two bonus branches that the next reader would have consolidated into a bug. This removes the older one. Gifted money is a bonus, so a gift or a rain drop now creates a grant instead of crediting the real balance. The terms it is granted under live in the bonus module rather than being passed in by the wallet: the wallet's job is to report that a player was given money, and what that obliges them to do with it is the promo module's to decide. A grant with no offer behind it scores against a seeded `default` weight profile, which is reference data for the same reason the IAM roles are - a gift that cannot resolve a profile would be refused, and an operator should not have to remember to create one. That is a visible behaviour change and worth stating plainly: gift and rain money used to be spendable as real balance with a share locked at withdrawal, and is now bonus balance that has to be wagered before it converts. It is the same product rule expressed in the model the specification asks for. The withdrawal and swap paths lose their locked-share subtraction. Nothing replaces it: grant money never enters `wallet_balance`, so everything that row holds is the player's own. `debitWithdrawableBalance` and its lock formula are gone, and `amount >= requested` is a complete guard again. `promo.bonus.completed` replaces `wallet.bonus_rollover.completed` as the player notification, and the round path emits it after the transaction that moved the money commits. Also collapses the wallet-commands constructor's optional ports into one named object. It was already over the parameter limit and this change would have pushed it further; the call sites that made that refactor expensive are the ones this commit deletes.
Two ways a gift could still become withdrawable cash without the wagering it
was supposed to carry, both from a security pass over the teardown.
The gift arm was written as "if the port is bound, grant; otherwise fall
through", and falling through credits the real balance. The binding is
opportunistic - a consumer whose extension list omits the bonus module would
have had gift and rain money land instantly withdrawable, where the model this
replaces attached a requirement whether or not any promo module was loaded. A
wagering requirement an operator can switch off by not listing a plugin is not
a requirement, so a gift with no engine behind it now refuses.
The grant's outcome was discarded. The port can refuse - ineligible, offer
inactive, currency unsupported - and the credit reported success regardless: a
ledger row written, the sender debited, and nothing granted to anyone. It is
read now, and a refusal rolls the whole credit back.
`sourceRef` takes the caller's provider reference when there is one, so a gift
delivered twice resolves to the first grant through the existing idempotency
index. Without one there is no durable guard beyond the caller's transaction,
which is the same position the previous model was in and is worth saying rather
than implying.
`completedGrantIds: string[]` alongside a single `convertedAmount` became one
nullable `completed: { grantId, convertedAmount }`. One bet feeds exactly one
grant, and a list beside a single amount invites a caller to report that amount
for every grant in it.
…earing The consumer template still imported and called `seedBonusRolloverConfig`, which this branch deletes, so every freshly scaffolded app failed to build. `pnpm verify` does not typecheck `.tpl` files, so nothing caught it. It now seeds the default weight profile instead. That is not a rename: a grant with no offer behind it - a gift, a rain drop, a hand-issued bonus - resolves its wagering weights through that profile and is refused outright when it is missing, so a consumer that skipped it would find gifts failing at runtime rather than at boot. BF-592
zaxovaiko
force-pushed
the
feat/promo-wallet-teardown
branch
from
September 20, 2026 00:32
d7c44db to
6632724
Compare
zaxovaiko
marked this pull request as ready for review
September 20, 2026 09:49
zaxovaiko
requested review from
damianrzepka,
jakubfilinger-b,
klaudia-blazyczek-blurify,
marek-chmielowski-blurify,
mp-blurify and
okapitula
as code owners
September 20, 2026 09:49
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
Stacked on #190. Removes wallet's fungible
bonus_creditrollover model and moves chat gifts and rain onto bonus grants. Net negative diff. BF-592.Why
Two bonus models were running side by side on the same bet. Wallet's credits the money into
wallet_balanceand subtracts a proportional un-rolled share at withdrawal time; the grant engine keeps bonus funds out of the balance entirely and converts them once they are wagered.That is two answers to "what is locked", two rollover engines advancing on the same stake, and a
debit()with two bonus branches. The branches were genuinely disjoint - one readswallet_balance, the other the grant - but "genuinely disjoint, trust me" is exactly the shape of comment that precedes someone consolidating them into a bug.Gifted money is a bonus. A gift or a rain drop now creates a grant rather than crediting the real balance. The terms it is granted under live in the bonus module rather than being passed in by the wallet: the wallet's job is to report that a player was given money, and what that obliges them to do with it belongs to the module that owns bonus policy. A grant with no offer behind it scores against a seeded
defaultweight profile - reference data for the same reason the IAM roles are, since a gift that cannot resolve a profile would be refused and an operator should not have to remember to create one.This is a visible behaviour change and worth saying plainly. Gift and rain money used to be spendable as real balance with a share locked at withdrawal. It is now bonus balance that has to be wagered before it converts. Same product rule, expressed in the model the specification asks for.
Withdrawal and swap lose their locked-share subtraction, and nothing replaces it.
debitWithdrawableBalanceexisted only because the old model mixed the two kinds of money in one row. Grant money never enterswallet_balance, so everything that row holds is the player's own andamount >= requestedis a complete guard again. There is a test that gifts a player 500, then withdraws their entire 100 deposit and expects it to succeed.promo.bonus.completedreplaceswallet.bonus_rollover.completedas the player-facing notification, emitted after the transaction that moved the money commits.What is removed
Tables
wallet_bonus_creditandwallet_bonus_rollover_configwith their enums; thebonus-rollover-configIAM resource; threewallet.bonus_credit*audit actions; thewallet.bonus_rollover.completedevent; the player rollover-status route and the admin config route pair;createBonusCredit,applyBonusRolloverProgress,resolveRolloverMultiplier,debitWithdrawableBalance,readLockedBonusAmount,WalletDebitOutcome.completedBonusCredits; the rollover seed; and four test files covering the deleted model.Alternatives considered
Keeping both and deprecating later. The interim state is the problem, not the migration: every bet would advance two rollover engines, and a reviewer would have to hold both in their head to judge the money path.
Migrating existing
bonus_creditrows onto grants. Those rows hold money that is already insidewallet_balance, so carrying them across is a money-moving migration on live balances - debit each holder by their locked share and re-materialise it as bonus. Nothing here is in production, so the rows are dropped instead.Passing the gift terms in from the wallet, via platform config. Tried and reverted. It put a bonus policy decision in the module that has no business making one, and threaded configuration through a seam that exists to move money.
Risks
Dropping
wallet_bonus_creditreleases every outstanding locked share. The model being removed credited gift money intowallet_balanceand held the un-wagered part back only at withdrawal time, so any activewallet_bonus_creditrow is money that is already in the balance and is only locked by the code this PR deletes. The migration drops the table with no data migration, which means every unmet rollover requirement is cancelled at deploy - no ledger entry, no audit row, no event.That is a deliberate call, not an oversight: nothing here is in production and data loss on this table is accepted. If that ever stops being true, the fix is a data migration ordered before the drop that either moves each active row's unmet share onto a
promo_grantor forfeits it with an audit record.A gift now refuses when no bonus engine is bound. Previously the requirement was attached unconditionally; now it lives in the promo module, so a consumer whose extension list omits
bonusgets an error on gift and rain rather than instantly withdrawable money. That is the fail-closed direction and it is deliberate, but it is a new hard dependency for anyone running a trimmed edition.The default weight profile is seeded, not migrated. An environment that runs migrations without running the seeds has no
defaultprofile, and every gift or rain grant is refused until one exists. This follows the existing pattern for IAM roles and player tags, and it fails closed, but it is a deployment step rather than something the schema guarantees.A gift without a provider reference has no idempotency guard beyond the caller's transaction, since its
sourceRefis a freshly minted ledger row id. Callers that pass a stable reference - a chat message id, a rain draw id - get the grant's unique index. The model this replaces was equally unguarded.Stale notification rows.
wallet.bonus_rollover.completedrows already in thenotificationtable are dropped from list reads but still counted by the unread badge. Not backfilled, on the same "nothing in production" basis as the table drop.The gifting player is not on the audit record. The grant is audited as
system, and neither the grant port nor the wallet transaction carries the sender. The model this replaces had the same gap; closing it needs a field on the port.The default grant terms - one turnover, thirty days - are constants in the bonus module for now. They become offer configuration when the offer table lands, which is the next PR in this stack.