diff --git a/docs/adr/0040-bonus-funds-live-on-the-grant.md b/docs/adr/0040-bonus-funds-live-on-the-grant.md new file mode 100644 index 00000000..c176d8b0 --- /dev/null +++ b/docs/adr/0040-bonus-funds-live-on-the-grant.md @@ -0,0 +1,58 @@ +# ADR-0040: Bonus funds live on the grant, not in the wallet balance + +**Date**: 2026-09-18 +**Status**: Accepted + +## Context + +The platform needs a bonus a player must wager before they can withdraw it. Two shapes were +available. + +The first is the one already shipped for chat gifts and rain: the bonus is credited into +`wallet_balance` like any other money, a `wallet_bonus_credit` row records how much of it is still +un-wagered, and `debitWithdrawableBalance` subtracts a proportional locked share at withdrawal +time. One balance, one number the player sees, and a formula standing between them and their own +cash. + +The second keeps bonus funds out of the wallet entirely. A grant row carries its own balance, a +bet decides which side pays, and the money becomes real exactly once - when the wagering +requirement is met. + +The specification asks for the second: bonus balance tracked separately from real balance, bets +drawing on both, and a forfeit that takes the bonus together with any winnings it produced. The +last clause is the one that decides it. Winnings from a bonus-funded stake have to be +attributable to the grant, and under one fungible balance there is nothing to attribute them to. + +## Decision + +`promo_grant.bonus_balance` is the bonus balance. Bonus funds never enter `wallet_balance` until +they convert, and conversion writes one `wallet_transaction` of type `bonus` for the exact amount. + +`wallet_transaction` stays the real-money ledger and still records every bet and every win for +the full amount. `promo_grant_entry` is a second, append-only ledger recording which part of each +movement was bonus. Neither is bypassed and neither is a subset of the other. + +## Consequences + +Withdrawal needs no bonus logic at all. The proportional locked-share subquery inside +`debitWithdrawableBalance` exists only because the old model mixed the two kinds of money in one +row; with them separated, `amount >= requested` is a complete guard. Responsible-gambling limits +are unaffected: they read `wallet_transaction`, which still sees the full stake. + +Reconciliation stays correct. It compares internal balances against on-chain custody, and bonus +money was never deposited. Holding it in `wallet_balance` would manufacture a permanent +unexplained surplus on every run. + +Thirty-odd existing reads of `wallet_balance` - reconciliation, the custody sweep, swap, deposit, +withdrawal, the balance stream, admin reporting - keep their current meaning. The alternative, a +`kind` discriminator column, would have required a `kind = 'real'` predicate in every one of them, +and the one that got missed would be a player withdrawing bonus money. + +The cost is that "total balance" is two reads rather than one: the wallet balance plus the +player's active grants. The client sums them. + +The fungible model is removed rather than kept alongside. Two mechanisms implementing one product +rule means two answers to "what is locked" and a debit path with two bonus branches, which is a +bug waiting for whoever consolidates them. Chat gifts and rain move onto grants, which changes +their behaviour: that money now has to be wagered before it converts, instead of being spendable +immediately with a share locked at withdrawal. diff --git a/packages/core/src/contracts/adapters/bonus-grants.ts b/packages/core/src/contracts/adapters/bonus-grants.ts index 53ecd0e6..2a766b90 100644 --- a/packages/core/src/contracts/adapters/bonus-grants.ts +++ b/packages/core/src/contracts/adapters/bonus-grants.ts @@ -7,6 +7,26 @@ import type { BonusGrantSource } from '../schemas/promo.js'; import { createToken, type Token } from './token.js'; +/** + * The terms a grant is created under. The grant row stores a snapshot of them, with the weight + * profile's rows resolved into it, so editing or deleting an offer or a profile never changes a + * bonus a player already holds. + */ +export type BonusGrantTerms = { + /** Wagering requirement as a multiple of the granted amount, as a decimal string. */ + wageringMultiplier: string; + /** Days from the grant until it expires and what is left of it is forfeited. */ + expiryDays: number; + /** Weight profile whose rows are copied onto the grant to score its bets. */ + weightProfileId: string; +}; + +/** + * Who asked for the grant. A `manual` grant is an admin handing a player money, so it carries + * that admin onto the audit row; every other source is a rule firing with no person behind it. + */ +export type BonusGrantActor = { type: 'admin'; id: string } | { type: 'system' }; + export type BonusGrantArgs = { userId: string; currency: string; @@ -19,8 +39,10 @@ export type BonusGrantArgs = { * replayed deposit or a re-run job resolves to the first grant instead of creating a second. */ sourceRef: string; + actor: BonusGrantActor; /** Offer the grant is created from, when one exists. Absent for a manual or a job grant. */ offerId?: string; + terms: BonusGrantTerms; }; export type BonusGrantOutcome = diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index eb2a8792..f349d951 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -48,7 +48,13 @@ export { WALLET_COMMANDS } from './wallet-commands.js'; export type { WagerContext, WagerProduct } from './wager-context.js'; export { WAGER_PRODUCTS, isWagerProduct } from './wager-context.js'; -export type { BonusGrantCommands, BonusGrantArgs, BonusGrantOutcome } from './bonus-grants.js'; +export type { + BonusGrantCommands, + BonusGrantActor, + BonusGrantArgs, + BonusGrantOutcome, + BonusGrantTerms, +} from './bonus-grants.js'; export { BONUS_GRANTS } from './bonus-grants.js'; export type { diff --git a/packages/core/src/contracts/schemas/promo.ts b/packages/core/src/contracts/schemas/promo.ts index dbb5f979..d116c19f 100644 --- a/packages/core/src/contracts/schemas/promo.ts +++ b/packages/core/src/contracts/schemas/promo.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; // Canonical promo value sets. Declared here rather than in the promo module because the // isomorphic event and adapter contracts reference them and cannot import from a domain. +/** What caused a grant. Half of its idempotency key. */ export const BONUS_GRANT_SOURCES = [ 'deposit', 'manual', @@ -13,6 +14,11 @@ export const BONUS_GRANT_SOURCES = [ 'rain', ] as const; +/** + * `pending` is a grant that is claimed but not yet funded, `cancelled` its only exit - nothing + * was credited, so there is nothing to lose. Everything after funding ends in `completed`, + * `expired` or `forfeited`. + */ export const BONUS_GRANT_STATUSES = [ 'pending', 'active', @@ -22,6 +28,7 @@ export const BONUS_GRANT_STATUSES = [ 'cancelled', ] as const; +/** Why an active grant was taken away. Recorded on every forfeit, for the regulator. */ export const BONUS_FORFEIT_REASONS = [ 'self_exclusion', 'account_closed', diff --git a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts new file mode 100644 index 00000000..58dabb3f --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { findOneOrThrow } from '@openora/core/server'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import type { BonusGrantArgs, Uuid } from '@openora/core/contracts'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { promoGrant, promoWeight, promoWeightProfile } from '../schema/index.js'; +import { GrantService } from '../service/grant.service.js'; +import { resolveContributionPercent, weightedStake } from '../shared/wagering-weight.js'; + +let db: TestDb; +let service: GrantService; +let audit: ReturnType; +let weightProfileId: Uuid; + +const CASINO = { provider: 'aggregator', product: 'casino' } as const; + +const termsWith = (wageringMultiplier: string, expiryDays = 30) => ({ + wageringMultiplier, + expiryDays, + weightProfileId, +}); + +const args = (over: Partial = {}): BonusGrantArgs => ({ + userId: randomUUID(), + currency: 'USD', + amount: '100', + source: 'deposit', + sourceRef: randomUUID(), + actor: { type: 'system' }, + terms: termsWith('35'), + ...over, +}); + +const grant = (a: BonusGrantArgs) => db.drizzle.db.transaction((tx) => service.grant(tx, a)); + +const rows = () => db.drizzle.db.select().from(promoGrant); + +const seedWeight = ( + scope: (typeof promoWeight.$inferInsert)['scope'], + scopeRef: string | null, + contributionPercent: string, +) => + db.drizzle.db + .insert(promoWeight) + .values({ profileId: weightProfileId, scope, scopeRef, contributionPercent }); + +const scoreOf = (terms: (typeof promoGrant.$inferSelect)['terms'], stake: string) => + weightedStake(stake, resolveContributionPercent(terms.weights, CASINO)); + +beforeAll(async () => { + db = await createTestDb([migrate]); + audit = makeAuditWriter(); + service = new GrantService(audit); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.delete(promoGrant); + await db.drizzle.db.delete(promoWeight); + await db.drizzle.db.delete(promoWeightProfile); + vi.clearAllMocks(); + weightProfileId = findOneOrThrow( + await db.drizzle.db + .insert(promoWeightProfile) + .values({ name: `profile-${randomUUID()}` }) + .returning(), + new Error('seed profile: query returned no row'), + ).id; + // A profile with no positive weight can never progress a grant's wagering requirement, so + // the fixture always carries a usable default; the empty/unusable case gets its own profile. + await seedWeight('default', null, '100'); +}); + +describe('GrantService.grant', () => { + it('credits the bonus and derives the requirement from the multiplier', async () => { + const outcome = await grant(args({ amount: '100', terms: termsWith('35') })); + + expect(outcome).toMatchObject({ ok: true, created: true }); + const [row] = await rows(); + expect(row).toMatchObject({ + grantedAmount: '100.000000000000000000', + bonusBalance: '100.000000000000000000', + wageringRequired: '3500.000000000000000000', + wageringProgress: '0.000000000000000000', + status: 'active', + }); + expect(row?.activatedAt).toBeInstanceOf(Date); + expect(row?.closedAt).toBeNull(); + }); + + it('uppercases the currency, so a lowercase caller cannot hide a grant from its own bets', async () => { + await grant(args({ currency: 'usd' })); + + const [row] = await rows(); + expect(row?.currency).toBe('USD'); + }); + + it('snapshots the terms and the profile weights onto the grant', async () => { + await seedWeight('product', 'casino', '100'); + await grant(args({ terms: termsWith('35', 7) })); + + const [row] = await rows(); + expect(row?.terms).toEqual({ + wageringMultiplier: '35', + expiryDays: 7, + weightProfileId, + weights: expect.arrayContaining([ + { scope: 'product', scopeRef: 'casino', contributionPercent: '100.00' }, + ]), + }); + }); + + it('keeps scoring a granted bonus at its snapshot after the profile is edited', async () => { + await seedWeight('product', 'casino', '100'); + await grant(args()); + await db.drizzle.db + .update(promoWeight) + .set({ contributionPercent: '10' }) + .where(eq(promoWeight.profileId, weightProfileId)); + + const [row] = await rows(); + expect(scoreOf(row!.terms, '50')).toBe('50.000000000000000000'); + }); + + it('keeps scoring a granted bonus at its snapshot after the profile is deleted', async () => { + await seedWeight('product', 'casino', '100'); + await grant(args()); + await db.drizzle.db + .delete(promoWeightProfile) + .where(eq(promoWeightProfile.id, weightProfileId)); + + const [row] = await rows(); + expect(scoreOf(row!.terms, '50')).toBe('50.000000000000000000'); + }); + + it('refuses a grant on a weight profile with no positive weight, which could never progress', async () => { + const emptyProfileId = findOneOrThrow( + await db.drizzle.db + .insert(promoWeightProfile) + .values({ name: `empty-profile-${randomUUID()}` }) + .returning(), + new Error('seed profile: query returned no row'), + ).id; + + await expect( + grant( + args({ + terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId: emptyProfileId }, + }), + ), + ).rejects.toThrow(/no positive weight/i); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a grant on a weight profile whose only rows are zero, which could never progress', async () => { + await db.drizzle.db.insert(promoWeight).values({ + profileId: weightProfileId, + scope: 'product', + scopeRef: 'casino', + contributionPercent: '0', + }); + await db.drizzle.db + .update(promoWeight) + .set({ contributionPercent: '0' }) + .where(eq(promoWeight.profileId, weightProfileId)); + + await expect(grant(args())).rejects.toThrow(/no positive weight/i); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a grant whose weight profile does not exist', async () => { + await expect( + grant( + args({ terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId: randomUUID() } }), + ), + ).rejects.toThrow(/WagerWeightProfile/i); + }); + + it('expires the grant `expiryDays` after it was created', async () => { + await grant(args({ terms: termsWith('1', 7) })); + + const [row] = await rows(); + const days = (row!.expiresAt.getTime() - row!.createdAt.getTime()) / 86_400_000; + expect(days).toBeCloseTo(7, 5); + }); + + it('a replayed deposit returns the first grant and creates no second row', async () => { + const a = args(); + + const first = await grant(a); + const second = await grant(a); + + expect(second).toEqual({ ...first, created: false }); + expect(await rows()).toHaveLength(1); + }); + + it('refuses a replay that asks for different money under the same reference', async () => { + const a = args({ amount: '100' }); + await grant(a); + + await expect(grant({ ...a, amount: '500' })).rejects.toThrow(); + expect(await rows()).toHaveLength(1); + }); + + it('concurrent duplicates settle to one grant, the index is the guard', async () => { + const a = args(); + + const outcomes = await Promise.all([grant(a), grant(a), grant(a)]); + + expect(await rows()).toHaveLength(1); + expect(outcomes.filter((o) => o.ok && o.created)).toHaveLength(1); + expect(new Set(outcomes.map((o) => (o.ok ? o.grantId : null))).size).toBe(1); + }); + + it('the same source ref under a different source is a different grant', async () => { + const sourceRef = randomUUID(); + const userId = randomUUID(); + + await grant(args({ userId, sourceRef, source: 'deposit' })); + await grant( + args({ userId, sourceRef, source: 'manual', actor: { type: 'admin', id: randomUUID() } }), + ); + + expect(await rows()).toHaveLength(2); + }); + + it('the same source ref for a different player is a different grant', async () => { + const sourceRef = randomUUID(); + + await grant(args({ sourceRef })); + await grant(args({ sourceRef })); + + expect(await rows()).toHaveLength(2); + }); + + it('writes one audit row per grant, on the same transaction', async () => { + const a = args(); + await grant(a); + await grant(a); + + expect(audit.recordInTransaction).toHaveBeenCalledTimes(1); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'promo.bonus.granted', actorType: 'system' }), + ); + }); + + it('records the admin behind a manual grant, so a hand-issued bonus is attributable', async () => { + const adminId = randomUUID(); + await grant(args({ source: 'manual', actor: { type: 'admin', id: adminId } })); + + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ actorType: 'admin', actorId: adminId }), + ); + }); + + it('rejects a zero or negative amount before it reaches the ledger', async () => { + await expect(grant(args({ amount: '0' }))).rejects.toThrow(); + await expect(grant(args({ amount: '-1' }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects an amount that is not a decimal string', async () => { + await expect(grant(args({ amount: 'NaN' }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects a multiplier of zero, which would convert the moment it was granted', async () => { + await expect(grant(args({ terms: termsWith('0') }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects a negative multiplier', async () => { + await expect(grant(args({ terms: termsWith('-1') }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects a multiplier past the sane ceiling, which is a fat finger not an offer', async () => { + await expect(grant(args({ terms: termsWith('100000') }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects an expiry that is not a positive whole number of days', async () => { + await expect(grant(args({ terms: termsWith('35', 0) }))).rejects.toThrow(); + await expect(grant(args({ terms: termsWith('35', 1.5) }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects an expiry past the sane ceiling', async () => { + await expect(grant(args({ terms: termsWith('35', 3651) }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('rejects a manual grant with no admin behind it', async () => { + await expect(grant(args({ source: 'manual', actor: { type: 'system' } }))).rejects.toThrow( + /admin/, + ); + expect(await rows()).toHaveLength(0); + }); + + it('rejects a non-manual grant that names an admin actor, which would misattribute it in the audit trail', async () => { + await expect( + grant(args({ source: 'deposit', actor: { type: 'admin', id: randomUUID() } })), + ).rejects.toThrow(/admin/); + expect(await rows()).toHaveLength(0); + }); + + it('rejects an amount whose requirement would overflow the column', async () => { + await expect( + grant(args({ amount: '99999999999999999999', terms: termsWith('2') })), + ).rejects.toThrow(/too large/); + expect(await rows()).toHaveLength(0); + }); + + it('a replay still returns the first grant after its weight profile was deleted', async () => { + const a = args(); + const first = await grant(a); + await db.drizzle.db + .delete(promoWeightProfile) + .where(eq(promoWeightProfile.id, weightProfileId)); + + expect(await grant(a)).toEqual({ ...first, created: false }); + expect(await rows()).toHaveLength(1); + }); + + it('the database refuses a forfeit without a reason, and a reason without a forfeit', async () => { + await grant(args()); + const [row] = await rows(); + const set = (values: Partial) => + db.drizzle.db.update(promoGrant).set(values).where(eq(promoGrant.id, row!.id)); + + await expect(set({ status: 'forfeited' })).rejects.toThrow(); + await expect(set({ forfeitReason: 'admin' })).rejects.toThrow(); + await expect(set({ status: 'forfeited', forfeitReason: 'admin' })).resolves.toBeDefined(); + }); + + it('rolls the audit write back with the grant when the transaction fails', async () => { + const a = args(); + await expect( + db.drizzle.db.transaction(async (tx) => { + await service.grant(tx, a); + throw new Error('caller failed after the grant'); + }), + ).rejects.toThrow('caller failed after the grant'); + + expect(await rows()).toHaveLength(0); + }); +}); diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql b/packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql new file mode 100644 index 00000000..ac0ec5c1 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql @@ -0,0 +1,29 @@ +CREATE TYPE "public"."promo_forfeit_reason" AS ENUM('self_exclusion', 'account_closed', 'admin', 'player_opt_out', 'withdrawal_while_active');--> statement-breakpoint +CREATE TYPE "public"."promo_grant_source" AS ENUM('deposit', 'manual', 'streak', 'rank', 'race', 'gift', 'rain');--> statement-breakpoint +CREATE TYPE "public"."promo_grant_status" AS ENUM('pending', 'active', 'completed', 'expired', 'forfeited', 'cancelled');--> statement-breakpoint +CREATE TABLE "promo_grant" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "currency" text NOT NULL, + "source" "promo_grant_source" NOT NULL, + "source_ref" text NOT NULL, + "offer_id" uuid, + "terms" jsonb NOT NULL, + "granted_amount" numeric(38, 18) NOT NULL, + "bonus_balance" numeric(38, 18) DEFAULT '0' NOT NULL, + "wagering_required" numeric(38, 18) NOT NULL, + "wagering_progress" numeric(38, 18) DEFAULT '0' NOT NULL, + "status" "promo_grant_status" DEFAULT 'active' NOT NULL, + "forfeit_reason" "promo_forfeit_reason", + "expires_at" timestamp with time zone NOT NULL, + "activated_at" timestamp with time zone, + "closed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "promo_grant_bonus_balance_non_negative" CHECK ("promo_grant"."bonus_balance" >= 0), + CONSTRAINT "promo_grant_progress_within_requirement" CHECK ("promo_grant"."wagering_progress" >= 0 AND "promo_grant"."wagering_progress" <= "promo_grant"."wagering_required"), + CONSTRAINT "promo_grant_forfeit_reason_requires_forfeited" CHECK ("promo_grant"."forfeit_reason" is null or "promo_grant"."status" = 'forfeited') +); +--> statement-breakpoint +CREATE UNIQUE INDEX "promo_grant_user_id_source_source_ref_idx" ON "promo_grant" USING btree ("user_id","source","source_ref");--> statement-breakpoint +CREATE INDEX "promo_grant_user_id_currency_created_at_idx" ON "promo_grant" USING btree ("user_id","currency","created_at") WHERE "promo_grant"."status" in ('pending', 'active');--> statement-breakpoint +CREATE INDEX "promo_grant_expires_at_idx" ON "promo_grant" USING btree ("expires_at") WHERE "promo_grant"."status" = 'active'; \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0002_clever_nuke.sql b/packages/core/src/promo/bonus/drizzle/migrations/0002_clever_nuke.sql new file mode 100644 index 00000000..3e65a831 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0002_clever_nuke.sql @@ -0,0 +1,2 @@ +ALTER TABLE "promo_grant" DROP CONSTRAINT "promo_grant_forfeit_reason_requires_forfeited";--> statement-breakpoint +ALTER TABLE "promo_grant" ADD CONSTRAINT "promo_grant_forfeit_reason_matches_status" CHECK (("promo_grant"."status" = 'forfeited') = ("promo_grant"."forfeit_reason" is not null)); \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..2782edf0 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,411 @@ +{ + "id": "2f2e0f6c-e8e0-42c3-ae04-db27bf93c1e1", + "prevId": "2ac20ab4-1698-4d57-9d07-9e0cdc76857c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.promo_grant": { + "name": "promo_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "promo_grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terms": { + "name": "terms", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "granted_amount": { + "name": "granted_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "bonus_balance": { + "name": "bonus_balance", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "wagering_required": { + "name": "wagering_required", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagering_progress": { + "name": "wagering_progress", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "promo_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "forfeit_reason": { + "name": "forfeit_reason", + "type": "promo_forfeit_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_grant_user_id_source_source_ref_idx": { + "name": "promo_grant_user_id_source_source_ref_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_currency_created_at_idx": { + "name": "promo_grant_user_id_currency_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_expires_at_idx": { + "name": "promo_grant_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_grant_bonus_balance_non_negative": { + "name": "promo_grant_bonus_balance_non_negative", + "value": "\"promo_grant\".\"bonus_balance\" >= 0" + }, + "promo_grant_progress_within_requirement": { + "name": "promo_grant_progress_within_requirement", + "value": "\"promo_grant\".\"wagering_progress\" >= 0 AND \"promo_grant\".\"wagering_progress\" <= \"promo_grant\".\"wagering_required\"" + }, + "promo_grant_forfeit_reason_requires_forfeited": { + "name": "promo_grant_forfeit_reason_requires_forfeited", + "value": "\"promo_grant\".\"forfeit_reason\" is null or \"promo_grant\".\"status\" = 'forfeited'" + } + }, + "isRLSEnabled": false + }, + "public.promo_weight": { + "name": "promo_weight", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "promo_weight_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_ref": { + "name": "scope_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contribution_percent": { + "name": "contribution_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_weight_profile_id_scope_scope_ref_idx": { + "name": "promo_weight_profile_id_scope_scope_ref_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_weight_profile_id_default_idx": { + "name": "promo_weight_profile_id_default_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"promo_weight\".\"scope\" = 'default'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_weight_profile_id_promo_weight_profile_id_fk": { + "name": "promo_weight_profile_id_promo_weight_profile_id_fk", + "tableFrom": "promo_weight", + "tableTo": "promo_weight_profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_weight_contribution_percent_range": { + "name": "promo_weight_contribution_percent_range", + "value": "\"promo_weight\".\"contribution_percent\" >= 0 AND \"promo_weight\".\"contribution_percent\" <= 100" + } + }, + "isRLSEnabled": false + }, + "public.promo_weight_profile": { + "name": "promo_weight_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_weight_profile_name_unique": { + "name": "promo_weight_profile_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.promo_forfeit_reason": { + "name": "promo_forfeit_reason", + "schema": "public", + "values": [ + "self_exclusion", + "account_closed", + "admin", + "player_opt_out", + "withdrawal_while_active" + ] + }, + "public.promo_grant_source": { + "name": "promo_grant_source", + "schema": "public", + "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain"] + }, + "public.promo_grant_status": { + "name": "promo_grant_status", + "schema": "public", + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] + }, + "public.promo_weight_scope": { + "name": "promo_weight_scope", + "schema": "public", + "values": ["game", "category", "product", "default"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0002_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..17752c16 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0002_snapshot.json @@ -0,0 +1,411 @@ +{ + "id": "28065819-7abd-43e9-826c-59ebaee979b9", + "prevId": "2f2e0f6c-e8e0-42c3-ae04-db27bf93c1e1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.promo_grant": { + "name": "promo_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "promo_grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offer_id": { + "name": "offer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "terms": { + "name": "terms", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "granted_amount": { + "name": "granted_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "bonus_balance": { + "name": "bonus_balance", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "wagering_required": { + "name": "wagering_required", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "wagering_progress": { + "name": "wagering_progress", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "promo_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "forfeit_reason": { + "name": "forfeit_reason", + "type": "promo_forfeit_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_grant_user_id_source_source_ref_idx": { + "name": "promo_grant_user_id_source_source_ref_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_currency_created_at_idx": { + "name": "promo_grant_user_id_currency_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_expires_at_idx": { + "name": "promo_grant_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_grant_bonus_balance_non_negative": { + "name": "promo_grant_bonus_balance_non_negative", + "value": "\"promo_grant\".\"bonus_balance\" >= 0" + }, + "promo_grant_progress_within_requirement": { + "name": "promo_grant_progress_within_requirement", + "value": "\"promo_grant\".\"wagering_progress\" >= 0 AND \"promo_grant\".\"wagering_progress\" <= \"promo_grant\".\"wagering_required\"" + }, + "promo_grant_forfeit_reason_matches_status": { + "name": "promo_grant_forfeit_reason_matches_status", + "value": "(\"promo_grant\".\"status\" = 'forfeited') = (\"promo_grant\".\"forfeit_reason\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.promo_weight": { + "name": "promo_weight", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "promo_weight_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "scope_ref": { + "name": "scope_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contribution_percent": { + "name": "contribution_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_weight_profile_id_scope_scope_ref_idx": { + "name": "promo_weight_profile_id_scope_scope_ref_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_weight_profile_id_default_idx": { + "name": "promo_weight_profile_id_default_idx", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"promo_weight\".\"scope\" = 'default'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_weight_profile_id_promo_weight_profile_id_fk": { + "name": "promo_weight_profile_id_promo_weight_profile_id_fk", + "tableFrom": "promo_weight", + "tableTo": "promo_weight_profile", + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_weight_contribution_percent_range": { + "name": "promo_weight_contribution_percent_range", + "value": "\"promo_weight\".\"contribution_percent\" >= 0 AND \"promo_weight\".\"contribution_percent\" <= 100" + } + }, + "isRLSEnabled": false + }, + "public.promo_weight_profile": { + "name": "promo_weight_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_weight_profile_name_unique": { + "name": "promo_weight_profile_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.promo_forfeit_reason": { + "name": "promo_forfeit_reason", + "schema": "public", + "values": [ + "self_exclusion", + "account_closed", + "admin", + "player_opt_out", + "withdrawal_while_active" + ] + }, + "public.promo_grant_source": { + "name": "promo_grant_source", + "schema": "public", + "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain"] + }, + "public.promo_grant_status": { + "name": "promo_grant_status", + "schema": "public", + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] + }, + "public.promo_weight_scope": { + "name": "promo_weight_scope", + "schema": "public", + "values": ["game", "category", "product", "default"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json index ea058ac5..eeefafce 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1789687196995, "tag": "0000_short_jean_grey", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789687709950, + "tag": "0001_talented_human_cannonball", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1790118107856, + "tag": "0002_clever_nuke", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/bonus/index.ts b/packages/core/src/promo/bonus/index.ts index 17dbd4d1..0624378a 100644 --- a/packages/core/src/promo/bonus/index.ts +++ b/packages/core/src/promo/bonus/index.ts @@ -1,2 +1,3 @@ +export { GrantService } from './service/grant.service.js'; export { resolveContributionPercent, weightedStake } from './shared/wagering-weight.js'; export type { WagerWeightRow } from './shared/wagering-weight.js'; diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index 72b2f164..e0d298c4 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -1,6 +1,11 @@ import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { AUDIT_WRITER, BONUS_GRANTS } from '@openora/core/contracts'; +import { GrantService } from './service/grant.service.js'; export default { id: 'bonus', - register() {}, + dependsOn: ['audit'], + register(ctx) { + ctx.provide(BONUS_GRANTS, (c) => new GrantService(c.get(AUDIT_WRITER))); + }, } as const satisfies Plugin; diff --git a/packages/core/src/promo/bonus/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index 54f2cf40..e9111b75 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -7,13 +7,25 @@ import { text, decimal, timestamp, + jsonb, + index, uniqueIndex, } from 'drizzle-orm/pg-core'; import { CONTRIBUTION_PERCENT_PRECISION, CONTRIBUTION_PERCENT_SCALE, + MONEY_PRECISION, + MONEY_SCALE, + BONUS_FORFEIT_REASONS, + BONUS_GRANT_SOURCES, + BONUS_GRANT_STATUSES, + type BonusForfeitReason, + type BonusGrantSource, + type BonusGrantStatus, + type BonusGrantTerms, } from '@openora/core/contracts'; import { WAGER_WEIGHT_SCOPES, type WagerWeightScope } from '../contract/index.js'; +import type { WagerWeightRow } from '../shared/wagering-weight.js'; export const promoWeightScopeEnum = pgEnum('promo_weight_scope', WAGER_WEIGHT_SCOPES); @@ -67,5 +79,84 @@ export const promoWeight = pgTable( ], ); +/** + * The terms as stored on a grant: the caller's terms plus the weight rows the profile held at + * grant time. Wagering is scored from `weights`, never from the live profile. + */ +export type GrantTermsSnapshot = BonusGrantTerms & { weights: WagerWeightRow[] }; + +export const promoGrantStatusEnum = pgEnum('promo_grant_status', BONUS_GRANT_STATUSES); +export const promoGrantSourceEnum = pgEnum('promo_grant_source', BONUS_GRANT_SOURCES); +export const promoForfeitReasonEnum = pgEnum('promo_forfeit_reason', BONUS_FORFEIT_REASONS); + +/** + * One bonus a player holds. The grant row IS the bonus balance: bonus funds never enter + * `wallet_balance`, so a withdrawal cannot reach them and reconciliation never sees money that + * was never deposited. They cross into the real balance exactly once, at conversion. + * + * `terms` is a snapshot, never a lookup. Editing an offer must not change a bonus already + * granted, which is the one rule the configuration surface has to obey. + */ +export const promoGrant = pgTable( + 'promo_grant', + { + id: uuid().primaryKey().defaultRandom(), + // Cross-module id, no FK (module-boundary rule). + userId: uuid().notNull(), + currency: text().notNull(), + source: promoGrantSourceEnum().$type().notNull(), + // The deposit transaction, the `:` job key, the race id. + sourceRef: text().notNull(), + offerId: uuid(), + terms: jsonb().$type().notNull(), + grantedAmount: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + // Bonus funds still on this grant. Spent by a bet, topped up by a bonus-funded win, + // zeroed by conversion, expiry or forfeiture. + bonusBalance: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }) + .notNull() + .default('0'), + wageringRequired: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + wageringProgress: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }) + .notNull() + .default('0'), + status: promoGrantStatusEnum().$type().notNull().default('active'), + forfeitReason: promoForfeitReasonEnum().$type(), + expiresAt: timestamp({ withTimezone: true }).notNull(), + // Null while the grant is still `pending` and nothing has been credited. + activatedAt: timestamp({ withTimezone: true }), + // Set once the grant reaches any terminal status; `status` says which one. + closedAt: timestamp({ withTimezone: true }), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // The idempotency guard. A replayed deposit or a re-run daily job hits this, not a + // read-then-write check that two concurrent callers would both pass. + uniqueIndex('promo_grant_user_id_source_source_ref_idx').on(t.userId, t.source, t.sourceRef), + // FIFO consumption order and the balance read. Partial, because a terminal grant is never + // consumed again and long-term they are almost the whole table. + index('promo_grant_user_id_currency_created_at_idx') + .on(t.userId, t.currency, t.createdAt) + .where(sql`${t.status} in ('pending', 'active')`), + // The expiry sweep, over live rows only. + index('promo_grant_expires_at_idx') + .on(t.expiresAt) + .where(sql`${t.status} = 'active'`), + // Money invariants the engine must never be able to break, held where no caller can route + // around them: a bonus balance cannot go negative and progress cannot pass its requirement. + check('promo_grant_bonus_balance_non_negative', sql`${t.bonusBalance} >= 0`), + check( + 'promo_grant_progress_within_requirement', + sql`${t.wageringProgress} >= 0 AND ${t.wageringProgress} <= ${t.wageringRequired}`, + ), + // Both directions: a reason on a live grant is a lie, and a forfeit without one leaves the + // regulator nothing to read. + check( + 'promo_grant_forfeit_reason_matches_status', + sql`(${t.status} = 'forfeited') = (${t.forfeitReason} is not null)`, + ), + ], +); + +export type PromoGrant = typeof promoGrant.$inferSelect; export type PromoWeightProfile = typeof promoWeightProfile.$inferSelect; export type PromoWeight = typeof promoWeight.$inferSelect; diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts new file mode 100644 index 00000000..11ba749d --- /dev/null +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -0,0 +1,219 @@ +import { and, eq, sql } from 'drizzle-orm'; +import * as z from 'zod'; +import { + BonusGrantSourceSchema, + CurrencyTickerInputSchema, + MoneyAmountSchema, + UuidSchema, + type AuditWritePort, + type BonusGrantArgs, + type BonusGrantCommands, + type BonusGrantOutcome, +} from '@openora/core/contracts'; +import { + isPositiveMoney, + makeConflictError, + makeNotFoundError, + moneyCompare, + moneyScaleBy, + type DrizzleTx, +} from '@openora/core/server'; +import { + promoGrant, + promoWeight, + promoWeightProfile, + type GrantTermsSnapshot, +} from '../schema/index.js'; + +export const WagerWeightProfileNotFoundError = makeNotFoundError('WagerWeightProfile'); +export const GrantConflictError = makeConflictError( + 'GrantConflictError', + 'A different grant already exists for this source reference', +); +export const UnusableWeightProfileError = makeConflictError( + 'UnusableWeightProfileError', + 'This weight profile has no positive weight, so no wager could ever progress a grant on it', +); + +// A requirement of zero converts the moment it is created, and a multiplier in the thousands is +// a fat finger rather than an offer. Both are refused before anything reaches the ledger. +const MAX_WAGERING_MULTIPLIER = '1000'; + +// Past this, `days => N` still fits `make_interval`'s int4 argument, but nothing this platform +// grants runs longer than a decade; a bigger value is a fat finger, not an offer. +const MAX_EXPIRY_DAYS = 3650; + +const grantArgsSchema = z + .object({ + userId: UuidSchema, + currency: CurrencyTickerInputSchema, + amount: MoneyAmountSchema.refine(isPositiveMoney, 'must be greater than zero'), + source: BonusGrantSourceSchema, + sourceRef: z.string().min(1), + actor: z.union([ + z.object({ type: z.literal('admin'), id: UuidSchema }), + z.object({ type: z.literal('system') }), + ]), + offerId: UuidSchema.optional(), + terms: z.object({ + wageringMultiplier: MoneyAmountSchema.refine( + isPositiveMoney, + 'must be greater than zero', + ).refine( + (v) => moneyCompare(v, MAX_WAGERING_MULTIPLIER) <= 0, + `must not exceed ${MAX_WAGERING_MULTIPLIER}`, + ), + expiryDays: z.number().int().positive().max(MAX_EXPIRY_DAYS), + weightProfileId: UuidSchema, + }), + }) + .refine((a) => (a.source === 'manual') === (a.actor.type === 'admin'), { + message: 'a manual grant must name the admin who issued it, and no other source may name one', + path: ['actor'], + }) + // Each input fits `numeric(38,18)` on its own, but their product need not: the requirement + // would overflow at the insert instead of being refused here. + .refine( + (a) => MoneyAmountSchema.safeParse(moneyScaleBy(a.amount, a.terms.wageringMultiplier)).success, + { message: 'wagering requirement is too large to store', path: ['amount'] }, + ); + +/** + * Creates the bonuses a player holds. Bound to BONUS_GRANTS, and always called on the caller's + * transaction handle so the grant commits with whatever earned it. + */ +export class GrantService implements BonusGrantCommands { + constructor(private readonly audit: AuditWritePort) {} + + async grant(tx: DrizzleTx, rawArgs: BonusGrantArgs): Promise { + const args = grantArgsSchema.parse(rawArgs); + const wageringRequired = moneyScaleBy(args.amount, args.terms.wageringMultiplier); + + // A retry resolves before any live configuration is read, so a profile deleted since the + // first call cannot turn an exact replay into an error. + const replayed = await this.findReplay(tx, args, wageringRequired); + if (replayed) { + return replayed; + } + const terms = await this.snapshotTerms(tx, args.terms); + + // The unique index is the idempotency guard. A read-then-write check would let two + // concurrent replays of the same deposit both pass and grant the bonus twice. + const [inserted] = await tx + .insert(promoGrant) + .values({ + userId: args.userId, + currency: args.currency, + source: args.source, + sourceRef: args.sourceRef, + offerId: args.offerId ?? null, + terms, + grantedAmount: args.amount, + bonusBalance: args.amount, + wageringRequired, + expiresAt: sql`now() + make_interval(days => ${args.terms.expiryDays})`, + activatedAt: sql`now()`, + }) + .onConflictDoNothing() + .returning({ id: promoGrant.id }); + + if (!inserted) { + // Lost the race to a concurrent replay; the unique index held, the winner's row is there. + const winner = await this.findReplay(tx, args, wageringRequired); + if (!winner) { + throw new GrantConflictError(); + } + return winner; + } + + await this.audit.recordInTransaction(tx, { + ...(args.actor.type === 'admin' ? { actorId: args.actor.id } : {}), + actorType: args.actor.type, + action: 'promo.bonus.granted', + resourceType: 'promo_grant', + resourceId: inserted.id, + after: { + userId: args.userId, + currency: args.currency, + source: args.source, + sourceRef: args.sourceRef, + grantedAmount: args.amount, + wageringRequired, + wageringMultiplier: args.terms.wageringMultiplier, + expiryDays: args.terms.expiryDays, + weightProfileId: args.terms.weightProfileId, + }, + }); + + return { ok: true, grantId: inserted.id, created: true }; + } + + /** + * A replay resolves to the grant that already exists - but only if it is the same grant. Two + * different payouts sharing one source reference would otherwise return success while crediting + * nothing, and the caller would record a payout that never happened. + */ + private async findReplay( + tx: DrizzleTx, + args: z.infer, + wageringRequired: string, + ): Promise { + const [existing] = await tx + .select({ + id: promoGrant.id, + currency: promoGrant.currency, + grantedAmount: promoGrant.grantedAmount, + wageringRequired: promoGrant.wageringRequired, + }) + .from(promoGrant) + .where( + and( + eq(promoGrant.userId, args.userId), + eq(promoGrant.source, args.source), + eq(promoGrant.sourceRef, args.sourceRef), + ), + ); + if (!existing) { + return undefined; + } + const matches = + existing.currency === args.currency && + moneyCompare(existing.grantedAmount, args.amount) === 0 && + moneyCompare(existing.wageringRequired, wageringRequired) === 0; + if (!matches) { + throw new GrantConflictError(); + } + return { ok: true, grantId: existing.id, created: false }; + } + + // One statement, so a profile deleted or edited mid-grant yields either its old rows or a + // missing profile, never a half-read set of weights. + private async snapshotTerms( + tx: DrizzleTx, + terms: BonusGrantArgs['terms'], + ): Promise { + const rows = await tx + .select({ + scope: promoWeight.scope, + scopeRef: promoWeight.scopeRef, + contributionPercent: promoWeight.contributionPercent, + }) + .from(promoWeightProfile) + .leftJoin(promoWeight, eq(promoWeight.profileId, promoWeightProfile.id)) + .where(eq(promoWeightProfile.id, terms.weightProfileId)); + + if (rows.length === 0) { + throw new WagerWeightProfileNotFoundError(terms.weightProfileId); + } + const weights = rows.flatMap((r) => + r.scope && r.contributionPercent + ? [{ scope: r.scope, scopeRef: r.scopeRef, contributionPercent: r.contributionPercent }] + : [], + ); + const hasUsableWeight = weights.some((w) => moneyCompare(w.contributionPercent, '0') > 0); + if (!hasUsableWeight) { + throw new UnusableWeightProfileError(); + } + return { ...terms, weights }; + } +} diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index 8f895492..699bde4f 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -63,8 +63,8 @@ import { SMS_ADAPTER, SOCIAL_COMMANDS, TAG_EVALUATION_COMMANDS, - WALLET_ASSET_CATALOG, WAGER_TRACKING, + WALLET_ASSET_CATALOG, WALLET_COMMANDS, WALLET_READER, } from '@openora/core/contracts'; diff --git a/packages/testing/src/db.ts b/packages/testing/src/db.ts index babb1265..6eacbee5 100644 --- a/packages/testing/src/db.ts +++ b/packages/testing/src/db.ts @@ -20,6 +20,8 @@ import { migrate as migrateChatCommands } from '@openora/core/engagement/migrate import { migrate as migrateNotifications } from '@openora/core/engagement/migrate/notifications'; import { migrate as migrateSocial } from '@openora/core/engagement/migrate/social'; import { migrate as migrateExchangeRate } from '@openora/core/fx/migrate/exchange-rate'; +import { migrate as migratePromoBonus } from '@openora/core/promo/migrate/bonus'; +import { migrate as migratePromoGamification } from '@openora/core/promo/migrate/gamification'; const DEFAULT_TEST_URL = 'postgres://postgres:postgres@localhost:5432/oss_igaming_test'; @@ -41,6 +43,8 @@ async function applyAllMigrations(url: string): Promise { await migrateNotifications(url); await migrateSocial(url); await migrateExchangeRate(url); + await migratePromoBonus(url); + await migratePromoGamification(url); } export async function applyMigrations(url: string): Promise {