Skip to content

refactor(wallet): move chat gifts and rain onto bonus grants - #191

Open
zaxovaiko wants to merge 4 commits into
feat/promo-wageringfrom
feat/promo-wallet-teardown
Open

zaxovaiko wants to merge 4 commits into
feat/promo-wageringfrom
feat/promo-wallet-teardown

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #190. Removes wallet's fungible bonus_credit rollover 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_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 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 reads wallet_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 default weight 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. debitWithdrawableBalance existed only because the old model mixed the two kinds of money in one row. Grant money never enters wallet_balance, so everything that row holds is the player's own and amount >= requested is 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.completed replaces wallet.bonus_rollover.completed as the player-facing notification, emitted after the transaction that moved the money commits.

What is removed

Tables wallet_bonus_credit and wallet_bonus_rollover_config with their enums; the bonus-rollover-config IAM resource; three wallet.bonus_credit* audit actions; the wallet.bonus_rollover.completed event; 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_credit rows onto grants. Those rows hold money that is already inside wallet_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_credit releases every outstanding locked share. The model being removed credited gift money into wallet_balance and held the un-wagered part back only at withdrawal time, so any active wallet_bonus_credit row 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_grant or 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 bonus gets 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 default profile, 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 sourceRef is 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.completed rows already in the notification table 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.

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
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