Skip to content

feat(promo): grant expiry, forfeiture and the player's view of a bonus - #192

Draft
zaxovaiko wants to merge 3 commits into
feat/promo-wallet-teardownfrom
feat/promo-grant-lifecycle
Draft

zaxovaiko wants to merge 3 commits into
feat/promo-wallet-teardownfrom
feat/promo-grant-lifecycle

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #191. Everything that ends a grant - expiry, forfeiture on self-exclusion or account closure - plus the two routes a player reads their bonuses through. BF-592, BF-595 (the read half).

Why

Expiry and forfeiture are one code path with two reasons. Both leave the same shape behind: a terminal status, a zeroed bonus balance, a ledger entry for the funds that died with the grant, and an audit row. Writing them separately would have meant two chances to forget one of those four.

The claim is the status = 'active' predicate on the update, not a read. A second sweep running beside the first, or an admin forfeiting a grant the sweep is about to expire, updates zero rows and writes nothing - rather than a second ledger entry against a balance that is already gone. Each grant closes in its own transaction: one transaction for a batch of five hundred would hold locks across the whole sweep for no benefit.

Self-exclusion and account closure forfeit immediately, not at the next cron tick. A bonus is money a player may not keep once they have excluded themselves, and a five-minute window where they still can is not what the rule says. Both subscribe to events that already exist (rg.self_exclusion.activated, player.account.closed).

The forfeited amount is read off the grant's own ledger, not from the column the update just zeroed. The entries sum to the balance by construction, so what the grant still held is what the entries say it held - and if that identity is ever broken, this is one of the places it shows up rather than being papered over.

The player routes return a grant as its holder sees it. The terms snapshot stays internal: it carries the operator's weighting, which is not the player's business. A grant belonging to someone else is 404, not 403 - a 403 confirms the id exists.

Why these land together

The cron that drives the sweep is scheduled from a router factory, which is the only place in a plugin with container access after boot. An expiry sweep without a router would have meant mounting an empty router to hold the schedule.

What the review pass changed

A win on a grant this branch could now expire or forfeit was paid out as withdrawable cash. Settlement treated "the grant refused this" as "this round drew no bonus", which credits the full amount to the real balance. That was correct while completed was the only closed state - those funds had already converted - and wrong the moment a grant could be expired or forfeited, where the funds were destroyed. A player could stake a bonus on a slow-settling round, self-exclude, and collect the win as cash with none of the wagering done. Settlement now asks why the grant refused and destroys the bonus share when the grant's own balance was destroyed. The same change unsticks the void path, which used to throw on a closed grant and leave a provider retrying a rollback forever.

Forfeiture moved onto a durable job. It moves money, and a best-effort event handler that died between two of a player's grants left the rest active with nothing to retry them - the sweep only looks at expiry dates, never at exclusion state. The status = 'active' claim already made the work idempotent, so the retry costs nothing.

The actor is carried through. Both subscribed events name who acted; an admin closing an account and a player excluding themselves were both being recorded as system.

The forfeited amount is read under the row lock the update is about to take, so it is the column the CHECK constraint protects rather than a sum derived from the ledger.

Also from the quality pass: event payloads are validated with domainEventSchemas[...].safeParse instead of a cast, GET /promo/grants paginates like every other list route in this repo, and a dead conditional type and its suppressive cast are gone.

Alternatives considered

A single transaction per sweep batch. Simpler to write, holds row locks across up to five hundred grants, and one failure rolls back the whole batch. Per-grant transactions mean a bad row costs one grant.

Reversing a completed grant's conversion on forfeit. Out of scope here: a grant that completed converted its funds to real money, and clawing that back is a different decision with a different audit story.

Deferring exclusion forfeiture to the sweep. Would have been less code and a window in which an excluded player still holds spendable bonus funds.

Risks

A grant that completed or expired before a forfeit arrives is left alone - the forfeit finds nothing active and writes nothing. That is the intended reading of "forfeit what they still hold", but it means a forfeit issued seconds after a conversion takes nothing.

The sweep takes five hundred grants per tick at a five-minute cadence. A backlog larger than six thousand an hour would grow; the cadence is a constant and the batch is ordered by expiry, so the oldest always move first.

This PR also drops the promo value sets that the module was still declaring alongside the copies in the isomorphic contracts zone. Same values, one declaration.

@zaxovaiko
zaxovaiko added this pull request to stack #179 September 18, 2026 08:41
@zaxovaiko
zaxovaiko force-pushed the feat/promo-grant-lifecycle branch from 43e6392 to d96ddb0 Compare September 18, 2026 08:48
Everything that ends a grant, and the routes a player reads one through. They
land together because the cron that drives the sweep is scheduled from a router
factory - the only place in a plugin with container access after boot - so an
expiry sweep without a router would have meant mounting an empty one to hold it.

Expiry and forfeiture are one code path with two reasons. Both leave the same
shape: a terminal status, a zeroed bonus balance, a ledger entry for the funds
that died with the grant, and an audit row. The claim is the `status = 'active'`
predicate on the update, so a second sweep, or an admin racing the sweep, writes
nothing rather than a second entry. Each grant is closed in its own transaction:
a sweep that took one transaction for the batch would hold it open across five
hundred rows.

Self-exclusion and account closure forfeit every live grant the player holds,
immediately rather than at the next sweep - a bonus is money a player may not
keep once they have excluded themselves, and "by the next cron tick" is not what
that rule says.

The two player routes return a grant as its holder sees it: the amounts, the
progress, the expiry. The terms snapshot stays internal - it carries the
operator's weighting, which is not the player's business. A grant belonging to
someone else is missing rather than forbidden, because a 403 confirms the id
exists.

The promo value sets stop being declared twice. They moved to the isomorphic
contracts zone when the events file needed them, and the module kept its own
copies until now.
A security pass over the lifecycle found a leak a player could trigger on
purpose, and it only became reachable because this branch made `expired` and
`forfeited` reachable at all.

Settlement asked the grant to take the money and treated a refusal as "this
round drew no bonus", which credits the whole amount to the real balance. That
was right while `completed` was the only closed state - those funds had already
converted - and wrong the moment a grant could be expired or forfeited, where
the funds were destroyed. A player could stake a bonus on a slow-settling
round, self-exclude, and have the win paid out as withdrawable cash with none
of the wagering done.

Settlement now asks why the grant refused. Converted means the money is already
real and the win belongs there. Destroyed means the bonus share of the round
goes the same way the balance did, recorded as an entry against the closed
grant so the ledger still explains itself. That also unsticks the void path: it
used to throw on a closed grant, and a provider retrying a rollback forever had
no way out.

Forfeiture moved off the event handler onto a durable job. It moves money, and
a handler that dies between two of a player's grants left the rest active with
nothing to retry it - the sweep only looks at expiry dates, never at exclusion.
The `status = 'active'` claim already made the work idempotent, so a retried
job is free.

The actor is carried through rather than discarded. Both events name who acted,
and an admin closing an account or a player excluding themselves were both
being recorded as the system. The balance a forfeit reports is now read under
the row lock it is about to take, so it is the column the CHECK constraint
protects rather than a sum derived from the ledger.
@zaxovaiko
zaxovaiko force-pushed the feat/promo-grant-lifecycle branch from d96ddb0 to c0a0e7c Compare September 18, 2026 08:57
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