Skip to content

feat(promo): bet-time bonus consumption and proportional win settlement - #190

Draft
zaxovaiko wants to merge 2 commits into
feat/promo-grant-ledgerfrom
feat/promo-wagering
Draft

zaxovaiko wants to merge 2 commits into
feat/promo-grant-ledgerfrom
feat/promo-wagering

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #189. The bet-time half of the engine: a stake decides which balance pays it, wagering advances on exactly one grant, a met requirement converts to real money, and a win or a void settles back in the proportion the stake was paid in. BF-592.

Why

This is the part that moves a player's money, so the reasoning is written out rather than implied.

The split, and where it sits. debit() now takes the real balance first and the bonus only for what it could not cover - the operator's stated order. The engine call sits below the wallet's existing duplicate-provider-reference guard, which is the whole dedupe story: a replayed wager returns at that guard and can never reach the code that spends bonus funds or advances progress. A second check inside the engine would be a copy that drifts out of sync with the first. There is a test that sends the same providerRef twice and asserts one contribution, one spend, one ledger entry.

One bet feeds exactly one grant. Attribution is a single locked read: the active grant in the bet's own currency, earliest expiry first, with a stable tiebreak so two concurrent bets take the lock in the same order. The bonus spend comes from that same grant - there is no waterfall across several. That is what keeps a win attributable: a stake drawn from three grants would have to be split three ways on the way back, for a case the product rule says cannot happen.

A grant in another currency does not participate. No implicit conversion, no cross-currency scoring. Failing closed here costs a player nothing they were promised; the opposite mistake releases a bonus early and cannot be taken back.

The conditional update is the concurrency guard. Bonus is spent with UPDATE ... WHERE bonus_balance >= $amount AND status = 'active'; zero rows means another bet got there first. Progress advances with LEAST(required, progress + weighted) and flips the status in the same statement, so the cap and the completion decision cannot disagree. A test fires twenty concurrent bets against a balance that covers ten and asserts exactly ten succeed, the balance lands on zero and never below, and the ledger still sums to it.

The engine never calls back into the wallet. A completed grant is reported as convertedAmount; the wallet, which already holds the transaction, performs the credit and writes the wallet_transaction of type bonus. Conversion happens only at bet time - expiry and forfeit convert nothing, and a win does not complete a requirement - so there is exactly one conversion site and no dependency cycle between the two modules.

The win path finds its grant through the ledger. One indexed read of the round's stake entries gives the bonus and real halves of the stake; the win splits in that proportion, truncating, so a rounding remainder lands on the real balance rather than the bonus one. A round with no bonus stake finds no rows and credits normally.

A voided round returns the bonus stake and takes back the progress it bought. Money back with progress left standing is free wagering bought by a rollback.

wallet_transaction still records the full amount of every bet and every win. The split lives only in promo_grant_entry, so gross-flow reporting stays honest.

A defect the end-to-end pass turned up. A stake paid entirely from bonus funds reached debitWalletBalance for zero, matched no row, and was reported as refused - after the bonus had already been spent. The wallet said no while the grant had already paid. A stake with no real part now touches no real balance at all.

What the review pass changed

Three findings from the security pass, all of them paths that released money the terms had not:

  • The in-core bet path never named its round. startRound debited before inserting game_round, so no round id reached the engine, and endRound credited the win without one. Every bonus-funded win on the only bet path core ships was therefore credited to the real balance, bypassing the requirement outright. The round id is now minted before the debit and carried on both sides, with the game context the weighting needs.
  • settle() failed open. A movement the grant refused looked exactly like a round that drew no bonus, and both credited the full amount as real. A win on a completed grant does belong to the real balance - its funds converted at completion - but a reversal does not, and that case now stops with a named error instead of paying out.
  • The conversion was unaudited. The single movement that turns a bonus into withdrawable money wrote a ledger row and nothing else. It now writes an audit record with the balance before and after and the grants that completed, on the same transaction.

Narrower, from the same pass: a round drawing on several grants splits across all of them in proportion rather than taking whichever row Postgres returned first; a partial void reverses only the progress the returned part bought, and a repeated reversal callback refunds nothing; and the balance a debit decides on is read under a row lock, because the wallet lock above it does not cover wallet_balance.

Alternatives considered

Spending across several grants when the first cannot cover the stake. Rejected: it makes a win unattributable and multiplies every downstream rule - the max-win cap, the forfeit-takes-winnings rule - by the number of grants a stake happened to touch. A stake the chosen grant cannot cover is refused instead.

