From cd7d2afb492ffbb7ae71376b99760ef806f0fb01 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Wed, 16 Sep 2026 18:57:29 +0200 Subject: [PATCH 1/8] feat(promo): grant creation with a durable idempotency guard --- .../src/contracts/adapters/bonus-grants.ts | 14 + packages/core/src/contracts/adapters/index.ts | 7 +- .../bonus/__tests__/grant.service.int.test.ts | 177 ++++++++ .../core/src/promo/bonus/contract/index.ts | 37 ++ .../migrations/0001_zippy_weapon_omega.sql | 23 ++ .../migrations/meta/0001_snapshot.json | 379 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/bonus/index.ts | 1 + packages/core/src/promo/bonus/plugin.ts | 7 +- packages/core/src/promo/bonus/schema/index.ts | 61 ++- .../src/promo/bonus/service/grant.service.ts | 95 +++++ .../src/server/runtime/core-token-catalog.ts | 2 +- 12 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json create mode 100644 packages/core/src/promo/bonus/service/grant.service.ts diff --git a/packages/core/src/contracts/adapters/bonus-grants.ts b/packages/core/src/contracts/adapters/bonus-grants.ts index 53ecd0e6..7833d552 100644 --- a/packages/core/src/contracts/adapters/bonus-grants.ts +++ b/packages/core/src/contracts/adapters/bonus-grants.ts @@ -7,6 +7,19 @@ import type { BonusGrantSource } from '../schemas/promo.js'; import { createToken, type Token } from './token.js'; +/** + * The terms a grant is created under, snapshotted onto the grant row. Editing an offer must + * not change a bonus a player already holds, so nothing here is read from config again later. + */ +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 that scores this grant's bets. */ + weightProfileId: string; +}; + export type BonusGrantArgs = { userId: string; currency: string; @@ -21,6 +34,7 @@ export type BonusGrantArgs = { sourceRef: string; /** 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..d17ab896 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -48,7 +48,12 @@ 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, + BonusGrantArgs, + BonusGrantOutcome, + BonusGrantTerms, +} from './bonus-grants.js'; export { BONUS_GRANTS } from './bonus-grants.js'; export type { 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..99a33930 --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { findOneOrThrow, type DrizzleTx } from '@openora/core/server'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import type { AuditWritePort, BonusGrantArgs, Uuid } from '@openora/core/contracts'; +import { migrate } from '../migrate.js'; +import { promoGrant, promoWeightProfile } from '../schema/index.js'; +import { GrantService } from '../service/grant.service.js'; + +let db: TestDb; +let service: GrantService; +let audit: AuditWritePort; +let weightProfileId: Uuid; + +const args = (over: Partial = {}): BonusGrantArgs => ({ + userId: randomUUID(), + currency: 'USD', + amount: '100', + source: 'deposit', + sourceRef: randomUUID(), + terms: { wageringMultiplier: '35', expiryDays: 30, weightProfileId }, + ...over, +}); + +const grant = (a: BonusGrantArgs) => + db.drizzle.db.transaction((tx) => service.grant(tx as unknown as DrizzleTx, a)); + +const rows = () => db.drizzle.db.select().from(promoGrant); + +beforeAll(async () => { + db = await createTestDb([migrate]); + audit = { record: vi.fn(), recordInTransaction: vi.fn() }; + service = new GrantService(audit); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute(sql`TRUNCATE ${promoGrant} RESTART IDENTITY CASCADE`); + vi.clearAllMocks(); + weightProfileId = findOneOrThrow( + await db.drizzle.db + .insert(promoWeightProfile) + .values({ name: `profile-${randomUUID()}` }) + .returning(), + new Error('seed profile: query returned no row'), + ).id as Uuid; +}); + +describe('GrantService.grant (real PG)', () => { + it('GRT-01: 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', + wageringRequired: '3500.000000000000000000', + wageringProgress: '0.000000000000000000', + status: 'active', + }); + }); + + it('snapshots the terms, so a later config change cannot reach a granted bonus', async () => { + await grant(args({ terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId } })); + + const [row] = await rows(); + expect(row?.terms).toEqual({ wageringMultiplier: '35', expiryDays: 7, weightProfileId }); + }); + + 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('IDM-01: 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('IDM-03: 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('IDM-09: 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' })); + + 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('LDG-01: 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('VAL-01: 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('VAL-02: rejects an amount that is not a decimal string', async () => { + await expect(grant(args({ amount: 'NaN' }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('VAL-03: rejects a multiplier below zero', async () => { + await expect(grant(args({ terms: termsWith('-1') }))).rejects.toThrow(); + expect(await rows()).toHaveLength(0); + }); + + it('VAL-04: 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('allows a zero multiplier, which is a bonus with no wagering attached', async () => { + await grant(args({ terms: termsWith('0') })); + + const [row] = await rows(); + expect(row?.wageringRequired).toBe('0.000000000000000000'); + }); + + 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 as unknown as DrizzleTx, a); + throw new Error('caller failed after the grant'); + }), + ).rejects.toThrow('caller failed after the grant'); + + expect(await rows()).toHaveLength(0); + }); +}); + +function termsWith(wageringMultiplier: string, expiryDays = 30) { + return { wageringMultiplier, expiryDays, weightProfileId }; +} diff --git a/packages/core/src/promo/bonus/contract/index.ts b/packages/core/src/promo/bonus/contract/index.ts index e32c0559..4f8ebc80 100644 --- a/packages/core/src/promo/bonus/contract/index.ts +++ b/packages/core/src/promo/bonus/contract/index.ts @@ -30,4 +30,41 @@ export const WagerWeightProfileSchema = z.object({ export type WagerWeightProfile = z.infer; +/** + * `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', + 'completed', + 'expired', + 'forfeited', + 'cancelled', +] as const; +export type BonusGrantStatus = (typeof BONUS_GRANT_STATUSES)[number]; + +/** Why an active grant was taken away. Recorded on every forfeit, for the regulator. */ +export const BONUS_FORFEIT_REASONS = [ + 'self_exclusion', + 'account_closed', + 'admin', + 'player_opt_out', + 'withdrawal_while_active', +] as const; +export type BonusForfeitReason = (typeof BONUS_FORFEIT_REASONS)[number]; + +/** What caused a grant. Half of its idempotency key. */ +export const BONUS_GRANT_SOURCES = [ + 'deposit', + 'manual', + 'streak', + 'rank', + 'race', + 'gift', + 'rain', +] as const; +export const BonusGrantSourceSchema = z.enum(BONUS_GRANT_SOURCES); + export const bonusContract = {}; diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql b/packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql new file mode 100644 index 00000000..35e19fc1 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql @@ -0,0 +1,23 @@ +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, + "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, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "promo_grant_user_id_source_source_ref_index" ON "promo_grant" USING btree ("user_id","source","source_ref");--> statement-breakpoint +CREATE INDEX "promo_grant_user_id_currency_status_created_at_index" ON "promo_grant" USING btree ("user_id","currency","status","created_at");--> statement-breakpoint +CREATE INDEX "promo_grant_expires_at_index" 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/meta/0001_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..6ead6b6b --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,379 @@ +{ + "id": "ffb33b06-e89f-41d8-9f57-f8a36bfff0c2", + "prevId": "aaed7415-d3af-410d-8b4e-b86174d4dd66", + "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 + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_grant_user_id_source_source_ref_index": { + "name": "promo_grant_user_id_source_source_ref_index", + "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_status_created_at_index": { + "name": "promo_grant_user_id_currency_status_created_at_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_expires_at_index": { + "name": "promo_grant_expires_at_index", + "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": {}, + "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(38, 18)", + "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_index": { + "name": "promo_weight_profile_id_scope_scope_ref_index", + "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_index": { + "name": "promo_weight_profile_id_index", + "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": {}, + "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..e32f773c 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,13 @@ "when": 1789687196995, "tag": "0000_short_jean_grey", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789573016383, + "tag": "0001_zippy_weapon_omega", + "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..382de0e9 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, + type BonusGrantSource, + type BonusGrantTerms, } from '@openora/core/contracts'; -import { WAGER_WEIGHT_SCOPES, type WagerWeightScope } from '../contract/index.js'; +import { + BONUS_FORFEIT_REASONS, + BONUS_GRANT_SOURCES, + BONUS_GRANT_STATUSES, + WAGER_WEIGHT_SCOPES, + type BonusForfeitReason, + type BonusGrantStatus, + type WagerWeightScope, +} from '../contract/index.js'; export const promoWeightScopeEnum = pgEnum('promo_weight_scope', WAGER_WEIGHT_SCOPES); @@ -67,5 +79,52 @@ export const promoWeight = pgTable( ], ); +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. There is no separate balance column and no balance table: a grant + * row is the bonus, and what a player has left is derived from its own numbers. + * + * `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(), + 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(), + 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().on(t.userId, t.source, t.sourceRef), + // FIFO consumption order and the balance read. + index().on(t.userId, t.currency, t.status, t.createdAt), + // The expiry sweep, over live rows only. + index() + .on(t.expiresAt) + .where(sql`${t.status} = 'active'`), + ], +); + +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..cfa344b1 --- /dev/null +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -0,0 +1,95 @@ +import { and, eq, sql } from 'drizzle-orm'; +import * as z from 'zod'; +import { + MoneyAmountSchema, + UuidSchema, + type AuditWritePort, + type BonusGrantArgs, + type BonusGrantCommands, + type BonusGrantOutcome, +} from '@openora/core/contracts'; +import { isPositiveMoney, moneyScaleBy, type DrizzleTx } from '@openora/core/server'; +import { BonusGrantSourceSchema } from '../contract/index.js'; +import { promoGrant } from '../schema/index.js'; + +// Untrusted at the boundary: a caller is another module, and a malformed multiplier or a +// non-positive amount must be refused before it reaches the ledger, not corrected after. +const grantArgsSchema = z.object({ + userId: UuidSchema, + currency: z.string().min(1), + amount: MoneyAmountSchema.refine(isPositiveMoney, 'must be greater than zero'), + source: BonusGrantSourceSchema, + sourceRef: z.string().min(1), + offerId: UuidSchema.optional(), + terms: z.object({ + wageringMultiplier: MoneyAmountSchema, + expiryDays: z.number().int().positive(), + weightProfileId: UuidSchema, + }), +}); + +/** + * 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); + + // 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: args.terms, + grantedAmount: args.amount, + wageringRequired, + expiresAt: sql`now() + make_interval(days => ${args.terms.expiryDays})`, + }) + .onConflictDoNothing() + .returning({ id: promoGrant.id }); + + if (!inserted) { + const [existing] = await tx + .select({ id: promoGrant.id }) + .from(promoGrant) + .where( + and( + eq(promoGrant.userId, args.userId), + eq(promoGrant.source, args.source), + eq(promoGrant.sourceRef, args.sourceRef), + ), + ); + if (!existing) { + throw new Error('promo grant: insert conflicted but no existing grant was found'); + } + return { ok: true, grantId: existing.id, created: false }; + } + + await this.audit.recordInTransaction(tx, { + actorType: 'system', + 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, + terms: args.terms, + }, + }); + + return { ok: true, grantId: inserted.id, created: true }; + } +} 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'; From 890383e4ab89be8fdb19b9ed0159702f4f229d59 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 17 Sep 2026 15:14:08 +0200 Subject: [PATCH 2/8] fix(promo): snapshot weight profile rows onto the grant 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. --- .../src/contracts/adapters/bonus-grants.ts | 7 +- .../bonus/__tests__/grant.service.int.test.ts | 65 ++++++++++++++++++- packages/core/src/promo/bonus/schema/index.ts | 9 ++- .../src/promo/bonus/service/grant.service.ts | 39 ++++++++++- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/packages/core/src/contracts/adapters/bonus-grants.ts b/packages/core/src/contracts/adapters/bonus-grants.ts index 7833d552..167b1f12 100644 --- a/packages/core/src/contracts/adapters/bonus-grants.ts +++ b/packages/core/src/contracts/adapters/bonus-grants.ts @@ -8,15 +8,16 @@ import type { BonusGrantSource } from '../schemas/promo.js'; import { createToken, type Token } from './token.js'; /** - * The terms a grant is created under, snapshotted onto the grant row. Editing an offer must - * not change a bonus a player already holds, so nothing here is read from config again later. + * 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 that scores this grant's bets. */ + /** Weight profile whose rows are copied onto the grant to score its bets. */ weightProfileId: string; }; 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 index 99a33930..18178191 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -5,7 +5,8 @@ import { findOneOrThrow, type DrizzleTx } from '@openora/core/server'; import { createTestDb, type TestDb } from '@openora/core/testing'; import type { AuditWritePort, BonusGrantArgs, Uuid } from '@openora/core/contracts'; import { migrate } from '../migrate.js'; -import { promoGrant, promoWeightProfile } from '../schema/index.js'; +import { promoGrant, promoWeight, promoWeightProfile } from '../schema/index.js'; +import { BonusService } from '../service/bonus.service.js'; import { GrantService } from '../service/grant.service.js'; let db: TestDb; @@ -64,11 +65,57 @@ describe('GrantService.grant (real PG)', () => { }); }); - it('snapshots the terms, so a later config change cannot reach a granted bonus', async () => { + it('snapshots the terms and the profile weights onto the grant', async () => { + await seedWeight('product', 'casino', '100'); await grant(args({ terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId } })); const [row] = await rows(); - expect(row?.terms).toEqual({ wageringMultiplier: '35', expiryDays: 7, weightProfileId }); + expect(row?.terms).toEqual({ + wageringMultiplier: '35', + expiryDays: 7, + weightProfileId, + weights: [ + { scope: 'product', scopeRef: 'casino', contributionPercent: '100.000000000000000000' }, + ], + }); + }); + + 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' }); + + const [row] = await rows(); + expect( + new BonusService().weightedContribution({ terms: row!.terms, stake: '50', context: CASINO }), + ).toMatchObject({ weightedAmount: '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); + + const [row] = await rows(); + expect( + new BonusService().weightedContribution({ terms: row!.terms, stake: '50', context: CASINO }), + ).toMatchObject({ weightedAmount: '50.000000000000000000' }); + }); + + it('snapshots an empty weight set for a profile with no rows, so no bet counts', async () => { + await grant(args()); + + const [row] = await rows(); + expect(row?.terms.weights).toEqual([]); + }); + + it('refuses a grant whose weight profile does not exist', async () => { + await expect( + grant( + args({ terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId: randomUUID() } }), + ), + ).rejects.toThrow('weight profile'); + expect(await rows()).toHaveLength(0); }); it('expires the grant `expiryDays` after it was created', async () => { @@ -172,6 +219,18 @@ describe('GrantService.grant (real PG)', () => { }); }); +const CASINO = { provider: 'aggregator', product: 'casino' }; + +function seedWeight( + scope: (typeof promoWeight.$inferInsert)['scope'], + scopeRef: string | null, + contributionPercent: string, +) { + return db.drizzle.db + .insert(promoWeight) + .values({ profileId: weightProfileId, scope, scopeRef, contributionPercent }); +} + function termsWith(wageringMultiplier: string, expiryDays = 30) { return { wageringMultiplier, expiryDays, weightProfileId }; } diff --git a/packages/core/src/promo/bonus/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index 382de0e9..89f17281 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -26,6 +26,7 @@ import { type BonusGrantStatus, type WagerWeightScope, } from '../contract/index.js'; +import type { WagerWeightRow } from '../shared/wagering-weight.js'; export const promoWeightScopeEnum = pgEnum('promo_weight_scope', WAGER_WEIGHT_SCOPES); @@ -79,6 +80,12 @@ 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); @@ -101,7 +108,7 @@ export const promoGrant = pgTable( // The deposit transaction, the `:` job key, the race id. sourceRef: text().notNull(), offerId: uuid(), - terms: jsonb().$type().notNull(), + terms: jsonb().$type().notNull(), grantedAmount: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), wageringRequired: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), wageringProgress: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }) diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts index cfa344b1..144af8b6 100644 --- a/packages/core/src/promo/bonus/service/grant.service.ts +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -10,7 +10,12 @@ import { } from '@openora/core/contracts'; import { isPositiveMoney, moneyScaleBy, type DrizzleTx } from '@openora/core/server'; import { BonusGrantSourceSchema } from '../contract/index.js'; -import { promoGrant } from '../schema/index.js'; +import { + promoGrant, + promoWeight, + promoWeightProfile, + type GrantTermsSnapshot, +} from '../schema/index.js'; // Untrusted at the boundary: a caller is another module, and a malformed multiplier or a // non-positive amount must be refused before it reaches the ledger, not corrected after. @@ -38,6 +43,7 @@ export class GrantService implements BonusGrantCommands { async grant(tx: DrizzleTx, rawArgs: BonusGrantArgs): Promise { const args = grantArgsSchema.parse(rawArgs); const wageringRequired = moneyScaleBy(args.amount, args.terms.wageringMultiplier); + 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. @@ -49,7 +55,7 @@ export class GrantService implements BonusGrantCommands { source: args.source, sourceRef: args.sourceRef, offerId: args.offerId ?? null, - terms: args.terms, + terms, grantedAmount: args.amount, wageringRequired, expiresAt: sql`now() + make_interval(days => ${args.terms.expiryDays})`, @@ -86,10 +92,37 @@ export class GrantService implements BonusGrantCommands { sourceRef: args.sourceRef, grantedAmount: args.amount, wageringRequired, - terms: args.terms, + terms, }, }); return { ok: true, grantId: inserted.id, created: true }; } + + // 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 Error(`promo grant: weight profile ${terms.weightProfileId} not found`); + } + const weights = rows.flatMap((r) => + r.scope && r.contributionPercent + ? [{ scope: r.scope, scopeRef: r.scopeRef, contributionPercent: r.contributionPercent }] + : [], + ); + return { ...terms, weights }; + } } From 8b7c20596b6961a48e004e58d4fb3d25d873612d Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 18 Sep 2026 00:57:57 +0200 Subject: [PATCH 3/8] feat(promo): hold the bonus balance on the grant row 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. --- .../bonus/__tests__/grant.service.int.test.ts | 3 +++ ..._omega.sql => 0001_strong_black_queen.sql} | 3 +++ .../migrations/meta/0001_snapshot.json | 21 ++++++++++++++++++- .../drizzle/migrations/meta/_journal.json | 4 ++-- packages/core/src/promo/bonus/schema/index.ts | 14 +++++++++++-- .../src/promo/bonus/service/grant.service.ts | 5 +++++ packages/testing/src/db.ts | 4 ++++ 7 files changed, 49 insertions(+), 5 deletions(-) rename packages/core/src/promo/bonus/drizzle/migrations/{0001_zippy_weapon_omega.sql => 0001_strong_black_queen.sql} (91%) 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 index 18178191..834e1e7c 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -59,10 +59,13 @@ describe('GrantService.grant (real PG)', () => { 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('snapshots the terms and the profile weights onto the grant', async () => { diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql b/packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql similarity index 91% rename from packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql rename to packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql index 35e19fc1..f6fb29f3 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/0001_zippy_weapon_omega.sql +++ b/packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql @@ -10,11 +10,14 @@ CREATE TABLE "promo_grant" ( "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 ); --> statement-breakpoint 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 index 6ead6b6b..d56585a7 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json @@ -1,5 +1,5 @@ { - "id": "ffb33b06-e89f-41d8-9f57-f8a36bfff0c2", + "id": "b4340622-844b-4b2c-b865-f54e2b62cdae", "prevId": "aaed7415-d3af-410d-8b4e-b86174d4dd66", "version": "7", "dialect": "postgresql", @@ -58,6 +58,13 @@ "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)", @@ -92,6 +99,18 @@ "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", 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 e32f773c..c0e138a9 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -12,8 +12,8 @@ { "idx": 1, "version": "7", - "when": 1789573016383, - "tag": "0001_zippy_weapon_omega", + "when": 1789685753946, + "tag": "0001_strong_black_queen", "breakpoints": true } ] diff --git a/packages/core/src/promo/bonus/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index 89f17281..bcf27c76 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -91,8 +91,9 @@ export const promoGrantSourceEnum = pgEnum('promo_grant_source', BONUS_GRANT_SOU export const promoForfeitReasonEnum = pgEnum('promo_forfeit_reason', BONUS_FORFEIT_REASONS); /** - * One bonus a player holds. There is no separate balance column and no balance table: a grant - * row is the bonus, and what a player has left is derived from its own numbers. + * 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. @@ -110,6 +111,11 @@ export const promoGrant = pgTable( 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() @@ -117,6 +123,10 @@ export const promoGrant = pgTable( 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) => [ diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts index 144af8b6..3f6e0d93 100644 --- a/packages/core/src/promo/bonus/service/grant.service.ts +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -57,8 +57,12 @@ export class GrantService implements BonusGrantCommands { offerId: args.offerId ?? null, terms, grantedAmount: args.amount, + // The grant row is the bonus balance: the funds start here and never sit in + // `wallet_balance` until they convert. + bonusBalance: args.amount, wageringRequired, expiresAt: sql`now() + make_interval(days => ${args.terms.expiryDays})`, + activatedAt: sql`now()`, }) .onConflictDoNothing() .returning({ id: promoGrant.id }); @@ -91,6 +95,7 @@ export class GrantService implements BonusGrantCommands { source: args.source, sourceRef: args.sourceRef, grantedAmount: args.amount, + bonusBalance: args.amount, wageringRequired, terms, }, 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 { From 682d3c1b12745dfc92b61ed60fc613c028f09ceb Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 18 Sep 2026 01:29:35 +0200 Subject: [PATCH 4/8] refactor(promo): attribute a manual grant and bound the grant inputs 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. --- .../adr/0040-bonus-funds-live-on-the-grant.md | 58 +++++++ .../src/contracts/adapters/bonus-grants.ts | 7 + packages/core/src/contracts/adapters/index.ts | 1 + .../bonus/__tests__/grant.service.int.test.ts | 155 +++++++++++------- ...sql => 0001_talented_human_cannonball.sql} | 11 +- .../migrations/meta/0001_snapshot.json | 93 ++++++++--- .../drizzle/migrations/meta/_journal.json | 4 +- packages/core/src/promo/bonus/schema/index.ts | 24 ++- .../src/promo/bonus/service/grant.service.ts | 101 +++++++++--- 9 files changed, 332 insertions(+), 122 deletions(-) create mode 100644 docs/adr/0040-bonus-funds-live-on-the-grant.md rename packages/core/src/promo/bonus/drizzle/migrations/{0001_strong_black_queen.sql => 0001_talented_human_cannonball.sql} (54%) 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 167b1f12..2a766b90 100644 --- a/packages/core/src/contracts/adapters/bonus-grants.ts +++ b/packages/core/src/contracts/adapters/bonus-grants.ts @@ -21,6 +21,12 @@ export type BonusGrantTerms = { 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; @@ -33,6 +39,7 @@ 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; diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index d17ab896..f349d951 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -50,6 +50,7 @@ export { WAGER_PRODUCTS, isWagerProduct } from './wager-context.js'; export type { BonusGrantCommands, + BonusGrantActor, BonusGrantArgs, BonusGrantOutcome, BonusGrantTerms, 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 index 834e1e7c..5981f946 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -1,37 +1,58 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { sql } from 'drizzle-orm'; -import { findOneOrThrow, type DrizzleTx } from '@openora/core/server'; +import { eq } from 'drizzle-orm'; +import { findOneOrThrow } from '@openora/core/server'; import { createTestDb, type TestDb } from '@openora/core/testing'; -import type { AuditWritePort, BonusGrantArgs, Uuid } from '@openora/core/contracts'; +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 { BonusService } from '../service/bonus.service.js'; import { GrantService } from '../service/grant.service.js'; +import { resolveContributionPercent, weightedStake } from '../shared/wagering-weight.js'; let db: TestDb; let service: GrantService; -let audit: AuditWritePort; +let audit: ReturnType; let weightProfileId: Uuid; +const CASINO = { provider: 'aggregator', product: 'casino' }; + +const termsWith = (wageringMultiplier: string, expiryDays = 30) => ({ + wageringMultiplier, + expiryDays, + weightProfileId, +}); + const args = (over: Partial = {}): BonusGrantArgs => ({ userId: randomUUID(), currency: 'USD', amount: '100', source: 'deposit', sourceRef: randomUUID(), - terms: { wageringMultiplier: '35', expiryDays: 30, weightProfileId }, + actor: { type: 'system' }, + terms: termsWith('35'), ...over, }); -const grant = (a: BonusGrantArgs) => - db.drizzle.db.transaction((tx) => service.grant(tx as unknown as DrizzleTx, a)); +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 = { record: vi.fn(), recordInTransaction: vi.fn() }; + audit = makeAuditWriter(); service = new GrantService(audit); }); @@ -40,7 +61,9 @@ afterAll(async () => { }); beforeEach(async () => { - await db.drizzle.db.execute(sql`TRUNCATE ${promoGrant} RESTART IDENTITY CASCADE`); + 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 @@ -48,11 +71,11 @@ beforeEach(async () => { .values({ name: `profile-${randomUUID()}` }) .returning(), new Error('seed profile: query returned no row'), - ).id as Uuid; + ).id; }); -describe('GrantService.grant (real PG)', () => { - it('GRT-01: credits the bonus and derives the requirement from the multiplier', async () => { +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 }); @@ -68,41 +91,47 @@ describe('GrantService.grant (real PG)', () => { 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: { wageringMultiplier: '35', expiryDays: 7, weightProfileId } })); + await grant(args({ terms: termsWith('35', 7) })); const [row] = await rows(); expect(row?.terms).toEqual({ wageringMultiplier: '35', expiryDays: 7, weightProfileId, - weights: [ - { scope: 'product', scopeRef: 'casino', contributionPercent: '100.000000000000000000' }, - ], + weights: [{ 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' }); + await db.drizzle.db + .update(promoWeight) + .set({ contributionPercent: '10' }) + .where(eq(promoWeight.profileId, weightProfileId)); const [row] = await rows(); - expect( - new BonusService().weightedContribution({ terms: row!.terms, stake: '50', context: CASINO }), - ).toMatchObject({ weightedAmount: '50.000000000000000000' }); + 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); + await db.drizzle.db + .delete(promoWeightProfile) + .where(eq(promoWeightProfile.id, weightProfileId)); const [row] = await rows(); - expect( - new BonusService().weightedContribution({ terms: row!.terms, stake: '50', context: CASINO }), - ).toMatchObject({ weightedAmount: '50.000000000000000000' }); + expect(scoreOf(row!.terms, '50')).toBe('50.000000000000000000'); }); it('snapshots an empty weight set for a profile with no rows, so no bet counts', async () => { @@ -117,8 +146,7 @@ describe('GrantService.grant (real PG)', () => { grant( args({ terms: { wageringMultiplier: '35', expiryDays: 7, weightProfileId: randomUUID() } }), ), - ).rejects.toThrow('weight profile'); - expect(await rows()).toHaveLength(0); + ).rejects.toThrow(/WagerWeightProfile/i); }); it('expires the grant `expiryDays` after it was created', async () => { @@ -129,7 +157,7 @@ describe('GrantService.grant (real PG)', () => { expect(days).toBeCloseTo(7, 5); }); - it('IDM-01: a replayed deposit returns the first grant and creates no second row', async () => { + it('a replayed deposit returns the first grant and creates no second row', async () => { const a = args(); const first = await grant(a); @@ -139,7 +167,15 @@ describe('GrantService.grant (real PG)', () => { expect(await rows()).toHaveLength(1); }); - it('IDM-03: concurrent duplicates settle to one grant, the index is the guard', async () => { + 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)]); @@ -149,12 +185,14 @@ describe('GrantService.grant (real PG)', () => { expect(new Set(outcomes.map((o) => (o.ok ? o.grantId : null))).size).toBe(1); }); - it('IDM-09: the same source ref under a different source is a different grant', async () => { + 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' })); + await grant( + args({ userId, sourceRef, source: 'manual', actor: { type: 'admin', id: randomUUID() } }), + ); expect(await rows()).toHaveLength(2); }); @@ -168,7 +206,7 @@ describe('GrantService.grant (real PG)', () => { expect(await rows()).toHaveLength(2); }); - it('LDG-01: writes one audit row per grant, on the same transaction', async () => { + it('writes one audit row per grant, on the same transaction', async () => { const a = args(); await grant(a); await grant(a); @@ -180,40 +218,53 @@ describe('GrantService.grant (real PG)', () => { ); }); - it('VAL-01: rejects a zero or negative amount before it reaches the ledger', async () => { + 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('VAL-02: rejects an amount that is not a decimal string', async () => { + 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('VAL-03: rejects a multiplier below zero', async () => { - await expect(grant(args({ terms: termsWith('-1') }))).rejects.toThrow(); + 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('VAL-04: 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(); + it('rejects a negative multiplier', async () => { + await expect(grant(args({ terms: termsWith('-1') }))).rejects.toThrow(); expect(await rows()).toHaveLength(0); }); - it('allows a zero multiplier, which is a bonus with no wagering attached', async () => { - await grant(args({ terms: termsWith('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); + }); - const [row] = await rows(); - expect(row?.wageringRequired).toBe('0.000000000000000000'); + 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('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 as unknown as DrizzleTx, a); + await service.grant(tx, a); throw new Error('caller failed after the grant'); }), ).rejects.toThrow('caller failed after the grant'); @@ -221,19 +272,3 @@ describe('GrantService.grant (real PG)', () => { expect(await rows()).toHaveLength(0); }); }); - -const CASINO = { provider: 'aggregator', product: 'casino' }; - -function seedWeight( - scope: (typeof promoWeight.$inferInsert)['scope'], - scopeRef: string | null, - contributionPercent: string, -) { - return db.drizzle.db - .insert(promoWeight) - .values({ profileId: weightProfileId, scope, scopeRef, contributionPercent }); -} - -function termsWith(wageringMultiplier: string, expiryDays = 30) { - return { wageringMultiplier, expiryDays, weightProfileId }; -} diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql b/packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql similarity index 54% rename from packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql rename to packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql index f6fb29f3..ac0ec5c1 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/0001_strong_black_queen.sql +++ b/packages/core/src/promo/bonus/drizzle/migrations/0001_talented_human_cannonball.sql @@ -18,9 +18,12 @@ CREATE TABLE "promo_grant" ( "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 + "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_index" ON "promo_grant" USING btree ("user_id","source","source_ref");--> statement-breakpoint -CREATE INDEX "promo_grant_user_id_currency_status_created_at_index" ON "promo_grant" USING btree ("user_id","currency","status","created_at");--> statement-breakpoint -CREATE INDEX "promo_grant_expires_at_index" ON "promo_grant" USING btree ("expires_at") WHERE "promo_grant"."status" = 'active'; \ No newline at end of file +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/meta/0001_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json index d56585a7..c3c00727 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "b4340622-844b-4b2c-b865-f54e2b62cdae", - "prevId": "aaed7415-d3af-410d-8b4e-b86174d4dd66", + "id": "2f2e0f6c-e8e0-42c3-ae04-db27bf93c1e1", + "prevId": "2ac20ab4-1698-4d57-9d07-9e0cdc76857c", "version": "7", "dialect": "postgresql", "tables": { @@ -120,8 +120,8 @@ } }, "indexes": { - "promo_grant_user_id_source_source_ref_index": { - "name": "promo_grant_user_id_source_source_ref_index", + "promo_grant_user_id_source_source_ref_idx": { + "name": "promo_grant_user_id_source_source_ref_idx", "columns": [ { "expression": "user_id", @@ -147,8 +147,8 @@ "method": "btree", "with": {} }, - "promo_grant_user_id_currency_status_created_at_index": { - "name": "promo_grant_user_id_currency_status_created_at_index", + "promo_grant_user_id_currency_created_at_idx": { + "name": "promo_grant_user_id_currency_created_at_idx", "columns": [ { "expression": "user_id", @@ -162,12 +162,6 @@ "asc": true, "nulls": "last" }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, { "expression": "created_at", "isExpression": false, @@ -176,12 +170,13 @@ } ], "isUnique": false, + "where": "\"promo_grant\".\"status\" in ('pending', 'active')", "concurrently": false, "method": "btree", "with": {} }, - "promo_grant_expires_at_index": { - "name": "promo_grant_expires_at_index", + "promo_grant_expires_at_idx": { + "name": "promo_grant_expires_at_idx", "columns": [ { "expression": "expires_at", @@ -201,7 +196,20 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "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": { @@ -236,7 +244,7 @@ }, "contribution_percent": { "name": "contribution_percent", - "type": "numeric(38, 18)", + "type": "numeric(5, 2)", "primaryKey": false, "notNull": true }, @@ -249,8 +257,8 @@ } }, "indexes": { - "promo_weight_profile_id_scope_scope_ref_index": { - "name": "promo_weight_profile_id_scope_scope_ref_index", + "promo_weight_profile_id_scope_scope_ref_idx": { + "name": "promo_weight_profile_id_scope_scope_ref_idx", "columns": [ { "expression": "profile_id", @@ -276,8 +284,8 @@ "method": "btree", "with": {} }, - "promo_weight_profile_id_index": { - "name": "promo_weight_profile_id_index", + "promo_weight_profile_id_default_idx": { + "name": "promo_weight_profile_id_default_idx", "columns": [ { "expression": "profile_id", @@ -298,8 +306,12 @@ "name": "promo_weight_profile_id_promo_weight_profile_id_fk", "tableFrom": "promo_weight", "tableTo": "promo_weight_profile", - "columnsFrom": ["profile_id"], - "columnsTo": ["id"], + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], "onDelete": "cascade", "onUpdate": "no action" } @@ -307,7 +319,12 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, - "checkConstraints": {}, + "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": { @@ -349,7 +366,9 @@ "promo_weight_profile_name_unique": { "name": "promo_weight_profile_name_unique", "nullsNotDistinct": false, - "columns": ["name"] + "columns": [ + "name" + ] } }, "policies": {}, @@ -372,17 +391,37 @@ "public.promo_grant_source": { "name": "promo_grant_source", "schema": "public", - "values": ["deposit", "manual", "streak", "rank", "race", "gift", "rain"] + "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"] + "values": [ + "pending", + "active", + "completed", + "expired", + "forfeited", + "cancelled" + ] }, "public.promo_weight_scope": { "name": "promo_weight_scope", "schema": "public", - "values": ["game", "category", "product", "default"] + "values": [ + "game", + "category", + "product", + "default" + ] } }, "schemas": {}, 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 c0e138a9..cc7d28b5 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -12,8 +12,8 @@ { "idx": 1, "version": "7", - "when": 1789685753946, - "tag": "0001_strong_black_queen", + "when": 1789687709950, + "tag": "0001_talented_human_cannonball", "breakpoints": true } ] diff --git a/packages/core/src/promo/bonus/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index bcf27c76..f91ffe5d 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -14,6 +14,8 @@ import { import { CONTRIBUTION_PERCENT_PRECISION, CONTRIBUTION_PERCENT_SCALE, + MONEY_PRECISION, + MONEY_SCALE, type BonusGrantSource, type BonusGrantTerms, } from '@openora/core/contracts'; @@ -132,13 +134,27 @@ export const promoGrant = pgTable( (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().on(t.userId, t.source, t.sourceRef), - // FIFO consumption order and the balance read. - index().on(t.userId, t.currency, t.status, t.createdAt), + 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() + 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}`, + ), + check( + 'promo_grant_forfeit_reason_requires_forfeited', + sql`${t.forfeitReason} is null or ${t.status} = 'forfeited'`, + ), ], ); diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts index 3f6e0d93..7bdda643 100644 --- a/packages/core/src/promo/bonus/service/grant.service.ts +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from 'drizzle-orm'; import * as z from 'zod'; import { + CurrencyTickerInputSchema, MoneyAmountSchema, UuidSchema, type AuditWritePort, @@ -8,7 +9,14 @@ import { type BonusGrantCommands, type BonusGrantOutcome, } from '@openora/core/contracts'; -import { isPositiveMoney, moneyScaleBy, type DrizzleTx } from '@openora/core/server'; +import { + isPositiveMoney, + makeConflictError, + makeNotFoundError, + moneyCompare, + moneyScaleBy, + type DrizzleTx, +} from '@openora/core/server'; import { BonusGrantSourceSchema } from '../contract/index.js'; import { promoGrant, @@ -17,17 +25,35 @@ import { type GrantTermsSnapshot, } from '../schema/index.js'; -// Untrusted at the boundary: a caller is another module, and a malformed multiplier or a -// non-positive amount must be refused before it reaches the ledger, not corrected after. +export const WagerWeightProfileNotFoundError = makeNotFoundError('WagerWeightProfile'); +export const GrantConflictError = makeConflictError( + 'GrantConflictError', + 'A different grant already exists for this source reference', +); + +// 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'; + const grantArgsSchema = z.object({ userId: UuidSchema, - currency: z.string().min(1), + 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, + 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(), weightProfileId: UuidSchema, }), @@ -57,8 +83,6 @@ export class GrantService implements BonusGrantCommands { offerId: args.offerId ?? null, terms, grantedAmount: args.amount, - // The grant row is the bonus balance: the funds start here and never sit in - // `wallet_balance` until they convert. bonusBalance: args.amount, wageringRequired, expiresAt: sql`now() + make_interval(days => ${args.terms.expiryDays})`, @@ -68,24 +92,12 @@ export class GrantService implements BonusGrantCommands { .returning({ id: promoGrant.id }); if (!inserted) { - const [existing] = await tx - .select({ id: promoGrant.id }) - .from(promoGrant) - .where( - and( - eq(promoGrant.userId, args.userId), - eq(promoGrant.source, args.source), - eq(promoGrant.sourceRef, args.sourceRef), - ), - ); - if (!existing) { - throw new Error('promo grant: insert conflicted but no existing grant was found'); - } - return { ok: true, grantId: existing.id, created: false }; + return this.resolveReplay(tx, args, wageringRequired); } await this.audit.recordInTransaction(tx, { - actorType: 'system', + ...(args.actor.type === 'admin' ? { actorId: args.actor.id } : {}), + actorType: args.actor.type, action: 'promo.bonus.granted', resourceType: 'promo_grant', resourceId: inserted.id, @@ -95,15 +107,54 @@ export class GrantService implements BonusGrantCommands { source: args.source, sourceRef: args.sourceRef, grantedAmount: args.amount, - bonusBalance: args.amount, wageringRequired, - terms, + 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 resolveReplay( + 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) { + throw new GrantConflictError(); + } + 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( @@ -121,7 +172,7 @@ export class GrantService implements BonusGrantCommands { .where(eq(promoWeightProfile.id, terms.weightProfileId)); if (rows.length === 0) { - throw new Error(`promo grant: weight profile ${terms.weightProfileId} not found`); + throw new WagerWeightProfileNotFoundError(terms.weightProfileId); } const weights = rows.flatMap((r) => r.scope && r.contributionPercent From e8a45f054a1fbc86775e16f53636a72d46085957 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 18 Sep 2026 10:47:06 +0200 Subject: [PATCH 5/8] style(promo): format the grant migration snapshot --- .../migrations/meta/0001_snapshot.json | 38 +++---------------- 1 file changed, 6 insertions(+), 32 deletions(-) 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 index c3c00727..2782edf0 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0001_snapshot.json @@ -306,12 +306,8 @@ "name": "promo_weight_profile_id_promo_weight_profile_id_fk", "tableFrom": "promo_weight", "tableTo": "promo_weight_profile", - "columnsFrom": [ - "profile_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["profile_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -366,9 +362,7 @@ "promo_weight_profile_name_unique": { "name": "promo_weight_profile_name_unique", "nullsNotDistinct": false, - "columns": [ - "name" - ] + "columns": ["name"] } }, "policies": {}, @@ -391,37 +385,17 @@ "public.promo_grant_source": { "name": "promo_grant_source", "schema": "public", - "values": [ - "deposit", - "manual", - "streak", - "rank", - "race", - "gift", - "rain" - ] + "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" - ] + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] }, "public.promo_weight_scope": { "name": "promo_weight_scope", "schema": "public", - "values": [ - "game", - "category", - "product", - "default" - ] + "values": ["game", "category", "product", "default"] } }, "schemas": {}, From 875827ed334f640192059b1cea6eb90a19f9498e Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Wed, 23 Sep 2026 01:15:08 +0200 Subject: [PATCH 6/8] fix(promo): bound grant inputs and resolve a replay before live config 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. --- packages/core/src/contracts/schemas/promo.ts | 7 + .../bonus/__tests__/grant.service.int.test.ts | 36 ++ .../core/src/promo/bonus/contract/index.ts | 37 -- .../drizzle/migrations/0002_clever_nuke.sql | 2 + .../migrations/meta/0002_snapshot.json | 411 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/bonus/schema/index.ts | 17 +- .../src/promo/bonus/service/grant.service.ts | 79 ++-- 8 files changed, 522 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0002_clever_nuke.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0002_snapshot.json 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 index 5981f946..44f843ba 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -260,6 +260,42 @@ describe('GrantService.grant', () => { 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 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( diff --git a/packages/core/src/promo/bonus/contract/index.ts b/packages/core/src/promo/bonus/contract/index.ts index 4f8ebc80..e32c0559 100644 --- a/packages/core/src/promo/bonus/contract/index.ts +++ b/packages/core/src/promo/bonus/contract/index.ts @@ -30,41 +30,4 @@ export const WagerWeightProfileSchema = z.object({ export type WagerWeightProfile = z.infer; -/** - * `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', - 'completed', - 'expired', - 'forfeited', - 'cancelled', -] as const; -export type BonusGrantStatus = (typeof BONUS_GRANT_STATUSES)[number]; - -/** Why an active grant was taken away. Recorded on every forfeit, for the regulator. */ -export const BONUS_FORFEIT_REASONS = [ - 'self_exclusion', - 'account_closed', - 'admin', - 'player_opt_out', - 'withdrawal_while_active', -] as const; -export type BonusForfeitReason = (typeof BONUS_FORFEIT_REASONS)[number]; - -/** What caused a grant. Half of its idempotency key. */ -export const BONUS_GRANT_SOURCES = [ - 'deposit', - 'manual', - 'streak', - 'rank', - 'race', - 'gift', - 'rain', -] as const; -export const BonusGrantSourceSchema = z.enum(BONUS_GRANT_SOURCES); - export const bonusContract = {}; 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/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 cc7d28b5..eeefafce 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "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/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index f91ffe5d..e9111b75 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -16,18 +16,15 @@ import { CONTRIBUTION_PERCENT_SCALE, MONEY_PRECISION, MONEY_SCALE, - type BonusGrantSource, - type BonusGrantTerms, -} from '@openora/core/contracts'; -import { BONUS_FORFEIT_REASONS, BONUS_GRANT_SOURCES, BONUS_GRANT_STATUSES, - WAGER_WEIGHT_SCOPES, type BonusForfeitReason, + type BonusGrantSource, type BonusGrantStatus, - type WagerWeightScope, -} from '../contract/index.js'; + 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); @@ -151,9 +148,11 @@ export const promoGrant = pgTable( '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_requires_forfeited', - sql`${t.forfeitReason} is null or ${t.status} = 'forfeited'`, + 'promo_grant_forfeit_reason_matches_status', + sql`(${t.status} = 'forfeited') = (${t.forfeitReason} is not null)`, ), ], ); diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts index 7bdda643..638a6fec 100644 --- a/packages/core/src/promo/bonus/service/grant.service.ts +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -1,6 +1,7 @@ import { and, eq, sql } from 'drizzle-orm'; import * as z from 'zod'; import { + BonusGrantSourceSchema, CurrencyTickerInputSchema, MoneyAmountSchema, UuidSchema, @@ -17,7 +18,6 @@ import { moneyScaleBy, type DrizzleTx, } from '@openora/core/server'; -import { BonusGrantSourceSchema } from '../contract/index.js'; import { promoGrant, promoWeight, @@ -35,29 +35,40 @@ export const GrantConflictError = makeConflictError( // a fat finger rather than an offer. Both are refused before anything reaches the ledger. const MAX_WAGERING_MULTIPLIER = '1000'; -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(), - weightProfileId: UuidSchema, - }), -}); +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(), + weightProfileId: UuidSchema, + }), + }) + .refine((a) => a.source !== 'manual' || a.actor.type === 'admin', { + message: 'a manual grant must name the admin who issued it', + 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 @@ -69,6 +80,13 @@ export class GrantService implements BonusGrantCommands { 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 @@ -92,7 +110,12 @@ export class GrantService implements BonusGrantCommands { .returning({ id: promoGrant.id }); if (!inserted) { - return this.resolveReplay(tx, args, wageringRequired); + // 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, { @@ -122,11 +145,11 @@ export class GrantService implements BonusGrantCommands { * 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 resolveReplay( + private async findReplay( tx: DrizzleTx, args: z.infer, wageringRequired: string, - ): Promise { + ): Promise { const [existing] = await tx .select({ id: promoGrant.id, @@ -143,7 +166,7 @@ export class GrantService implements BonusGrantCommands { ), ); if (!existing) { - throw new GrantConflictError(); + return undefined; } const matches = existing.currency === args.currency && From 54a9015496da960a2965c343686d3bbc2eb37999 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 24 Sep 2026 01:42:14 +0200 Subject: [PATCH 7/8] fix(promo): attribute grants to the right actor and refuse dead weight profiles A non-manual grant could still name an admin actor, writing an automated bonus as a permanent admin audit action. Require the pairing both ways. Bound expiryDays so it cannot overflow make_interval, and refuse a weight profile with no positive weight, which could never progress a grant's wagering requirement. --- .../bonus/__tests__/grant.service.int.test.ts | 55 +++++++++++++++++-- .../src/promo/bonus/service/grant.service.ts | 18 +++++- 2 files changed, 65 insertions(+), 8 deletions(-) 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 index 44f843ba..69d54292 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -72,6 +72,9 @@ beforeEach(async () => { .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', () => { @@ -107,7 +110,9 @@ describe('GrantService.grant', () => { wageringMultiplier: '35', expiryDays: 7, weightProfileId, - weights: [{ scope: 'product', scopeRef: 'casino', contributionPercent: '100.00' }], + weights: expect.arrayContaining([ + { scope: 'product', scopeRef: 'casino', contributionPercent: '100.00' }, + ]), }); }); @@ -134,11 +139,39 @@ describe('GrantService.grant', () => { expect(scoreOf(row!.terms, '50')).toBe('50.000000000000000000'); }); - it('snapshots an empty weight set for a profile with no rows, so no bet counts', async () => { - await grant(args()); + 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; - const [row] = await rows(); - expect(row?.terms.weights).toEqual([]); + 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 () => { @@ -260,6 +293,11 @@ describe('GrantService.grant', () => { 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/, @@ -267,6 +305,13 @@ describe('GrantService.grant', () => { 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') })), diff --git a/packages/core/src/promo/bonus/service/grant.service.ts b/packages/core/src/promo/bonus/service/grant.service.ts index 638a6fec..11ba749d 100644 --- a/packages/core/src/promo/bonus/service/grant.service.ts +++ b/packages/core/src/promo/bonus/service/grant.service.ts @@ -30,11 +30,19 @@ 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, @@ -55,12 +63,12 @@ const grantArgsSchema = z (v) => moneyCompare(v, MAX_WAGERING_MULTIPLIER) <= 0, `must not exceed ${MAX_WAGERING_MULTIPLIER}`, ), - expiryDays: z.number().int().positive(), + 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', + .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 @@ -202,6 +210,10 @@ export class GrantService implements BonusGrantCommands { ? [{ 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 }; } } From b66730d9fbb1a51a66350fefb132705b4322564a Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 24 Sep 2026 02:24:33 +0200 Subject: [PATCH 8/8] fix(promo): satisfy the wager product literal type in the grant service test --- .../core/src/promo/bonus/__tests__/grant.service.int.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 69d54292..58dabb3f 100644 --- a/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts +++ b/packages/core/src/promo/bonus/__tests__/grant.service.int.test.ts @@ -15,7 +15,7 @@ let service: GrantService; let audit: ReturnType; let weightProfileId: Uuid; -const CASINO = { provider: 'aggregator', product: 'casino' }; +const CASINO = { provider: 'aggregator', product: 'casino' } as const; const termsWith = (wageringMultiplier: string, expiryDays = 30) => ({ wageringMultiplier,