Skip to content

feat(promo): append-only bonus grant ledger - #189

Open
zaxovaiko wants to merge 3 commits into
feat/promo-grantsfrom
feat/promo-grant-ledger
Open

zaxovaiko wants to merge 3 commits into
feat/promo-grantsfrom
feat/promo-grant-ledger

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #178. Adds promo_grant_entry, the append-only bonus ledger, and has GrantService open it with the grant's first movement. BF-592.

Why

wallet_transaction is 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_amount is a signed delta, and that is the design. The sum of a grant's entries always equals its bonus_balance. One identity, asserted in the tests rather than left as an intention:

select sum(bonus_amount) from promo_grant_entry where grant_id = $1  -- = promo_grant.bonus_balance

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_id is 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 null answers 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_amount sits beside bonus_amount on 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, not cascade. A ledger that a single DELETE FROM promo_grant can erase is not a ledger. A grant that has to go away gets a forfeit or expire entry; 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 stake rows 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_id is a bare uuid with no reference, per the module-boundary rule. grant_id does 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 restrict closes 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, forfeit or expire entry 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.

`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.
@zaxovaiko
zaxovaiko force-pushed the feat/promo-grant-ledger branch from 00c01fc to f83f9e8 Compare September 18, 2026 08:48
* 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 = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

2 participants