Publishing a domain event and letting the bonus module subscribe. The bus is post-commit and best-effort; a lost message is lost wagering progress on a player's money.

Reversing a void by writing a compensating entry only. The progress has to come back off the grant as well, or a provider that voids rounds is a wagering generator.

Enforcing max_bet here. Deferred, deliberately: it is a field of an offer, and there is no offer table yet. It belongs with the configuration surface that sets it, enforced in this same transaction against the terms snapshot.

Risks

Cross-currency wagering, a conversion cap on winnings, and whether a cash bet should still count once bonus funds are spent are all open product questions. The defaults here are: no cross-currency participation, no cap, and the full stake counts. Each is a field on the terms snapshot away from being configurable, and terms is jsonb, so none of them needs a migration.

The wallet-commands constructor is now at five positional parameters and should take a named object for its optional ports; that refactor touches call sites the next PR deletes, so it rides with the teardown rather than here.

Wallet's older bonus_credit rollover model is still present and still runs on the same bet. The two are disjoint - one reads wallet_balance, the other the grant - but debit() now has two bonus branches, which is exactly one more than should exist. The next PR removes the old one.

The bet half and the win half land together. Separately, the branch would sit
in a state where a bonus-funded stake wins entirely into the real balance,
which is a money bug rather than an unfinished feature.

`debit()` takes the real balance first and the bonus for whatever it could not
cover, and calls the engine below the duplicate-provider-reference guard that
was already there. That placement is the whole dedupe story: a replayed wager
returns at the guard and never reaches the code that spends bonus funds or
advances progress, so a provider retry cannot buy free wagering. A second guard
inside the engine would be a copy that drifts.

Attribution is one locked read - the active grant in the bet's own currency,
earliest expiry first, with a stable tiebreak so two concurrent bets take the
lock in the same order. The bonus spend comes from that same grant with no
waterfall, which is what keeps a win attributable to one grant. A grant held in
another currency does not participate at all.

The conditional `UPDATE ... WHERE bonus_balance >= $amount` is the concurrency
guard, and progress advances with `LEAST(required, progress + weighted)` while
flipping the status in the same statement, so the cap and the completion
decision cannot disagree.

The engine reports what a completed grant owes rather than crediting it. The
wallet already holds the transaction and performs the credit itself, so the two
modules do not end up depending on each other in both directions. Conversion
only happens at bet time, so there is exactly one conversion site.

A win finds its funding grant through the round's ledger entries and splits in
the proportion the stake was paid in, truncating toward the real balance. A
voided round returns the bonus stake and takes back the progress it bought -
money back with progress standing is free wagering bought by a rollback.

Writing the end-to-end pass turned up a defect worth naming: a stake paid
entirely from bonus funds reached `debitWalletBalance` for zero, matched no
row, and was reported as refused - after the bonus had already been spent. A
stake with no real part now touches no real balance.

The older fungible rollover model still runs alongside this one on the same
bet. It reads `wallet_balance`, which grant money never enters, so the two
cannot double-count. Removing it is the next change, deliberately not this one.
…t the conversion

Three defects a security pass over the consumption path turned up, all of them
paths where a player ends up with withdrawable money the terms never released.

The platform's own bet path never told the engine which round it was placing.
`startRound` debited before inserting `game_round`, so there was no round id to
pass, and `endRound` credited the win without one either - which meant every
bonus-funded win on the only bet path core ships was credited straight to the
real balance and the wagering requirement was bypassed outright. The round id
is now minted before the debit and carried on both sides, along with the game
context the weight resolution needs.

`settle()` failed open. A movement the funding grant refused was indistinguish-
able from a round that never drew on a bonus, and both produced a zero share -
so the whole amount was credited as real money. A win on a grant that has since
completed does belong to the real balance, because its funds converted at
completion; a reversal does not, and returning a bonus-funded stake as cash
releases it without the wagering it was granted under. That case now stops with
a named error rather than paying out.

The conversion itself - the one movement that turns a bonus into withdrawable
money - wrote a ledger row and no audit record. It writes one now, with the
balance before and after and the grants that completed, on the same
transaction.

Three narrower ones alongside: a round that drew on more than one grant now
splits the win across all of them in proportion rather than picking whichever
row Postgres returned first; a partial void takes back only the progress the
returned part of the stake bought, and a second reversal callback for a round
refunds nothing; and the balance a debit decides on is read under a row lock,
since the `wallet` lock above it does not cover `wallet_balance` and a
withdrawal or an admin adjustment writes that row directly.

The attribution index is reshaped to `(user_id, currency, expires_at)`, which
is what the per-bet lookup this stack introduced actually orders by.
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