Skip to content

feat(promo): deposit offers, eligibility and opt-in - #193

Open
zaxovaiko wants to merge 3 commits into
feat/promo-grant-lifecyclefrom
feat/promo-offers
Open

zaxovaiko wants to merge 3 commits into
feat/promo-grant-lifecyclefrom
feat/promo-offers

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 18, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #192. promo_offer and promo_opt_in, the eligibility rules, the deposit that turns an opt-in into a bonus, and the admin CRUD behind it. BF-589, and the deposit-offer half of BF-594.

Why

An offer is one mutable row, not a versioned one. A grant snapshots the terms it was created under, which already makes editing an offer unable to reach a bonus a player holds. A version column with a bump-on-edit rule, or a promo_offer_version table, would be a second mechanism enforcing a property the snapshot enforces by construction - and two mechanisms for one rule is how they drift. status covers draft, active, paused and archived.

Eligibility is a pure function, and it is the same function on both sides. "Which offers can this player see" and "does this deposit earn this bonus" are the same question asked with different facts. Two implementations would eventually disagree, and the one that granted would be the laxer one. It takes the facts from its caller and returns which rule closed the offer rather than a boolean, so the opt-in route can say why.

Absent facts fail the rule that needs them. An offer restricted to first depositors is not open to a caller who cannot say whether this is a first deposit - the alternative is a first-deposit-only offer granting on every deposit the moment a caller forgets to pass the flag.

Deposits accumulate on the opt-in. A player who deposits half the minimum twice qualifies; that is the progress bar the design asks for, and it is one column rather than a second table. The opt-in row is both the claim and the progress, and its unique index on (user_id, offer_id) is what makes taking an offer twice the same claim.

The grant is created by a durable job, keyed on the deposit's transaction id, not inside the event handler. A bonus a player was promised and did not receive because an event was dropped is the same class of defect as a forfeit that never ran - and the grant's own (user, source, source_ref) index means a retried job resolves to the grant it already made.

match_percent is numeric(5,2), like a weight. A percentage was never money and does not need eighteen decimal places. The grant amount is the match, truncating, capped at the offer's ceiling - truncation favours the operator on the fraction and the cap is exact.

What the review pass changed

Six ways a deposit could pay more bonus than it earned, or none at all:

  • The accumulator had no durable guard. Jobs are at-least-once and a queue idempotency key is not a correctness guard, so a redelivered deposit added itself to the running total twice - turning a below-minimum deposit into a full-cap bonus. promo_opt_in_deposit now records which deposits were counted toward which claim, and its unique index makes the second delivery a no-op.
  • The grant's source reference was the deposit id alone, shared across every offer a player holds. One deposit satisfying two claims collided on the grant's idempotency index: either the amounts differed and the job threw and retried forever, or they matched and the second claim was silently consumed against the first offer's grant. It is per deposit and offer now.
  • A refusal banked the deposit whatever the reason. Deposits made while an offer was paused or out of window accumulated, so re-activating it paid a match on the whole history. Only "short of the minimum" counts toward it now - the one refusal that means "not yet".
  • A grant the port refuses no longer banks the total that would have earned it, and logs rather than failing silently.
  • A match that truncates to zero skips instead of throwing and stranding every other claim in the batch.
  • The row lock names the opt-in; locking the joined offer rows deadlocked unrelated players' deposits over a shared offer.

excludedCountries is removed rather than shipped. No port exposes a player's residence country, so the rule could never fire - and a jurisdictional exclusion an operator configures and a regulator asks about must not be one that silently does nothing. firstDepositOnly stays and now works: the wallet already answers lifetime deposits, and a total equal to this deposit means there was nothing before it.

Also: re-denominating an offer with live claims is refused, taking a claim is audited, and requiresOptIn: false now opens the claim itself instead of being a flag nothing read.

Alternatives considered

Calling a command port from inside the deposit transaction instead of subscribing. The wallet publishes wallet.deposit.completed after its own commit, so there is no caller transaction left to join; a synchronous seam would mean a new port on the wallet's deposit path for one consumer. The durable job gets the same guarantee.

A separate archive route. Archiving is a status. A second endpoint writing one column is a second thing to keep in step with the first.

minRank eligibility, and free spins. Both are named in the ticket. The rank table belongs to the gamification module and does not exist yet, so the predicate would be written against a guess; free spins are unspecified beyond the number 50. Neither is in this PR, and rules is jsonb so neither costs a migration when it arrives.

Risks

applyDeposit locks every un-granted opt-in a player holds (FOR UPDATE) for the duration of the job. A player with many open opt-ins serialises their own deposits against each other; nobody else is affected.

The offer list is filtered in the service rather than in SQL, because the eligibility predicate is pure and takes facts the query does not have. At the scale an operator runs offers - tens, not thousands - that is the right trade; if it ever is not, the window and status filters are already columns.

