Skip to content

feat(promo): grant creation with a durable idempotency guard - #178

Open
zaxovaiko wants to merge 6 commits into
feat/promo-foundationfrom
feat/promo-grants
Open

zaxovaiko wants to merge 6 commits into
feat/promo-foundationfrom
feat/promo-grants

Conversation

@zaxovaiko

@zaxovaiko zaxovaiko commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #177. Review that one first; this diff is only the last three commits. Adds promo_grant - the row a player's bonus actually is - and GrantService.grant() bound to the BONUS_GRANTS port. BF-592.

Why

The grant row is the bonus balance. There is no separate balance table and no second wallet_balance row: promo_grant.bonus_balance holds the funds, and they cross into the real balance exactly once, at conversion, as a wallet_transaction of type bonus.

That is the load-bearing decision in this PR, so the reasoning in full. wallet_balance is unique on (wallet_id, currency), and it is read from roughly three dozen places - reconciliation, the custody sweep, swap, deposit, withdrawal, auto-withdrawal, the balance stream, admin reporting. Discriminating a bonus row by a kind column means every one of those reads needs a "real money only" predicate, and the one that gets missed is a player withdrawing bonus money as if it were their own.

Reconciliation is the sharper argument. It compares internal balances against on-chain custody. Bonus money was never deposited and has no chain side, so holding it in wallet_balance manufactures a permanent unexplained surplus on every reconciliation run.

Keeping it out also makes withdrawal correct by construction rather than by formula: there is nothing to subtract, because there is nothing there.

The idempotency guard is the unique index, not a read. unique (user_id, source, source_ref) with onConflictDoNothing, then a select for the existing row. A read-then-write check would let two concurrent replays of the same deposit both pass and grant the bonus twice; the index cannot. There is a test that fires three concurrent duplicates and asserts exactly one row and one created: true.

terms is a snapshot, never a lookup, and it snapshots the weight rows, not a reference to the profile. Editing an offer - or its weight profile - must not change a bonus a player already holds. Storing a profile id would have left that property true only by discipline; storing the resolved rows makes it true by construction. This is the fix for the one correction the design review raised against the original shape.

Input is validated at the boundary. A caller is another module, but a malformed multiplier or a non-positive amount has to be refused before it reaches the ledger rather than corrected after. Zod parse on entry.

expires_at comes from Postgres, now() + make_interval(days => ...), so there is no clock to inject and no drift between the row's created_at and its expiry.

Alternatives considered

A second wallet_balance row with a kind discriminator - rejected above, on the reconciliation and the thirty-odd unguarded reads.

A promo_grant_balance table - one row per grant per currency, which is one row per grant, which is a column.

Four terminal timestamps (completed_at, expired_at, forfeited_at, cancelled_at) - status already says which terminal state a grant closed in, so closed_at is one column carrying the same information.

A column-add migration on top of the existing one. The 0001 migration is regenerated in place instead. The table has never existed in a deployed database, so a follow-up ALTER TABLE would carry no information a reader could use.

Input bounds and attribution

Three bounds sit at the boundary rather than after the fact. A wagering multiplier of zero
produces a grant that converts the moment it is created, so there is a floor, and a ceiling above
it so a fat-fingered multiplier is refused rather than written. Currency is uppercased at entry:
a grant stored as usd against a wallet holding USD is invisible to every bet in that currency
and quietly expires.

BonusGrantArgs carries an actor. manual is in the source enum because an admin hands a player
a bonus through it, and before this the only record of that said the system did it, with a null
actor.

The replay path used to return success for any row matching the idempotency key without checking
it was the same grant, so two payouts sharing one source reference would have reported a credit
that never happened. It compares the currency and the amounts now and refuses on a mismatch.

Three CHECK constraints hold the money invariants where no caller can route around them: a bonus
balance cannot go negative, wagering progress cannot pass its requirement, and a forfeit reason
cannot exist without a forfeit. The consumption path is written against these rather than trusted
to respect them.

ADR-0040 records the balance-model decision this PR implements.

Risks

No wallet credit yet. GrantService writes the grant row and moves no money into a wallet; conversion is what moves money, and it arrives with the consumption path. A grant created today is a correct record of an obligation with no settlement behind it.

