Conversation
zaxovaiko
added this pull request to stack #179
September 18, 2026 08:41
zaxovaiko
force-pushed
the
feat/promo-offers
branch
from
September 18, 2026 08:48
a458298 to
70d90e8
Compare
zaxovaiko
force-pushed
the
feat/promo-offers
branch
from
September 18, 2026 08:57
70d90e8 to
ba5a5a8
Compare
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
zaxovaiko
force-pushed
the
feat/promo-offers
branch
from
September 20, 2026 00:32
ba5a5a8 to
437b531
Compare
zaxovaiko
marked this pull request as ready for review
September 20, 2026 09:49
zaxovaiko
requested review from
damianrzepka,
jakubfilinger-b,
klaudia-blazyczek-blurify,
marek-chmielowski-blurify,
mp-blurify and
okapitula
as code owners
September 20, 2026 09:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #192.
promo_offerandpromo_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
versioncolumn with a bump-on-edit rule, or apromo_offer_versiontable, would be a second mechanism enforcing a property the snapshot enforces by construction - and two mechanisms for one rule is how they drift.statuscovers 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_percentisnumeric(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:
promo_opt_in_depositnow records which deposits were counted toward which claim, and its unique index makes the second delivery a no-op.excludedCountriesis 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.firstDepositOnlystays 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: falsenow 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.completedafter 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.
minRankeligibility, 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, andrulesis jsonb so neither costs a migration when it arrives.Risks
applyDepositlocks 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
minDepositinstantly 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.