Skip to content

feat(promo): admin bonus visibility and manual forfeit - #194

Draft
zaxovaiko wants to merge 2 commits into
feat/promo-offersfrom
feat/promo-admin-grants
Draft

zaxovaiko wants to merge 2 commits into
feat/promo-offersfrom
feat/promo-admin-grants

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #193. A support manager can see a player's bonuses and take one away with a reason on the record. BF-595's API half.

Why

The admin view is the player's view plus the source. Which deposit, which rain drop, which offer produced the bonus - a player has no need for that and does not get it, and support cannot answer "why do I have this bonus" without it. The terms snapshot still stays internal on both surfaces; it is the operator's weighting, not an answer to a dispute.

The reason and the note are mandatory at the contract, not in the handler. A handler check is one refactor away from being skipped, and a forfeiture with no recorded reason is exactly what a regulator asks about. The note has a minimum length and is trimmed, so "." does not satisfy it.

Forfeiting a bonus that is already gone refuses rather than reporting success. The claim-then-act update finds nothing to claim and the route returns 409. An admin who forfeits a bonus that converted an hour ago acted on stale information and needs to be told - a silent no-op leaves them believing they took money back that the player already has.

What the review pass changed

  • The admin forfeit emits promo.bonus.forfeited. It was the only forfeit path that destroyed bonus funds silently - the sweep emits, self-exclusion emits - so every consumer of that event under-reported the hand-issued forfeits, which are the ones most likely to be disputed.
  • A missing grant answers 404, not 409. The 409 asserted the bonus existed and was already closed, so a typo'd id read as a completed action and put a false statement in the dispute trail. The read-back's own not-found error is mapped too, instead of surfacing as a 500 after the forfeit had committed.
  • A refused forfeit leaves an audit row. A money resource an admin can probe without trace is exactly where the outcome matters; only successful attempts were recorded before.
  • pending counts as live. Nothing writes that status today, but this PR is what makes forfeiting admin-visible, and a pending grant that could be neither forfeited nor expired would sit there and pay out later.

Alternatives considered

Re-using the player grant route with an admin-visible flag. Two audiences, two shapes, and a flag deciding which fields come back is the kind of thing that leaks the wider shape to the narrower audience the first time someone edits it.

Letting the forfeit route return the grant it took as the service saw it. It re-reads instead. The lifecycle service returns what it closed for the caller's own bookkeeping, not a DTO, and shaping that into the admin view inside the service would put a wire concern in the place that moves money.

Risks

Forfeiting is per grant. Taking every bonus a player holds is several calls, and nothing in the API expresses "all of them" - the sweep does that on self-exclusion and account closure, where it is a rule rather than a decision.

The route reads the grant back after the forfeit, so the response is a second query rather than the row the update returned. Consistent within the transaction that just committed, but a grant forfeited and then immediately expired by the sweep would report whichever landed last.

@zaxovaiko
zaxovaiko added this pull request to stack #179 September 18, 2026 08:41
@zaxovaiko
zaxovaiko force-pushed the feat/promo-admin-grants branch from 61e2174 to a5583ab Compare September 18, 2026 08:48
A support manager resolving a dispute needs two things: what bonuses this
player has held, and the ability to take one away with a reason on the record.

The admin view is the player's view plus the source and its reference - which
deposit, which rain drop, which offer produced the bonus. A player has no need
for that and does not get it; support cannot answer "why do I have this" without
it.

The reason and a note of real length are mandatory at the contract layer rather
than checked in the handler. A handler check is one refactor away from being
skipped, and a forfeiture with no recorded reason is the thing the regulator
asks about.

Forfeiting a bonus that has already completed, expired or been taken refuses
rather than reporting success over a no-op. An admin who forfeits a bonus that
converted an hour ago has made a decision on stale information and needs to be
told, not reassured.
…osed one

The admin forfeit was the only forfeit path that destroyed bonus funds without
emitting `promo.bonus.forfeited`. The sweep emits, self-exclusion emits, and a
hand-issued forfeit - the one most likely to be disputed later - was silent, so
every consumer of that event under-reported exactly the cases an operator most
needs to see.

Forfeiting a grant id that does not exist answered 409 "no longer active, so
there is nothing to forfeit", which asserts the bonus existed and was already
closed. A typo'd id read as a completed action, and the false statement went
into the dispute trail. It is 404 now, and the read-back's own not-found error
is mapped rather than surfacing as a 500 after the forfeit committed.

An attempt that takes nothing now leaves an audit row saying so. A money
resource an admin can probe without trace is the case where the outcome matters
most, and until now only the attempts that succeeded were recorded.

`close()` treats `pending` as live alongside `active`. Nothing writes that
status today, but this is the change that makes forfeiting admin-visible, and a
pending grant that could be neither forfeited nor expired would sit there and
pay out later.
@zaxovaiko
zaxovaiko force-pushed the feat/promo-admin-grants branch from a5583ab to cf2b767 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