No events emitted. promo.bonus.granted is declared in #177 and nothing subscribes. Emitting it from inside the caller's transaction would require the transactional outbox, which is optional and throws when unbound, so the emit belongs to whoever owns the commit - the deposit subscription, once offers exist.

No offer table, so terms arrive from the caller. Eligibility (ok: false with ineligible / offer_inactive / currency_unsupported) is declared on the port and unreachable until offers land.

This PR also registers both promo migration sets in the shared test-database bootstrap. They were missing; the module's self-migrating integration tests hid it, but anything booted through the shared test app would have failed on a missing relation.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

馃挕 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ca4a33d569

鈩癸笍 About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 馃憤.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/core/src/promo/bonus/plugin.ts
Comment thread packages/core/src/promo/bonus/service/grant.service.ts Outdated
Comment thread packages/core/src/promo/bonus/service/grant.service.ts Outdated
Comment thread packages/core/src/promo/bonus/service/grant.service.ts
Comment thread packages/core/src/promo/bonus/service/grant.service.ts Outdated
/** Days from the grant until it expires and what is left of it is forfeited. */
expiryDays: number;
/** Weight profile that scores this grant's bets. */
weightProfileId: string;

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.

This is only a pointer to mutable configuration, not a terms snapshot. weightedContribution reloads the profile rows by this id for every bet, so editing or deleting a profile changes or stops wagering for grants that already exist. Store the resolved weights here, or point to an immutable profile version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This one is already covered in 3bf82f22 - snapshotTerms copies the profile rows into terms.weights, and wagering scores from the snapshot, never from the live profile. Editing or deleting the profile leaves an existing grant untouched (there are tests for both).

@klaudia-blazyczek-blurify

Copy link
Copy Markdown
Collaborator

Are you planning on adding more for our "Stage 0"?