A player's residence country is not available to this module, so an offer cannot be restricted by jurisdiction yet. The rule is absent rather than present-and-inert; it returns when a port answers the question.

Lowering an offer's minDeposit instantly qualifies every banked accumulator across all players at once. That is arguably what an operator means by lowering it, but it is worth knowing before doing it.

What an operator offers and what a player has to do to earn it. An offer is a
single mutable row with a status, not a versioned one: a grant snapshots the
terms it was created under, so editing an offer already cannot reach a bonus a
player holds, and a version history would be a second mechanism enforcing a
property the snapshot enforces by construction.

Eligibility is a pure function the caller feeds facts to, and it is the same
function behind "show me the offers" and "credit this deposit" - two versions
would eventually disagree, and the laxer one would be the one that granted.
Absent facts fail the rule that needs them: an offer for first depositors is
not open to a caller who cannot say whether this is a first deposit.

Deposits accumulate on the opt-in. A player who deposits half the minimum twice
qualifies, which is what the progress bar in the design describes, and the
grant is created once the accumulated total clears it. The opt-in row is both
the claim and the progress; its unique index on (user, offer) is what makes
taking an offer twice the same claim rather than two.

The grant is created by a durable job keyed on the deposit's transaction id,
not inside the event handler. A bonus the player was promised and did not
receive because an event was dropped is the same class of bug as a forfeit that
never ran, and the grant's own idempotency index makes the retry free.

Admin CRUD sits behind the `bonus` resource the IAM catalogue already seeds.
There is no archive route: archiving is a status, and a second endpoint that
writes one column would be a second thing to keep in step with the first.
A security pass over the offer path found six ways a deposit could pay more
bonus than it earned, or none at all.

The accumulator had no durable guard. Jobs are at-least-once, and the queue's
idempotency key is not a correctness guard - a redelivered deposit added itself
to the running total a second time, so a deposit short of the minimum became a
full-cap bonus on the retry. `promo_opt_in_deposit` records which deposits have
been counted toward which claim, and its unique index is what makes the second
delivery a no-op.

The grant's source reference was the deposit id alone, shared across every
offer the player holds. One deposit satisfying two claims collided on the
grant's own idempotency index: either the amounts differed and the job threw
and retried forever, or they matched and the second claim was silently consumed
against the first offer's grant. The reference is per deposit and offer now,
which is the grain that is actually unique.

A refusal banked the deposit whatever the reason. Deposits made while an offer
was paused or out of its window accumulated, so re-activating it paid a match
on the whole history. Only a deposit short of the minimum counts toward it now,
which is the one refusal that means "not yet".

Three smaller ones: a grant the port refuses no longer banks the total that
would have earned it, and says so rather than failing silently; a match that
truncates to nothing skips instead of throwing and stranding every other claim
in the batch; and the row lock names the opt-in, because locking the joined
offer rows too put two players' deposits in a deadlock over an offer they
happened to share.

`excludedCountries` is gone rather than shipped. No port exposes a player's
residence country, so the rule could never fire, and an exclusion an operator
configures and a regulator asks about must not be one that silently does
nothing. `firstDepositOnly` stays and now works: the wallet already answers
lifetime deposits, and a total equal to this deposit means there was nothing
before it.

Re-denominating an offer players hold claims on is refused, taking a claim is
audited, and an offer that needs no taking now opens the claim itself instead
of being a flag nothing read.
… offer

`UpdatePromoOfferInputSchema` was `CreatePromoOfferInputSchema.partial()`.
`.partial()` makes a field optional but leaves a `.default()` on it intact, so
every omitted field came back filled with its create-time default and went
straight into the `UPDATE ... SET`:

- renaming a live offer flipped `status` to `draft`, so it vanished from the
  player list and every later deposit was refused, silently;
- pausing an opt-in, first-deposit-only offer cleared `requiresOptIn` and
  `rules.firstDepositOnly`, so re-activating it matched every depositor's deposit
  rather than the ones who took it - operator funds paid out on a rule nobody
  changed;
- `validFrom` and `validUntil` were wiped, reopening an expired seasonal offer.

The audit row recorded all of it as a deliberate before/after, so the corruption
read as intentional in the regulator-facing record. The update schema is built
from the row shape, which carries no defaults.

Second defect, same surface: neither player route supplied `isFirstDeposit`, and
the eligibility function fails an absent fact closed. A `firstDepositOnly` offer
was therefore invisible in `GET /promo/offers` for every player and always 409 on
opt-in - the sign-up bonus, the one offer that is first-deposit-gated, could never
be taken. The service already holds the wallet reader that answers it.

A granted deposit bonus now emits `promo.bonus.granted` after its transaction
commits, and the notification module maps it, so a player is told what they
received and what it obliges them to wager.

BF-589 BF-594
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