Conversation
096d484 to
00c01fc
Compare
`wallet_transaction` is the real-money ledger and stays that way. A movement that never touches the real balance - a stake paid from bonus funds, a win attributed back to the grant that funded it, a conversion, a forfeit - has nowhere to be recorded, yet the spec requires every credit, debit, conversion and forfeiture be recorded immutably. `promo_grant_entry` is that record. `bonus_amount` is a signed delta, so the sum of a grant's entries always equals its `bonus_balance`; if that identity ever breaks, money moved outside the ledger, and it is asserted in the tests rather than left as an intention. `user_id` is denormalised off the grant so the win-attribution lookup - find the stake rows for a provider round - is one indexed read rather than a join. That lookup is how a win knows which grant funded the bet, which is why the round id lives on the entry and not on the grant: a player can hold several grants and a grant spans many rounds. The entry is written only on the created path, so a replayed grant leaves the ledger untouched. The idempotency guard has to cover the ledger as well as the grant, or a retried deposit doubles a player's recorded bonus.
The doc comment called the ledger append-only while the grant foreign key was `on delete cascade`, so a single `DELETE FROM promo_grant` - a cleanup job, an erasure routine, an overlay migration - would have taken the entire movement history with it, together with the only evidence that it ever existed. It is `on delete restrict` now. A grant that has to go away gets a forfeit or expire entry; nothing deletes the row. The entry carries its own currency. Every money column in this repository is paired with one, and a ledger that has to join its parent to say what the numbers mean is not a ledger anyone can read. Indexes are named per the database standard, and the two ledger tests that duplicated the grant service's replay and rollback cases were merged into it - same scenarios, one database instead of two.
00c01fc to
f83f9e8
Compare
| * bet. `reversal` undoes a voided round: the bonus stake goes back and its wagering progress with | ||
| * it, because money returned without progress returned is free wagering bought by a rollback. | ||
| */ | ||
| export const BONUS_GRANT_ENTRY_TYPES = [ |
There was a problem hiding this comment.
BONUS_GRANT_ENTRY_TYPES already lives in contracts/schemas/promo.ts, alongside the event and adapter contracts. A second tuple, schema, and type here can drift from the ledger enum and event payloads. Import the shared values instead.
| * still requires it be recorded immutably. | ||
| * | ||
| * `bonusAmount` is a signed delta, so the sum of a grant's entries always equals its | ||
| * `bonus_balance`. If that identity ever breaks, money moved outside the ledger. |
There was a problem hiding this comment.
onDelete: restrict only protects the parent grant. The application role can still UPDATE or DELETE a promo_grant_entry, which breaks the immutable ledger and the sum(entries) = bonusBalance invariant. Enforce append-only writes at the database boundary, not only by convention.
| wageringDelta: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }) | ||
| .notNull() | ||
| .default('0'), | ||
| balanceAfter: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), |
There was a problem hiding this comment.
the target schema also needs (user_id, created_at) for player bonus history. (grant_id, created_at) only serves one grant, while the round index serves settlement; a history query will otherwise scan and sort the ledger. Add the user-history index as well.
Summary
Stacked on #178. Adds
promo_grant_entry, the append-only bonus ledger, and hasGrantServiceopen it with the grant's first movement. BF-592.Why
wallet_transactionis the real-money ledger and stays that way: one row per bet, per win, per deposit, for the full amount. Under the separate-balance model a whole class of movement never touches the real balance - a stake paid from bonus funds, a win attributed back to the grant that funded it, a conversion, an expiry, a forfeit - and the acceptance criterion is that every credit, debit, conversion and forfeiture is recorded. Those movements need a ledger of their own.bonus_amountis a signed delta, and that is the design. The sum of a grant's entries always equals itsbonus_balance. One identity, asserted in the tests rather than left as an intention:If it ever breaks, money moved outside the ledger, and that is the one class of bug in this area that cannot be recovered from after the fact.
user_idis denormalised off the grant. The win path has to answer "which grant funded this provider round" on every credit, and the index(user_id, external_round_id) where external_round_id is not nullanswers it with one read and no join. The round id lives on the entry rather than on the grant because a player can hold several grants and a grant spans many rounds - the round is a property of the movement, not of the bonus.real_amountsits besidebonus_amounton the same row. A stake split half from cash and half from bonus wins back proportionally, and the proportion is exactly these two numbers. Storing only the bonus half would mean recomputing the split from the wallet ledger at settlement time, across a module boundary, for a number this row already knows.The entry is written only on the created path. A replayed grant returns the existing row and touches nothing. The idempotency guard has to cover the ledger as well as the grant, or a retried deposit doubles a player's recorded bonus while the grant row stays correct - the worst kind of drift, because the two disagree silently.
The grant foreign key is
on delete restrict, notcascade. A ledger that a singleDELETE FROM promo_grantcan erase is not a ledger. A grant that has to go away gets aforfeitorexpireentry; the history stays.Alternatives considered
Recording progress increments as ledger rows. The table would grow one row per bet per active grant, the largest table in the system at real slot volume. It also is not what the requirement asks for: a wagering-progress increment is not a credit, a debit, a conversion or a forfeiture. Only money movements are logged; progress lives on the grant. The cost is losing per-bet attribution when a player disputes why their bonus never completed - at which point the
stakerows this table already carries are the answer, since a bonus-funded stake is a money movement.A cross-module FK to the wallet transaction.
wallet_transaction_idis a bare uuid with no reference, per the module-boundary rule.grant_iddoes carry a real FK, because that one is inside this module.Enforcing append-only with a database trigger. Not done, and worth naming rather than leaving implied: nothing in this repository uses one, including
audit_log, whose immutability is the stricter requirement.on delete restrictcloses the erasure path that actually existed; no code path updates an entry, and a trigger would be a local exception to a house rule.Risks
Nothing writes a
stake,win,reversal,convert,forfeitorexpireentry yet - those arrive with the consumption path and the lifecycle sweep. The type enum is declared in full up front so neither of those PRs has to touch this migration.