@zaxovaiko zaxovaiko self-assigned this Sep 17, 2026
@zaxovaiko
zaxovaiko force-pushed the feat/promo-grants branch 2 times, most recently from 4324b28 to 2d340ad Compare September 17, 2026 23:04
@zaxovaiko
zaxovaiko marked this pull request as draft September 17, 2026 23:05
@zaxovaiko
zaxovaiko marked this pull request as ready for review September 20, 2026 09:49
* was credited, so there is nothing to lose. Everything after funding ends in `completed`,
* `expired` or `forfeited`.
*/
export const BONUS_GRANT_STATUSES = [

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.

These grant value sets already exist in contracts/schemas/promo.ts, which is the shared event and adapter contract. Keeping another tuple, schema, and type here lets the database, input port, and event payloads drift. Import the shared values instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 97923d04 - dropped the copies, the module imports them from contracts/schemas/promo.ts now.

.notNull()
.default('0'),
status: promoGrantStatusEnum().$type<BonusGrantStatus>().notNull().default('active'),
forfeitReason: promoForfeitReasonEnum().$type<BonusForfeitReason>(),

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.

This only rejects a reason on a non-forfeited grant. It still allows { status: "forfeited", forfeitReason: null }, even though every forfeit needs a reason for the audit trail. Enforce both directions in the database constraint.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 97923d04 - the check is now (status = 'forfeited') = (forfeit_reason is not null), so both directions are enforced.

amount: MoneyAmountSchema.refine(isPositiveMoney, 'must be greater than zero'),
source: BonusGrantSourceSchema,
sourceRef: z.string().min(1),
actor: z.union([

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.

source and actor are validated independently, so { source: "manual", actor: { type: "system" } } is accepted. That records an admin-issued bonus as a system action. Require an admin actor for a manual grant.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 97923d04 - a manual grant is refused unless the actor is an admin.


async grant(tx: DrizzleTx, rawArgs: BonusGrantArgs): Promise<BonusGrantOutcome> {
const args = grantArgsSchema.parse(rawArgs);
const wageringRequired = moneyScaleBy(args.amount, args.terms.wageringMultiplier);

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.

MoneyAmountSchema allows 20 integer digits, and the 1000 multiplier can produce a 23-digit wageringRequired. For example, 99999999999999999999 times 2 reaches the insert and overflows numeric(38,18). Validate the derived amount, or cap the amount relative to the multiplier.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 97923d04 - the derived wageringRequired is validated against MoneyAmountSchema before the insert.

async grant(tx: DrizzleTx, rawArgs: BonusGrantArgs): Promise<BonusGrantOutcome> {
const args = grantArgsSchema.parse(rawArgs);
const wageringRequired = moneyScaleBy(args.amount, args.terms.wageringMultiplier);
const terms = await this.snapshotTerms(tx, args.terms);

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.

This reads the live weight profile before checking the idempotency key. If a grant already exists and its profile was later deleted, an exact retry throws here instead of returning the original grant. Resolve an existing key before requiring live configuration, while keeping the insert race-safe.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 97923d04 - an existing key is resolved before the profile is read. The insert keeps onConflictDoNothing, so the concurrent case is still race-safe.

Wagering is scored from the weights copied onto the grant terms at grant
time, so editing or deleting a weight profile no longer changes a bonus a
player already holds. A grant against a missing profile is refused.
The separate-balance model needs somewhere for bonus funds to live, and the
answer is the grant row itself rather than a second `wallet_balance` row.
`wallet_balance` is unique on (wallet, currency) and is read from three dozen
places - reconciliation, custody sweep, swap, withdrawal, the balance stream -
each of which would need a "real money only" predicate, and the one that gets
missed is a player withdrawing bonus money. Reconciliation is the sharper
argument: it compares internal balances against on-chain custody, and bonus
funds were never deposited, so holding them there manufactures a permanent
unexplained surplus on every run.

Keeping them out makes withdrawal correct by construction instead of by a
subtractive lock formula. Funds cross into the real balance exactly once, at
conversion, and are fully visible to withdrawal, limits and reconciliation from
that instant.

`activated_at` and `closed_at` bracket the grant's life; `status` already says
which terminal state it closed in, so there is one column rather than four.

The 0001 migration is regenerated in place rather than followed by a column-add
migration: the table has never existed in a deployed database, so the second
file would carry no information.

Also registers both promo migration sets in the test database bootstrap. They
were missing, which the module's self-migrating integration tests hid; anything
booted through the shared test app would have failed on a missing relation.
Every grant was audited as `actorType: 'system'` with no actor id, and the port
had no field to carry one. `manual` is in the source enum because an admin
hands a player a bonus through it, so the only record of who did that said the
system did. `BonusGrantArgs` now carries the actor and a manual grant records
the admin behind it.

Three input bounds, all on the money path, all at the boundary rather than
after the fact. A wagering multiplier of zero produces a grant that converts
the moment it is created; there is now an explicit floor, and a ceiling above
it so a fat-fingered multiplier is refused rather than written. Currency is
uppercased at entry, because a grant stored as `usd` against a wallet holding
`USD` is invisible to every bet in that currency and expires untouched.

The replay path returned success for any row that matched the idempotency key,
without checking it was the same grant. Two payouts sharing one source
reference would have reported a credit that never happened. It now compares the
currency and the amounts and refuses on a mismatch.

Three CHECK constraints hold the money invariants where no caller can route
around them: a bonus balance cannot go negative, wagering progress cannot pass
its requirement, and a forfeit reason cannot exist without a forfeit. The
consumption path is written against these rather than being trusted to respect
them.

The two raw `throw new Error` calls became typed errors the router can map, and
the FIFO index is now partial on live statuses - a terminal grant is never
consumed again, and long term they are almost the whole table.

ADR-0040 records why bonus funds live on the grant rather than in
`wallet_balance`, since that decision is what the rest of the stack is built
on.
Import the grant value sets from the shared promo contract instead of
redeclaring them. Require a forfeit reason exactly when a grant is forfeited,
refuse a manual grant with no admin actor, refuse a requirement that would
overflow numeric(38,18), and resolve an existing idempotency key before the
weight profile is read so a replay survives a deleted profile.
@zaxovaiko

Copy link
Copy Markdown
Member Author

Stage 0 is complete in #177 - both modules, the BONUS_GRANTS, WAGER_TRACKING and BONUS_WAGERING ports, context on WalletDebitArgs and the promo event names. Everything from #178 up is on my side of the split.

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