Conversation
zaxovaiko
added this pull request to stack #179
September 18, 2026 08:41
zaxovaiko
force-pushed
the
feat/promo-wagering
branch
from
September 18, 2026 08:48
d1a31fd to
aec7256
Compare
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.
zaxovaiko
force-pushed
the
feat/promo-wagering
branch
from
September 18, 2026 08:57
aec7256 to
8d84e3b
Compare
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 #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 sameproviderReftwice 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 withLEAST(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 thewallet_transactionof typebonus. 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
stakeentries 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_transactionstill records the full amount of every bet and every win. The split lives only inpromo_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
debitWalletBalancefor 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:
startRounddebited before insertinggame_round, so no round id reached the engine, andendRoundcredited 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.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
walletlock above it does not coverwallet_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_bethere. 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
termssnapshot away from being configurable, andtermsis 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_creditrollover model is still present and still runs on the same bet. The two are disjoint - one readswallet_balance, the other the grant - butdebit()now has two bonus branches, which is exactly one more than should exist. The next PR removes the old one.