From b38cfeb033de132c7a717686dca05422714e3b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:01 +0100 Subject: [PATCH 1/6] refactor(contracts): share the whole-day report range across report tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analytics read tools resolved and bounded their whole UTC days with helpers private to analytics. The reporting contract now holds them - resolveDayRange, checkDayRange and dayRangeInstants - so every module's report tool takes the same dateFrom and dateTo, defaults the same way and covers the same instants. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../src/analytics/contract/agent-tools.ts | 47 ++---------------- .../src/contracts/__tests__/day-range.test.ts | 32 ++++++++++++ .../core/src/contracts/schemas/reporting.ts | 49 +++++++++++++++++++ 3 files changed, 84 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/contracts/__tests__/day-range.test.ts diff --git a/packages/core/src/analytics/contract/agent-tools.ts b/packages/core/src/analytics/contract/agent-tools.ts index f424c3d07..191e0a9b2 100644 --- a/packages/core/src/analytics/contract/agent-tools.ts +++ b/packages/core/src/analytics/contract/agent-tools.ts @@ -8,11 +8,13 @@ import { WALLET_TRANSACTION_STATUSES, WalletTransactionStatusSchema, WalletTransactionTypeSchema, + checkDayRange, defineMcpTool, + resolveDayRange, + type DayRangeInput, } from '@openora/core/contracts'; import { GgrSeriesSchema } from './financial.js'; -const DAY_MS = 24 * 60 * 60 * 1000; const GGR_SUMMARY_DEFAULT_DAYS = 30; export const GGR_SUMMARY_MAX_DAYS = 366; const PAYMENTS_FLOW_DEFAULT_DAYS = 14; @@ -23,49 +25,6 @@ export const FINANCIAL_SUMMARY_MAX_DAYS = 366; export const FINANCIAL_SUMMARY_MAX_CURRENCIES = 100; export const FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS = 300; -type DayRangeInput = { dateFrom?: string; dateTo?: string }; -type DayRange = { dateFrom: string; dateTo: string }; - -const isoDay = (epochMs: number) => new Date(epochMs).toISOString().slice(0, 10); - -/** - * The whole UTC days a report covers, both ends inclusive: `dateTo` defaults to the day of - * `now`, and `dateFrom` to the `defaultDays` days ending on `dateTo`. - */ -function resolveDayRange( - { dateFrom, dateTo }: DayRangeInput, - defaultDays: number, - now: Date, -): DayRange { - const to = dateTo ?? isoDay(now.getTime()); - return { - dateFrom: dateFrom ?? isoDay(Date.parse(to) - (defaultDays - 1) * DAY_MS), - dateTo: to, - }; -} - -function dayRangeDays({ dateFrom, dateTo }: DayRange) { - return (Date.parse(dateTo) - Date.parse(dateFrom)) / DAY_MS + 1; -} - -function checkDayRange(range: DayRange, maxDays: number, ctx: z.core.$RefinementCtx) { - if (range.dateFrom > range.dateTo) { - ctx.addIssue({ - code: 'custom', - path: ['dateFrom'], - message: 'dateFrom must be on or before dateTo', - }); - return; - } - if (dayRangeDays(range) > maxDays) { - ctx.addIssue({ - code: 'custom', - path: ['dateFrom'], - message: `the range spans at most ${maxDays} days`, - }); - } -} - /** ggr.summary's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */ export function resolveGgrSummaryRange(input: DayRangeInput, now = new Date()) { return resolveDayRange(input, GGR_SUMMARY_DEFAULT_DAYS, now); diff --git a/packages/core/src/contracts/__tests__/day-range.test.ts b/packages/core/src/contracts/__tests__/day-range.test.ts new file mode 100644 index 000000000..b071d6d9c --- /dev/null +++ b/packages/core/src/contracts/__tests__/day-range.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { checkDayRange, dayRangeInstants, resolveDayRange } from '../schemas/reporting.js'; + +const NOW = new Date('2026-03-10T15:30:00.000Z'); + +describe('a report day range', () => { + it('defaults to the days ending on the day of now, both ends counted', () => { + expect(resolveDayRange({}, 30, NOW)).toEqual({ dateFrom: '2026-02-09', dateTo: '2026-03-10' }); + expect(resolveDayRange({ dateTo: '2026-01-31' }, 1, NOW)).toEqual({ + dateFrom: '2026-01-31', + dateTo: '2026-01-31', + }); + }); + + it('covers its days from the first millisecond of the first through the last of the last', () => { + expect(dayRangeInstants({ dateFrom: '2026-01-01', dateTo: '2026-01-31' })).toEqual({ + from: new Date('2026-01-01T00:00:00.000Z'), + to: new Date('2026-01-31T23:59:59.999Z'), + }); + }); + + it('refuses a range that ends before it starts or spans more days than allowed', () => { + const schema = z + .object({ dateFrom: z.iso.date(), dateTo: z.iso.date() }) + .superRefine((range, ctx) => checkDayRange(range, 31, ctx)); + + expect(schema.safeParse({ dateFrom: '2026-01-01', dateTo: '2026-01-31' }).success).toBe(true); + expect(schema.safeParse({ dateFrom: '2026-01-01', dateTo: '2026-02-01' }).success).toBe(false); + expect(schema.safeParse({ dateFrom: '2026-01-02', dateTo: '2026-01-01' }).success).toBe(false); + }); +}); diff --git a/packages/core/src/contracts/schemas/reporting.ts b/packages/core/src/contracts/schemas/reporting.ts index 960de0c86..2018e7438 100644 --- a/packages/core/src/contracts/schemas/reporting.ts +++ b/packages/core/src/contracts/schemas/reporting.ts @@ -22,6 +22,55 @@ export type Granularity = z.infer; const DAY_MS = 24 * 60 * 60 * 1000; +/** The whole UTC days a report covers, both ends inclusive, as `YYYY-MM-DD`. */ +export type DayRange = { dateFrom: string; dateTo: string }; +export type DayRangeInput = Partial; + +const isoDay = (epochMs: number) => new Date(epochMs).toISOString().slice(0, 10); + +/** + * The days a report covers: `dateTo` defaults to the day of `now`, and `dateFrom` to the + * `defaultDays` days ending on `dateTo`. + */ +export function resolveDayRange( + { dateFrom, dateTo }: DayRangeInput, + defaultDays: number, + now: Date, +): DayRange { + const to = dateTo ?? isoDay(now.getTime()); + return { + dateFrom: dateFrom ?? isoDay(Date.parse(to) - (defaultDays - 1) * DAY_MS), + dateTo: to, + }; +} + +/** A schema refinement: refuses a range that ends before it starts or spans over `maxDays` days. */ +export function checkDayRange(range: DayRange, maxDays: number, ctx: z.core.$RefinementCtx) { + if (range.dateFrom > range.dateTo) { + ctx.addIssue({ + code: 'custom', + path: ['dateFrom'], + message: 'dateFrom must be on or before dateTo', + }); + return; + } + if ((Date.parse(range.dateTo) - Date.parse(range.dateFrom)) / DAY_MS + 1 > maxDays) { + ctx.addIssue({ + code: 'custom', + path: ['dateFrom'], + message: `the range spans at most ${maxDays} days`, + }); + } +} + +/** The instants a day range covers: from the start of `dateFrom` through the end of `dateTo`. */ +export function dayRangeInstants({ dateFrom, dateTo }: DayRange): { from: Date; to: Date } { + return { + from: new Date(`${dateFrom}T00:00:00.000Z`), + to: new Date(`${dateTo}T23:59:59.999Z`), + }; +} + /** * Buckets `date_trunc(granularity, ...)` yields for from..to in UTC, both ends included. * Weeks are ISO weeks starting on Monday. Expects from <= to. From f5dca06cd3810469cb0d59c1a1054c492725429a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:02 +0100 Subject: [PATCH 2/6] feat(gaming): rank the games per currency as an agent read tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gaming.top_games, under report:view, ranks the games by their completed rounds over 1 to 92 whole UTC days, separately per currency: volume, revenue, rounds played and distinct players, as the back office game performance report counts them, with each currency's totals over every game played. sortBy and sortDir pick the figure and the order, limit takes 1 to 50 games, gameType and currency narrow it, and at most 20 currencies are listed. It names no player. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../__tests__/top-games-tool.contract.test.ts | 40 ++++ .../__tests__/top-games-tool.int.test.ts | 218 ++++++++++++++++++ .../src/casino/gaming/contract/agent-tools.ts | 101 ++++++++ .../core/src/casino/gaming/contract/index.ts | 1 + packages/core/src/casino/gaming/plugin.ts | 7 + .../gaming/service/top-games.service.ts | 118 ++++++++++ 6 files changed, 485 insertions(+) create mode 100644 packages/core/src/casino/gaming/__tests__/top-games-tool.contract.test.ts create mode 100644 packages/core/src/casino/gaming/__tests__/top-games-tool.int.test.ts create mode 100644 packages/core/src/casino/gaming/contract/agent-tools.ts create mode 100644 packages/core/src/casino/gaming/service/top-games.service.ts diff --git a/packages/core/src/casino/gaming/__tests__/top-games-tool.contract.test.ts b/packages/core/src/casino/gaming/__tests__/top-games-tool.contract.test.ts new file mode 100644 index 000000000..b5eaacd14 --- /dev/null +++ b/packages/core/src/casino/gaming/__tests__/top-games-tool.contract.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import { TopGamesInputSchema } from '../contract/index.js'; +import gamingPlugin from '../plugin.js'; + +describe('gaming.top_games input', () => { + const parse = (input: unknown) => z.safeParse(TopGamesInputSchema, input); + + it('defaults to the 10 games with the most volume', () => { + expect(parse({})).toEqual({ + success: true, + data: { sortBy: 'volume', sortDir: 'desc', limit: 10 }, + }); + }); + + it('takes a limit from 1 to 50 and sorts only by the figures it returns', () => { + expect(parse({ limit: '50', sortBy: 'revenue', sortDir: 'asc' }).success).toBe(true); + expect(parse({ limit: 0 }).success).toBe(false); + expect(parse({ limit: 51 }).success).toBe(false); + expect(parse({ sortBy: 'name' }).success).toBe(false); + expect(parse({ gameType: 'lottery' }).success).toBe(false); + }); + + it('spans at most 92 days', () => { + expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-02' }).success).toBe(true); + expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-03' }).success).toBe(false); + }); +}); + +describe('gaming agent surface registration', () => { + it('registers gaming.top_games within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('gaming'); + + await gamingPlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['gaming.top_games']); + }); +}); diff --git a/packages/core/src/casino/gaming/__tests__/top-games-tool.int.test.ts b/packages/core/src/casino/gaming/__tests__/top-games-tool.int.test.ts new file mode 100644 index 000000000..eccd25188 --- /dev/null +++ b/packages/core/src/casino/gaming/__tests__/top-games-tool.int.test.ts @@ -0,0 +1,218 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import type { GameType, RunContext } from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { game, gameProvider, gameRound } from '../schema/index.js'; +import gamingPlugin from '../plugin.js'; + +let db: TestDb; + +const RANGE = { dateFrom: '2026-01-01', dateTo: '2026-01-31' }; +const IN_RANGE = new Date('2026-01-10T12:00:00.000Z'); + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('gaming'); + gamingPlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() }, + catalogVersion: 'test', + correlationId: 'top-games-tool', +}); + +async function seedGame(name: string, gameType: GameType = 'casino') { + const provider = findOneOrThrow( + await db.drizzle.db + .insert(gameProvider) + .values({ slug: `studio-${randomUUID()}`, name: `${name} Studio` }) + .returning(), + new Error('seedGame: no provider'), + ); + return findOneOrThrow( + await db.drizzle.db + .insert(game) + .values({ + name, + slug: `game-${randomUUID()}`, + providerId: provider.id, + aggregator: 'direct', + gameType, + reviewStatus: 'approved', + }) + .returning(), + new Error('seedGame: no game'), + ); +} + +async function seedRound( + gameId: string, + values: Partial & { betAmount: string }, +) { + await db.drizzle.db.insert(gameRound).values({ + gameId, + userId: randomUUID(), + status: 'completed', + winAmount: '0', + currency: 'USD', + startedAt: IN_RANGE, + ...values, + }); +} + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${gameRound}, ${game}, ${gameProvider} RESTART IDENTITY CASCADE`, + ); +}); + +describe('gaming.top_games through the MCP kernel (real PG)', () => { + it('ranks the games per currency over the completed rounds of whole UTC days', async () => { + const kernel = bootKernel(); + const [aces, blaze, comet] = [ + await seedGame('Aces'), + await seedGame('Blaze'), + await seedGame('Comet'), + ]; + const regular = randomUUID(); + await seedRound(aces.id, { betAmount: '100', winAmount: '30', userId: regular }); + await seedRound(aces.id, { betAmount: '50', winAmount: '0', userId: regular }); + // The last millisecond of dateTo counts; the first of the next day does not. + await seedRound(blaze.id, { + betAmount: '300', + winAmount: '400', + startedAt: new Date('2026-01-31T23:59:59.999Z'), + }); + await seedRound(blaze.id, { + betAmount: '999', + startedAt: new Date('2026-02-01T00:00:00.000Z'), + }); + await seedRound(comet.id, { betAmount: '10' }); + await seedRound(comet.id, { betAmount: '999', status: 'active' }); + await seedRound(aces.id, { betAmount: '7', winAmount: '2', currency: 'EUR' }); + + const result = await kernel.invokeTool( + 'gaming.top_games', + { ...RANGE, limit: 2 }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + ...RANGE, + sortBy: 'volume', + sortDir: 'desc', + currencies: [ + { + currency: 'EUR', + totals: expect.objectContaining({ roundsPlayed: 1, uniquePlayers: 1, gamesPlayed: 1 }), + games: [expect.objectContaining({ gameId: aces.id, roundsPlayed: 1 })], + }, + { + currency: 'USD', + totals: { + volume: expect.stringMatching(/^460(\.0+)?$/), + revenue: expect.stringMatching(/^30(\.0+)?$/), + roundsPlayed: 4, + uniquePlayers: 3, + gamesPlayed: 3, + }, + games: [ + { + gameId: blaze.id, + name: 'Blaze', + provider: 'Blaze Studio', + gameType: 'casino', + volume: expect.any(String), + revenue: expect.any(String), + roundsPlayed: 1, + uniquePlayers: 1, + }, + expect.objectContaining({ gameId: aces.id, roundsPlayed: 2, uniquePlayers: 1 }), + ], + }, + ], + truncated: false, + }, + }); + }); + + it('lists the biggest losers first by revenue ascending, narrowed by game type', async () => { + const kernel = bootKernel(); + const [winner, loser, sportsbook] = [ + await seedGame('Winner'), + await seedGame('Loser'), + await seedGame('Book', 'sportsbook'), + ]; + await seedRound(winner.id, { betAmount: '100', winAmount: '10' }); + await seedRound(loser.id, { betAmount: '100', winAmount: '500' }); + await seedRound(sportsbook.id, { betAmount: '100', winAmount: '900' }); + + const result = await kernel.invokeTool( + 'gaming.top_games', + { ...RANGE, sortBy: 'revenue', sortDir: 'asc', gameType: 'casino', currency: 'usd' }, + runContext(), + ); + + expect(result).toMatchObject({ + ok: true, + output: { + currencies: [ + { + currency: 'USD', + totals: { gamesPlayed: 2 }, + games: [ + { gameId: loser.id, revenue: expect.stringMatching(/^-400(\.0+)?$/) }, + { gameId: winner.id }, + ], + }, + ], + }, + }); + }); + + it('refuses a range over 92 days and a limit over 50', async () => { + const kernel = bootKernel(); + + for (const input of [ + { dateFrom: '2026-01-01', dateTo: '2026-04-03' }, + { dateFrom: '2026-02-01', dateTo: '2026-01-31' }, + { limit: 51 }, + ]) { + expect(await kernel.invokeTool('gaming.top_games', input, runContext())).toMatchObject({ + ok: false, + error: 'invalid_input', + }); + } + }); +}); diff --git a/packages/core/src/casino/gaming/contract/agent-tools.ts b/packages/core/src/casino/gaming/contract/agent-tools.ts new file mode 100644 index 000000000..9f15f43b7 --- /dev/null +++ b/packages/core/src/casino/gaming/contract/agent-tools.ts @@ -0,0 +1,101 @@ +import * as z from 'zod'; +import { + CurrencyTickerSchema, + GameTypeSchema, + MoneyAmountSchema, + SignedMoneyAmountSchema, + UuidSchema, + checkDayRange, + defineMcpTool, + resolveDayRange, + type DayRangeInput, +} from '@openora/core/contracts'; + +const TOP_GAMES_DEFAULT_DAYS = 30; +export const TOP_GAMES_MAX_DAYS = 92; +export const TOP_GAMES_MAX_LIMIT = 50; +export const TOP_GAMES_MAX_CURRENCIES = 20; + +export const TOP_GAMES_SORT_FIELDS = [ + 'volume', + 'revenue', + 'roundsPlayed', + 'uniquePlayers', +] as const; +export const TopGamesSortBySchema = z.enum(TOP_GAMES_SORT_FIELDS); +export type TopGamesSortBy = z.infer; + +/** gaming.top_games's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */ +export function resolveTopGamesRange(input: DayRangeInput, now = new Date()) { + return resolveDayRange(input, TOP_GAMES_DEFAULT_DAYS, now); +} + +export const TopGamesInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + currency: CurrencyTickerSchema.max(10).optional(), + gameType: GameTypeSchema.optional(), + sortBy: TopGamesSortBySchema.default('volume'), + sortDir: z.enum(['desc', 'asc']).default('desc'), + limit: z.coerce.number().int().min(1).max(TOP_GAMES_MAX_LIMIT).default(10), + }) + .superRefine((input, ctx) => checkDayRange(resolveTopGamesRange(input), TOP_GAMES_MAX_DAYS, ctx)); +export type TopGamesInput = z.infer; + +const GameFiguresSchema = z.object({ + volume: MoneyAmountSchema, + revenue: SignedMoneyAmountSchema, + roundsPlayed: z.number().int().nonnegative(), + uniquePlayers: z.number().int().nonnegative(), +}); + +export const TopGamesOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + sortBy: TopGamesSortBySchema, + sortDir: z.enum(['desc', 'asc']), + currencies: z + .array( + z.object({ + currency: CurrencyTickerSchema, + totals: GameFiguresSchema.extend({ gamesPlayed: z.number().int().nonnegative() }), + games: z + .array( + GameFiguresSchema.extend({ + gameId: UuidSchema, + name: z.string(), + provider: z.string(), + gameType: GameTypeSchema, + }), + ) + .max(TOP_GAMES_MAX_LIMIT), + }), + ) + .max(TOP_GAMES_MAX_CURRENCIES), + truncated: z.boolean(), +}); +export type TopGames = z.infer; + +export const topGamesTool = defineMcpTool({ + id: 'gaming.top_games', + title: 'Top games', + description: + 'The games ranked by their completed rounds over whole UTC days from dateFrom through dateTo ' + + '(defaults to the 30 days ending today; a range spans at most 92 days), separately per ' + + 'currency: volume (stakes net of reversed stakes), revenue (GGR: stakes minus wins, can be ' + + 'negative), rounds played and distinct players, as on the back office game performance ' + + 'report. sortBy picks the figure (volume by default) and sortDir the order, so sortBy ' + + 'revenue with sortDir asc lists the games that lost the operator the most. Each currency ' + + 'lists at most limit games (1 to 50, default 10) with its totals over every game played; a ' + + 'game with no completed round in range is not listed. Narrow with currency or gameType. At ' + + 'most 20 currencies, alphabetical; truncated is true when more had rounds. Amounts are ' + + 'decimal strings in the row currency and are never summed across currencies. Names no player.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'report', action: 'view' }, + inputSchema: TopGamesInputSchema, + outputSchema: TopGamesOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'sortBy', 'sortDir', 'currencies', 'truncated'] }, + errors: [], +}); diff --git a/packages/core/src/casino/gaming/contract/index.ts b/packages/core/src/casino/gaming/contract/index.ts index bb161df37..d21c27a27 100644 --- a/packages/core/src/casino/gaming/contract/index.ts +++ b/packages/core/src/casino/gaming/contract/index.ts @@ -863,3 +863,4 @@ export const gamingAdminContract = { .route({ method: 'GET', path: '/backoffice/gaming/sort-options' }) .output(z.array(GameSortOptionSchema)), }; +export * from './agent-tools.js'; diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index 1b8954486..9927e2f18 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -35,6 +35,7 @@ import { GameTagService } from './service/game-tag.service.js'; import { GameProviderService } from './service/game-provider.service.js'; import { GameBulkService } from './service/game-bulk.service.js'; import { GameFavoriteService } from './service/game-favorite.service.js'; +import { TopGamesService } from './service/top-games.service.js'; import { createGamingRouter } from './router/index.js'; import { MockGameAdapter } from './adapters/mock/mock-game-adapter.js'; import { MockRngAdapter } from './adapters/mock/mock-rng-adapter.js'; @@ -49,6 +50,7 @@ import { GAME_CATEGORY_RANK_SWEEP_QUEUE, GameCategoryRankJobSchema, GameCategoryRankSweepJobSchema, + topGamesTool, } from './contract/index.js'; export default { @@ -68,6 +70,11 @@ export default { ctx.provide(RNG_ADAPTER, () => new MockRngAdapter()); ctx.provide(ADMIN_GAME_REPORTING, (c) => new DrizzleAdminGameReporting(c.get(DRIZZLE))); ctx.provide(GAME_CATALOG_READER, (c) => new GameCatalogReaderService(c.get(DRIZZLE))); + + ctx.mcp.tool(topGamesTool, (c) => { + const topGames = new TopGamesService(c.get(DRIZZLE)); + return (input) => topGames.topGames(input); + }); // Replaceable (not sealed): an overlay can rebind this to add or remove sort // definitions - eg an attribute or stats sort - without touching this module. ctx.provide(GAME_SORT_CATALOG, (c) => diff --git a/packages/core/src/casino/gaming/service/top-games.service.ts b/packages/core/src/casino/gaming/service/top-games.service.ts new file mode 100644 index 000000000..6b0bcbb44 --- /dev/null +++ b/packages/core/src/casino/gaming/service/top-games.service.ts @@ -0,0 +1,118 @@ +import { and, asc, eq, gte, lte, sql, type SQL } from 'drizzle-orm'; +import type { DrizzleService } from '@openora/core/server'; +import { dayRangeInstants } from '@openora/core/contracts'; +import { + TOP_GAMES_MAX_CURRENCIES, + resolveTopGamesRange, + type TopGames, + type TopGamesInput, + type TopGamesSortBy, +} from '../contract/index.js'; +import { game, gameProvider, gameRound } from '../schema/index.js'; + +const volume = sql`sum(${gameRound.betAmount})::numeric(38, 18)`; +// GGR: negative when the games paid out more than they took in. +const revenue = sql`(sum(${gameRound.betAmount}) - sum(${gameRound.winAmount}))::numeric(38, 18)`; +const roundsPlayed = sql`count(*)`.mapWith(Number); +const uniquePlayers = sql`count(distinct ${gameRound.userId})`.mapWith(Number); + +const SORT_FIGURES: Record = { + volume, + revenue, + roundsPlayed: sql`count(*)`, + uniquePlayers: sql`count(distinct ${gameRound.userId})`, +}; + +/** + * The games ranked by their completed rounds per currency, over the same rounds the back office + * game performance report reads (ADMIN_GAME_REPORTING): stakes net of reversed stakes, wins, and + * who played. + */ +export class TopGamesService { + constructor(private readonly drizzle: DrizzleService) {} + + async topGames( + { currency, gameType, sortBy, sortDir, limit, ...dates }: TopGamesInput, + now = new Date(), + ): Promise { + const range = resolveTopGamesRange(dates, now); + const { from, to } = dayRangeInstants(range); + const rounds = and( + eq(gameRound.status, 'completed'), + gte(gameRound.startedAt, from), + lte(gameRound.startedAt, to), + currency ? eq(gameRound.currency, currency.toUpperCase()) : undefined, + gameType ? eq(game.gameType, gameType) : undefined, + ); + const db = this.drizzle.db; + const figure = SORT_FIGURES[sortBy]; + const order = sortDir === 'asc' ? sql`asc` : sql`desc`; + const ranked = db + .select({ + currency: gameRound.currency, + gameId: game.id, + name: sql`${game.name}`.as('name'), + provider: sql`${gameProvider.name}`.as('provider'), + gameType: game.gameType, + volume: volume.as('volume'), + revenue: revenue.as('revenue'), + roundsPlayed: roundsPlayed.as('rounds_played'), + uniquePlayers: uniquePlayers.as('unique_players'), + // The game id breaks ties, so equal figures always rank in the same order. + rank: sql`row_number() over (partition by ${gameRound.currency} order by ${figure} ${order}, ${game.id})`.as( + 'rank', + ), + }) + .from(gameRound) + .innerJoin(game, eq(game.id, gameRound.gameId)) + .innerJoin(gameProvider, eq(gameProvider.id, game.providerId)) + .where(rounds) + .groupBy(gameRound.currency, game.id, gameProvider.name) + .as('ranked'); + const [totals, games] = await Promise.all([ + db + .select({ + currency: gameRound.currency, + volume, + revenue, + roundsPlayed, + uniquePlayers, + gamesPlayed: sql`count(distinct ${gameRound.gameId})`.mapWith(Number), + }) + .from(gameRound) + .innerJoin(game, eq(game.id, gameRound.gameId)) + .where(rounds) + .groupBy(gameRound.currency) + .orderBy(asc(gameRound.currency)) + .limit(TOP_GAMES_MAX_CURRENCIES + 1), + db + .select() + .from(ranked) + .where(lte(ranked.rank, limit)) + .orderBy(asc(ranked.currency), asc(ranked.rank)), + ]); + const kept = totals.slice(0, TOP_GAMES_MAX_CURRENCIES); + return { + ...range, + sortBy, + sortDir, + currencies: kept.map(({ currency: code, ...figures }) => ({ + currency: code, + totals: figures, + games: games + .filter((row) => row.currency === code) + .map((row) => ({ + gameId: row.gameId, + name: row.name, + provider: row.provider, + gameType: row.gameType, + volume: row.volume, + revenue: row.revenue, + roundsPlayed: Number(row.roundsPlayed), + uniquePlayers: Number(row.uniquePlayers), + })), + })), + truncated: totals.length > TOP_GAMES_MAX_CURRENCIES, + }; + } +} From 8cba63faa04ddb93a7c8e8d5c977918546bdf20d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:03 +0100 Subject: [PATCH 3/6] feat(betting): operator-wide sportsbook turnover and exposure agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sportsbook.turnover_exposure, under sportsbook:view, totals every player's bets per currency over 1 to 92 whole UTC days by the player summary's rules, now one shared query: stakes of the bets accepted or still reserved in range, payouts of those settled in it, and GGR. It adds the open bets now - their count, stakes and potential payout - and the 0 to 20 largest open bets by potential payout, each by player id with its stake, payout, number of legs and when its last leg starts, never its selections. The tool builds its reader from DRIZZLE alone, since the kernel builds every handler when it starts. A partial index on settled_at serves the settlement side, which no longer reads one player's bets. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../turnover-exposure-tool.contract.test.ts | 34 + .../turnover-exposure-tool.int.test.ts | 260 +++ .../betting/contract/agent-tools.ts | 101 + .../src/sportsbook/betting/contract/index.ts | 1 + .../migrations/0004_overjoyed_reavers.sql | 1 + .../migrations/meta/0004_snapshot.json | 1631 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + .../core/src/sportsbook/betting/plugin.ts | 9 + .../src/sportsbook/betting/schema/index.ts | 4 + .../betting/service/bet-query.service.ts | 63 +- .../service/turnover-exposure.service.ts | 89 + 11 files changed, 2177 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.contract.test.ts create mode 100644 packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.int.test.ts create mode 100644 packages/core/src/sportsbook/betting/contract/agent-tools.ts create mode 100644 packages/core/src/sportsbook/betting/drizzle/migrations/0004_overjoyed_reavers.sql create mode 100644 packages/core/src/sportsbook/betting/drizzle/migrations/meta/0004_snapshot.json create mode 100644 packages/core/src/sportsbook/betting/service/turnover-exposure.service.ts diff --git a/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.contract.test.ts b/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.contract.test.ts new file mode 100644 index 000000000..4e9515899 --- /dev/null +++ b/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.contract.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import { TurnoverExposureInputSchema } from '../contract/index.js'; +import bettingPlugin from '../plugin.js'; + +describe('sportsbook.turnover_exposure input', () => { + const parse = (input: unknown) => z.safeParse(TurnoverExposureInputSchema, input); + + it('defaults to the 5 largest open bets', () => { + expect(parse({})).toEqual({ success: true, data: { largestOpen: 5 } }); + }); + + it('takes 0 to 20 open bets and at most 92 days', () => { + expect(parse({ largestOpen: '0' }).success).toBe(true); + expect(parse({ largestOpen: 21 }).success).toBe(false); + expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-02' }).success).toBe(true); + expect(parse({ dateFrom: '2026-01-01', dateTo: '2026-04-03' }).success).toBe(false); + expect(parse({ currency: 'US' }).success).toBe(false); + }); +}); + +describe('betting agent surface registration', () => { + it('registers sportsbook.turnover_exposure within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('betting'); + + await bettingPlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([ + 'sportsbook.turnover_exposure', + ]); + }); +}); diff --git a/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.int.test.ts b/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.int.test.ts new file mode 100644 index 000000000..1df3e5608 --- /dev/null +++ b/packages/core/src/sportsbook/betting/__tests__/turnover-exposure-tool.int.test.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import type { RunContext } from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { sportsbookBet, sportsbookSession } from '../schema/index.js'; +import bettingPlugin from '../plugin.js'; + +let db: TestDb; + +const RANGE = { dateFrom: '2026-01-01', dateTo: '2026-01-31' }; +const AT = (iso: string) => new Date(iso); +const amount = (value: number) => expect.stringMatching(new RegExp(`^${value}(\\.0+)?$`)); +const LEG = (eventName: string) => ({ + eventId: randomUUID(), + eventName, + market: 'Match winner', + selection: 'Home', + odds: '2.50', + startsAt: '2026-12-01T18:00:00.000Z', +}); + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('betting'); + bettingPlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() }, + catalogVersion: 'test', + correlationId: 'turnover-exposure-tool', +}); + +async function seedPlayer() { + return findOneOrThrow( + await db.drizzle.db.insert(player).values({ userId: randomUUID() }).returning(), + new Error('seedPlayer: no row'), + ); +} + +async function seedBet( + userId: string, + values: Partial & { + status: (typeof sportsbookBet.$inferInsert)['status']; + stake: string; + potentialPayout: string; + }, +) { + const session = findOneOrThrow( + await db.drizzle.db + .insert(sportsbookSession) + .values({ + userId, + provider: 'book', + currency: values.currency ?? 'USD', + countryCode: 'MT', + expiresAt: AT('2027-01-01T00:00:00.000Z'), + }) + .returning(), + new Error('seedBet: no session'), + ); + return findOneOrThrow( + await db.drizzle.db + .insert(sportsbookBet) + .values({ + userId, + provider: 'book', + providerBetId: randomUUID(), + sessionId: session.id, + currency: 'USD', + currencyDecimals: 2, + cashStake: values.stake, + voidPayout: values.stake, + legs: [LEG('Secret Derby')], + lastLegStartsAt: AT('2026-12-01T18:00:00.000Z'), + ...values, + }) + .returning(), + new Error('seedBet: no bet'), + ); +} + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${sportsbookBet}, ${sportsbookSession}, ${player} RESTART IDENTITY CASCADE`, + ); +}); + +describe('sportsbook.turnover_exposure through the MCP kernel (real PG)', () => { + it('sums the turnover of the range per currency and ranks the open bets by what they pay', async () => { + const kernel = bootKernel(); + const [alice, bob] = [await seedPlayer(), await seedPlayer()]; + await seedBet(alice.userId, { + status: 'settled', + result: 'won', + stake: '10', + potentialPayout: '25', + payoutTotal: '25', + acceptedAt: AT('2026-01-10T10:00:00.000Z'), + settledAt: AT('2026-01-12T10:00:00.000Z'), + }); + const big = await seedBet(bob.userId, { + status: 'accepted', + stake: '20', + potentialPayout: '100', + legs: [LEG('Secret Derby'), LEG('Quiet Cup')], + acceptedAt: AT('2026-01-15T10:00:00.000Z'), + }); + await seedBet(alice.userId, { + status: 'reserved', + stake: '5', + potentialPayout: '50', + createdAt: AT('2026-01-20T10:00:00.000Z'), + }); + // Accepted before the range: open now, so in the exposure, but not in the range's stakes. + const old = await seedBet(bob.userId, { + status: 'accepted', + stake: '40', + potentialPayout: '60', + createdAt: AT('2025-12-01T10:00:00.000Z'), + acceptedAt: AT('2025-12-01T10:00:00.000Z'), + }); + // Staked before the range and lost in it: no stake, a zero payout. + await seedBet(alice.userId, { + status: 'settled', + result: 'lost', + stake: '30', + potentialPayout: '90', + acceptedAt: AT('2025-12-20T10:00:00.000Z'), + settledAt: AT('2026-01-05T10:00:00.000Z'), + }); + await seedBet(alice.userId, { + status: 'cancelled', + stake: '500', + potentialPayout: '900', + createdAt: AT('2026-01-08T10:00:00.000Z'), + cancelledAt: AT('2026-01-08T10:01:00.000Z'), + }); + await seedBet(bob.userId, { + currency: 'EUR', + status: 'settled', + result: 'lost', + stake: '7', + potentialPayout: '14', + acceptedAt: AT('2026-01-03T10:00:00.000Z'), + settledAt: AT('2026-01-04T10:00:00.000Z'), + }); + + const result = await kernel.invokeTool( + 'sportsbook.turnover_exposure', + { ...RANGE, largestOpen: 2 }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + ...RANGE, + currencies: [ + expect.objectContaining({ currency: 'EUR', bets: 1, ggr: amount(7), openBets: 0 }), + { + currency: 'USD', + bets: 3, + stake: amount(35), + cashStake: amount(35), + promotionStake: amount(0), + payout: amount(25), + cashPayout: amount(25), + promotionPayout: amount(0), + ggr: amount(10), + openBets: 3, + openExposure: amount(65), + openPotentialPayout: amount(210), + largestOpen: [ + { + betId: big.id, + playerId: bob.id, + status: 'accepted', + stake: amount(20), + potentialPayout: amount(100), + legs: 2, + placedAt: big.createdAt.toISOString(), + lastLegStartsAt: '2026-12-01T18:00:00.000Z', + }, + expect.objectContaining({ betId: old.id, playerId: bob.id }), + ], + }, + ], + truncated: false, + }, + }); + const served = JSON.stringify(result); + expect(served).not.toContain('Secret Derby'); + expect(served).not.toContain(bob.userId); + }); + + it('narrows to one currency, can leave the open bets out, and refuses a range over 92 days', async () => { + const kernel = bootKernel(); + const row = await seedPlayer(); + await seedBet(row.userId, { + status: 'accepted', + stake: '20', + potentialPayout: '100', + acceptedAt: AT('2026-01-15T10:00:00.000Z'), + }); + await seedBet(row.userId, { + currency: 'EUR', + status: 'accepted', + stake: '7', + potentialPayout: '14', + acceptedAt: AT('2026-01-15T10:00:00.000Z'), + }); + + expect( + await kernel.invokeTool( + 'sportsbook.turnover_exposure', + { ...RANGE, currency: 'eur', largestOpen: 0 }, + runContext(), + ), + ).toMatchObject({ + ok: true, + output: { currencies: [{ currency: 'EUR', openBets: 1, largestOpen: [] }] }, + }); + for (const input of [{ dateFrom: '2026-01-01', dateTo: '2026-04-03' }, { largestOpen: 21 }]) { + expect( + await kernel.invokeTool('sportsbook.turnover_exposure', input, runContext()), + ).toMatchObject({ ok: false, error: 'invalid_input' }); + } + }); +}); diff --git a/packages/core/src/sportsbook/betting/contract/agent-tools.ts b/packages/core/src/sportsbook/betting/contract/agent-tools.ts new file mode 100644 index 000000000..c92bbc74a --- /dev/null +++ b/packages/core/src/sportsbook/betting/contract/agent-tools.ts @@ -0,0 +1,101 @@ +import * as z from 'zod'; +import { + CurrencyTickerSchema, + MoneyAmountSchema, + SignedMoneyAmountSchema, + SportsbookBetStatusSchema, + TimestampSchema, + UuidSchema, + checkDayRange, + defineMcpTool, + resolveDayRange, + type DayRangeInput, +} from '@openora/core/contracts'; + +const TURNOVER_EXPOSURE_DEFAULT_DAYS = 30; +export const TURNOVER_EXPOSURE_MAX_DAYS = 92; +export const TURNOVER_EXPOSURE_MAX_CURRENCIES = 20; +export const LARGEST_OPEN_BETS_MAX = 20; + +/** sportsbook.turnover_exposure's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */ +export function resolveTurnoverExposureRange(input: DayRangeInput, now = new Date()) { + return resolveDayRange(input, TURNOVER_EXPOSURE_DEFAULT_DAYS, now); +} + +export const TurnoverExposureInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + currency: CurrencyTickerSchema.max(10).optional(), + largestOpen: z.coerce.number().int().min(0).max(LARGEST_OPEN_BETS_MAX).default(5), + }) + .superRefine((input, ctx) => + checkDayRange(resolveTurnoverExposureRange(input), TURNOVER_EXPOSURE_MAX_DAYS, ctx), + ); +export type TurnoverExposureInput = z.infer; + +export const OpenBetStatusSchema = SportsbookBetStatusSchema.extract(['reserved', 'accepted']); + +export const TurnoverExposureOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + currencies: z + .array( + z.object({ + currency: CurrencyTickerSchema, + bets: z.number().int().nonnegative(), + stake: MoneyAmountSchema, + cashStake: MoneyAmountSchema, + promotionStake: MoneyAmountSchema, + payout: MoneyAmountSchema, + cashPayout: MoneyAmountSchema, + promotionPayout: MoneyAmountSchema, + ggr: SignedMoneyAmountSchema, + openBets: z.number().int().nonnegative(), + openExposure: MoneyAmountSchema, + openPotentialPayout: MoneyAmountSchema, + largestOpen: z + .array( + z.object({ + betId: UuidSchema, + playerId: UuidSchema.nullable(), + status: OpenBetStatusSchema, + stake: MoneyAmountSchema, + potentialPayout: MoneyAmountSchema, + legs: z.number().int().nonnegative(), + placedAt: TimestampSchema, + lastLegStartsAt: TimestampSchema, + }), + ) + .max(LARGEST_OPEN_BETS_MAX), + }), + ) + .max(TURNOVER_EXPOSURE_MAX_CURRENCIES), + truncated: z.boolean(), +}); +export type TurnoverExposure = z.infer; + +export const turnoverExposureTool = defineMcpTool({ + id: 'sportsbook.turnover_exposure', + title: 'Sportsbook turnover and exposure', + description: + 'Operator-wide sportsbook figures per currency over whole UTC days from dateFrom through ' + + 'dateTo (defaults to the 30 days ending today; a range spans at most 92 days), as each bet ' + + 'stands now and as the per-player sportsbook summary counts them: bets and stake (cash and ' + + 'promotion-funded) of the bets accepted, or still reserved, in range; payout (cash and ' + + 'promotion-funded, a void returning its stake included) of the bets settled in range; and ' + + 'GGR, cash stake minus payout. The open figures are now, whatever the range: openBets, ' + + 'openExposure (their stakes) and openPotentialPayout (what they pay if every one wins), with ' + + 'the largestOpen open bets by potential payout (0 to 20, default 5), each with its betId, ' + + 'playerId, stake, potential payout, number of legs and when its last leg starts. Narrow with ' + + 'currency. At most 20 currencies, alphabetical; truncated is true when more had bets. ' + + 'Amounts are decimal strings in the row currency and are never summed across currencies. ' + + 'No selection, event or market is returned.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'sportsbook', action: 'view' }, + inputSchema: TurnoverExposureInputSchema, + outputSchema: TurnoverExposureOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'currencies', 'truncated'] }, + errors: [], +}); diff --git a/packages/core/src/sportsbook/betting/contract/index.ts b/packages/core/src/sportsbook/betting/contract/index.ts index 8b209dc3c..9406dd2a7 100644 --- a/packages/core/src/sportsbook/betting/contract/index.ts +++ b/packages/core/src/sportsbook/betting/contract/index.ts @@ -528,3 +528,4 @@ export type SportsbookRgRevokeJob = z.infer; export const SPORTSBOOK_RECONCILE_BATCH = 200; /** Status lookups a reserve gets before reconciliation files it as unresolved. */ export const SPORTSBOOK_RECONCILE_MAX_ATTEMPTS = 3; +export * from './agent-tools.js'; diff --git a/packages/core/src/sportsbook/betting/drizzle/migrations/0004_overjoyed_reavers.sql b/packages/core/src/sportsbook/betting/drizzle/migrations/0004_overjoyed_reavers.sql new file mode 100644 index 000000000..7a9fb77b7 --- /dev/null +++ b/packages/core/src/sportsbook/betting/drizzle/migrations/0004_overjoyed_reavers.sql @@ -0,0 +1 @@ +CREATE INDEX "sportsbook_bet_settled_at_idx" ON "sportsbook_bet" USING btree ("settled_at") WHERE "sportsbook_bet"."status" = 'settled'; \ No newline at end of file diff --git a/packages/core/src/sportsbook/betting/drizzle/migrations/meta/0004_snapshot.json b/packages/core/src/sportsbook/betting/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..23ee9e739 --- /dev/null +++ b/packages/core/src/sportsbook/betting/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,1631 @@ +{ + "id": "4e1a32b3-dc2c-433c-8047-126181943dd0", + "prevId": "712ba2cd-8900-45f8-9521-1f0f3a50058d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.sportsbook_bet": { + "name": "sportsbook_bet", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_bet_id": { + "name": "provider_bet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency_decimals": { + "name": "currency_decimals", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sportsbook_bet_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "sportsbook_bet_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "stake": { + "name": "stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "potential_payout": { + "name": "potential_payout", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "payout_total": { + "name": "payout_total", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "cash_stake": { + "name": "cash_stake", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "void_payout": { + "name": "void_payout", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "max_loss_refund": { + "name": "max_loss_refund", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "promotion_type": { + "name": "promotion_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotion_id": { + "name": "promotion_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotion_funder": { + "name": "promotion_funder", + "type": "sportsbook_promotion_funder", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "promotion_potential": { + "name": "promotion_potential", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "promotion_paid": { + "name": "promotion_paid", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "legs": { + "name": "legs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_leg_starts_at": { + "name": "last_leg_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reconcile_attempts": { + "name": "reconcile_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sportsbook_bet_provider_provider_bet_id_idx": { + "name": "sportsbook_bet_provider_provider_bet_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_bet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_user_id_created_at_idx": { + "name": "sportsbook_bet_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_user_id_accepted_at_idx": { + "name": "sportsbook_bet_user_id_accepted_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_user_id_status_settled_at_idx": { + "name": "sportsbook_bet_user_id_status_settled_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_created_at_idx": { + "name": "sportsbook_bet_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_accepted_at_idx": { + "name": "sportsbook_bet_accepted_at_idx", + "columns": [ + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_status_created_at_idx": { + "name": "sportsbook_bet_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_reserved_created_at_idx": { + "name": "sportsbook_bet_reserved_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_bet\".\"status\" = 'reserved' and \"sportsbook_bet\".\"reconcile_attempts\" < 3", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_settled_at_idx": { + "name": "sportsbook_bet_settled_at_idx", + "columns": [ + { + "expression": "settled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_bet\".\"status\" = 'settled'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_accepted_last_leg_starts_at_idx": { + "name": "sportsbook_bet_accepted_last_leg_starts_at_idx", + "columns": [ + { + "expression": "last_leg_starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_bet\".\"status\" = 'accepted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sportsbook_bet_session_id_sportsbook_session_id_fk": { + "name": "sportsbook_bet_session_id_sportsbook_session_id_fk", + "tableFrom": "sportsbook_bet", + "tableTo": "sportsbook_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sportsbook_bet_stake_positive_check": { + "name": "sportsbook_bet_stake_positive_check", + "value": "\"sportsbook_bet\".\"stake\" > 0" + }, + "sportsbook_bet_payout_total_non_negative_check": { + "name": "sportsbook_bet_payout_total_non_negative_check", + "value": "\"sportsbook_bet\".\"payout_total\" >= 0" + }, + "sportsbook_bet_cash_stake_check": { + "name": "sportsbook_bet_cash_stake_check", + "value": "\"sportsbook_bet\".\"cash_stake\" IN (0, \"sportsbook_bet\".\"stake\") AND (\"sportsbook_bet\".\"cash_stake\" = \"sportsbook_bet\".\"stake\" OR \"sportsbook_bet\".\"promotion_type\" IS NOT NULL)" + }, + "sportsbook_bet_void_payout_check": { + "name": "sportsbook_bet_void_payout_check", + "value": "\"sportsbook_bet\".\"void_payout\" IN (0, \"sportsbook_bet\".\"stake\") AND (\"sportsbook_bet\".\"cash_stake\" = 0 OR \"sportsbook_bet\".\"void_payout\" = \"sportsbook_bet\".\"stake\")" + }, + "sportsbook_bet_max_loss_refund_check": { + "name": "sportsbook_bet_max_loss_refund_check", + "value": "\"sportsbook_bet\".\"max_loss_refund\" IN (0, \"sportsbook_bet\".\"cash_stake\") AND (\"sportsbook_bet\".\"max_loss_refund\" = 0 OR \"sportsbook_bet\".\"promotion_type\" IS NOT NULL)" + }, + "sportsbook_bet_promotion_paid_check": { + "name": "sportsbook_bet_promotion_paid_check", + "value": "\"sportsbook_bet\".\"promotion_paid\" >= 0 AND \"sportsbook_bet\".\"promotion_paid\" <= \"sportsbook_bet\".\"payout_total\" AND (\"sportsbook_bet\".\"promotion_paid\" = 0 OR \"sportsbook_bet\".\"promotion_type\" IS NOT NULL) AND (\"sportsbook_bet\".\"cash_stake\" > 0 OR \"sportsbook_bet\".\"promotion_paid\" = \"sportsbook_bet\".\"payout_total\")" + }, + "sportsbook_bet_promotion_funder_check": { + "name": "sportsbook_bet_promotion_funder_check", + "value": "(\"sportsbook_bet\".\"promotion_type\" IS NULL) = (\"sportsbook_bet\".\"promotion_funder\" IS NULL)" + }, + "sportsbook_bet_result_payout_check": { + "name": "sportsbook_bet_result_payout_check", + "value": "(\"sportsbook_bet\".\"result\" IS DISTINCT FROM 'void' OR \"sportsbook_bet\".\"payout_total\" = \"sportsbook_bet\".\"void_payout\" OR (\"sportsbook_bet\".\"cash_stake\" = 0 AND \"sportsbook_bet\".\"payout_total\" = 0)) AND (\"sportsbook_bet\".\"result\" IS DISTINCT FROM 'lost' OR \"sportsbook_bet\".\"payout_total\" <= \"sportsbook_bet\".\"max_loss_refund\")" + } + }, + "isRLSEnabled": false + }, + "public.sportsbook_bet_transaction": { + "name": "sportsbook_bet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tx_id": { + "name": "provider_tx_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_bet_id": { + "name": "provider_bet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bet_id": { + "name": "bet_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "sportsbook_transaction_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "potential_payout": { + "name": "potential_payout", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "legs": { + "name": "legs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "promotion_potential": { + "name": "promotion_potential", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "bet_version": { + "name": "bet_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "sportsbook_bet_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "sportsbook_cancel_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wallet_transaction_ids": { + "name": "wallet_transaction_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "balance_after": { + "name": "balance_after", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "promotion_amount": { + "name": "promotion_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sportsbook_bet_transaction_provider_provider_tx_id_idx": { + "name": "sportsbook_bet_transaction_provider_provider_tx_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tx_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_transaction_bet_origin_idx": { + "name": "sportsbook_bet_transaction_bet_origin_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_bet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sportsbook_bet_transaction\".\"kind\" = 'reserve' OR \"sportsbook_bet_transaction\".\"bet_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_transaction_bet_id_created_at_idx": { + "name": "sportsbook_bet_transaction_bet_id_created_at_idx", + "columns": [ + { + "expression": "bet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_transaction_kind_created_at_idx": { + "name": "sportsbook_bet_transaction_kind_created_at_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_bet_transaction_promotion_created_at_idx": { + "name": "sportsbook_bet_transaction_promotion_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_bet_transaction\".\"promotion_amount\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sportsbook_bet_transaction_bet_id_sportsbook_bet_id_fk": { + "name": "sportsbook_bet_transaction_bet_id_sportsbook_bet_id_fk", + "tableFrom": "sportsbook_bet_transaction", + "tableTo": "sportsbook_bet", + "columnsFrom": ["bet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sportsbook_config": { + "name": "sportsbook_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currencies": { + "name": "currencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_countries": { + "name": "blocked_countries", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "reserve_timeout_seconds": { + "name": "reserve_timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "settlement_grace_minutes": { + "name": "settlement_grace_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "session_ttl_minutes": { + "name": "session_ttl_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "promotions": { + "name": "promotions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"enabled\":false,\"maxPromotionStakeUsd\":null,\"funder\":{}}'::jsonb" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": { + "sportsbook_config_singleton_key_unique": { + "name": "sportsbook_config_singleton_key_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sportsbook_debt_action": { + "name": "sportsbook_debt_action", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "debt_id": { + "name": "debt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "sportsbook_debt_action_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sportsbook_debt_action_debt_id_sportsbook_player_debt_id_fk": { + "name": "sportsbook_debt_action_debt_id_sportsbook_player_debt_id_fk", + "tableFrom": "sportsbook_debt_action", + "tableTo": "sportsbook_player_debt", + "columnsFrom": ["debt_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sportsbook_debt_action_idempotency_key_unique": { + "name": "sportsbook_debt_action_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": ["idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sportsbook_debt_shortfall": { + "name": "sportsbook_debt_shortfall", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seq": { + "name": "seq", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "debt_id": { + "name": "debt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bet_id": { + "name": "bet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "sportsbook_debt_shortfall_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "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": { + "sportsbook_debt_shortfall_debt_id_seq_idx": { + "name": "sportsbook_debt_shortfall_debt_id_seq_idx", + "columns": [ + { + "expression": "debt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sportsbook_debt_shortfall_debt_id_sportsbook_player_debt_id_fk": { + "name": "sportsbook_debt_shortfall_debt_id_sportsbook_player_debt_id_fk", + "tableFrom": "sportsbook_debt_shortfall", + "tableTo": "sportsbook_player_debt", + "columnsFrom": ["debt_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sportsbook_debt_shortfall_bet_id_sportsbook_bet_id_fk": { + "name": "sportsbook_debt_shortfall_bet_id_sportsbook_bet_id_fk", + "tableFrom": "sportsbook_debt_shortfall", + "tableTo": "sportsbook_bet", + "columnsFrom": ["bet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sportsbook_debt_shortfall_amount_check": { + "name": "sportsbook_debt_shortfall_amount_check", + "value": "\"sportsbook_debt_shortfall\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.sportsbook_player_debt": { + "name": "sportsbook_player_debt", + "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 + }, + "owed": { + "name": "owed", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "collected": { + "name": "collected", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "written_off": { + "name": "written_off", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "outstanding": { + "name": "outstanding", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "status": { + "name": "status", + "type": "sportsbook_debt_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "written_off_by": { + "name": "written_off_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "written_off_reason": { + "name": "written_off_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "written_off_at": { + "name": "written_off_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sportsbook_player_debt_user_id_currency_idx": { + "name": "sportsbook_player_debt_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_player_debt_status_updated_at_idx": { + "name": "sportsbook_player_debt_status_updated_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_player_debt_updated_at_idx": { + "name": "sportsbook_player_debt_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sportsbook_player_debt_outstanding_check": { + "name": "sportsbook_player_debt_outstanding_check", + "value": "\"sportsbook_player_debt\".\"outstanding\" = \"sportsbook_player_debt\".\"owed\" - \"sportsbook_player_debt\".\"collected\" - \"sportsbook_player_debt\".\"written_off\" AND \"sportsbook_player_debt\".\"outstanding\" >= 0 AND \"sportsbook_player_debt\".\"owed\" >= 0 AND \"sportsbook_player_debt\".\"collected\" >= 0 AND \"sportsbook_player_debt\".\"written_off\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.sportsbook_player_restriction": { + "name": "sportsbook_player_restriction", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "restricted_by": { + "name": "restricted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sportsbook_reconciliation_finding": { + "name": "sportsbook_reconciliation_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "sportsbook_finding_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bet_id": { + "name": "bet_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "sportsbook_finding_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sportsbook_reconciliation_finding_open_dedupe_key_idx": { + "name": "sportsbook_reconciliation_finding_open_dedupe_key_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sportsbook_reconciliation_finding\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_reconciliation_finding_status_created_at_idx": { + "name": "sportsbook_reconciliation_finding_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_reconciliation_finding_created_at_idx": { + "name": "sportsbook_reconciliation_finding_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_reconciliation_finding_bet_id_kind_idx": { + "name": "sportsbook_reconciliation_finding_bet_id_kind_idx", + "columns": [ + { + "expression": "bet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_reconciliation_finding_user_id_created_at_idx": { + "name": "sportsbook_reconciliation_finding_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sportsbook_reconciliation_finding_bet_id_sportsbook_bet_id_fk": { + "name": "sportsbook_reconciliation_finding_bet_id_sportsbook_bet_id_fk", + "tableFrom": "sportsbook_reconciliation_finding", + "tableTo": "sportsbook_bet", + "columnsFrom": ["bet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sportsbook_session": { + "name": "sportsbook_session", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_revoked_at": { + "name": "provider_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_opened_at": { + "name": "provider_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_revoke_attempted_at": { + "name": "provider_revoke_attempted_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": { + "sportsbook_session_user_id_live_idx": { + "name": "sportsbook_session_user_id_live_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_session\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_session_expires_at_live_idx": { + "name": "sportsbook_session_expires_at_live_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_session\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sportsbook_session_unacknowledged_expires_at_id_idx": { + "name": "sportsbook_session_unacknowledged_expires_at_id_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sportsbook_session\".\"revoked_at\" IS NOT NULL AND \"sportsbook_session\".\"provider_revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.sportsbook_bet_result": { + "name": "sportsbook_bet_result", + "schema": "public", + "values": ["won", "lost", "void", "half_won", "half_lost", "cashed_out"] + }, + "public.sportsbook_bet_status": { + "name": "sportsbook_bet_status", + "schema": "public", + "values": ["reserved", "accepted", "cancelled", "settled"] + }, + "public.sportsbook_cancel_reason": { + "name": "sportsbook_cancel_reason", + "schema": "public", + "values": ["rejected", "discarded"] + }, + "public.sportsbook_debt_action_kind": { + "name": "sportsbook_debt_action_kind", + "schema": "public", + "values": ["collected", "written_off"] + }, + "public.sportsbook_debt_shortfall_type": { + "name": "sportsbook_debt_shortfall_type", + "schema": "public", + "values": ["win_reversal", "bet"] + }, + "public.sportsbook_debt_status": { + "name": "sportsbook_debt_status", + "schema": "public", + "values": ["open", "cleared", "written_off"] + }, + "public.sportsbook_finding_kind": { + "name": "sportsbook_finding_kind", + "schema": "public", + "values": [ + "fingerprint_mismatch", + "payout_above_potential", + "invalid_amount", + "debt_recorded", + "reserve_unresolved", + "settlement_overdue", + "provider_mismatch", + "promotion_payout_unusual" + ] + }, + "public.sportsbook_finding_status": { + "name": "sportsbook_finding_status", + "schema": "public", + "values": ["open", "resolved"] + }, + "public.sportsbook_promotion_funder": { + "name": "sportsbook_promotion_funder", + "schema": "public", + "values": ["operator", "provider"] + }, + "public.sportsbook_transaction_kind": { + "name": "sportsbook_transaction_kind", + "schema": "public", + "values": ["reserve", "confirm", "cancel", "settle", "reopen"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/sportsbook/betting/drizzle/migrations/meta/_journal.json b/packages/core/src/sportsbook/betting/drizzle/migrations/meta/_journal.json index c2fdf3c5b..80733f1ec 100644 --- a/packages/core/src/sportsbook/betting/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/sportsbook/betting/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1792667466300, "tag": "0003_reporting_indexes", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1793961420428, + "tag": "0004_overjoyed_reavers", + "breakpoints": true } ] } diff --git a/packages/core/src/sportsbook/betting/plugin.ts b/packages/core/src/sportsbook/betting/plugin.ts index 2e60b8b26..2fad33d75 100644 --- a/packages/core/src/sportsbook/betting/plugin.ts +++ b/packages/core/src/sportsbook/betting/plugin.ts @@ -23,6 +23,7 @@ import { SPORTSBOOK_RG_REVOKE_TRIGGERS, SportsbookReconcileJobSchema, SportsbookRgRevokeJobSchema, + turnoverExposureTool, } from './contract/index.js'; import { createSportsbookRouter } from './router/index.js'; import { SportsbookAlignService } from './service/align.service.js'; @@ -34,6 +35,7 @@ import { SportsbookLedgerService } from './service/ledger.service.js'; import { SportsbookReconcileService } from './service/reconcile.service.js'; import { SportsbookRestrictionService } from './service/restriction.service.js'; import { SportsbookSessionService } from './service/session.service.js'; +import { SportsbookTurnoverService } from './service/turnover-exposure.service.js'; const logger = createLogger('sportsbook'); @@ -121,6 +123,13 @@ export default { ctx.provide(SPORTSBOOK_LEDGER, (c) => services(c).ledger); + // Its own reader over DRIZZLE alone: the kernel builds every tool's handler when it starts, + // and the services above need the wallet's and the provider's ports. + ctx.mcp.tool(turnoverExposureTool, (c) => { + const turnover = new SportsbookTurnoverService(c.get(DRIZZLE)); + return (input) => turnover.turnoverExposure(input); + }); + for (const topic of SPORTSBOOK_RG_REVOKE_TRIGGERS) { ctx.events.on(topic, async (payload, envelope) => { const parsed = domainEventSchemas[topic].safeParse(payload); diff --git a/packages/core/src/sportsbook/betting/schema/index.ts b/packages/core/src/sportsbook/betting/schema/index.ts index d2f98c3ef..a63d3f53d 100644 --- a/packages/core/src/sportsbook/betting/schema/index.ts +++ b/packages/core/src/sportsbook/betting/schema/index.ts @@ -190,6 +190,10 @@ export const sportsbookBet = pgTable( .where( sql`${t.status} = 'reserved' and ${t.reconcileAttempts} < ${sql.raw(String(SPORTSBOOK_RECONCILE_MAX_ATTEMPTS))}`, ), + // The operator-wide settlement read: bets settled in a range, whoever placed them. + index('sportsbook_bet_settled_at_idx') + .on(t.settledAt) + .where(sql`${t.status} = 'settled'`), index('sportsbook_bet_accepted_last_leg_starts_at_idx') .on(t.lastLegStartsAt, t.id) .where(sql`${t.status} = 'accepted'`), diff --git a/packages/core/src/sportsbook/betting/service/bet-query.service.ts b/packages/core/src/sportsbook/betting/service/bet-query.service.ts index afbcb7cf8..a57da553e 100644 --- a/packages/core/src/sportsbook/betting/service/bet-query.service.ts +++ b/packages/core/src/sportsbook/betting/service/bet-query.service.ts @@ -104,6 +104,40 @@ function sumOf(amount: SQL | PgColumn, where: SQL) { return sql`coalesce(sum(${amount}) filter (where ${where}), 0)::numeric(38, 18)`; } +/** A bet the provider has not settled or cancelled: the operator still owes its outcome. */ +export const openBet = inArray(sportsbookBet.status, ['reserved', 'accepted']); + +/** + * One currency's bet figures in the range, as each bet stands now (SportsbookPlayerSummaryRow): + * stake-side fields count every bet not cancelled, by its acceptance or, while still reserved, its + * debit, in the range; payout-side fields bets settled in it; and `openExposure` the reserved and + * accepted stakes now, whatever the range. Group by currency; `read` keeps the bets any of them + * counts. + */ +export function betTotals(range: DateRange) { + const bet = sportsbookBet; + const allOf = (conditions: SQL[]) => sql`(${sql.join(conditions, sql` and `)})`; + const accepted = allOf([isNotNull(bet.acceptedAt), ...within(bet.acceptedAt, range)]); + const reserved = allOf([eq(bet.status, 'reserved'), ...within(bet.createdAt, range)]); + const staked = sql`(${accepted} or ${reserved})`; + const settled = allOf([eq(bet.status, 'settled'), ...within(bet.settledAt, range)]); + return { + columns: { + currency: bet.currency, + bets: sql`count(*) filter (where ${staked})`.mapWith(Number), + stake: sumOf(bet.stake, staked), + cashStake: sumOf(bet.cashStake, staked), + promotionStake: sumOf(sql`${bet.stake} - ${bet.cashStake}`, staked), + payout: sumOf(bet.payoutTotal, settled), + cashPayout: sumOf(sql`${bet.payoutTotal} - ${bet.promotionPaid}`, settled), + promotionPayout: sumOf(bet.promotionPaid, settled), + ggr: sql`(${sumOf(bet.cashStake, staked)} - ${sumOf(bet.payoutTotal, settled)})::numeric(38, 18)`, + openExposure: sumOf(bet.stake, openBet), + }, + read: or(staked, settled, openBet), + }; +} + export class SportsbookBetQueryService { constructor( private readonly drizzle: DrizzleService, @@ -218,30 +252,13 @@ export class SportsbookBetQueryService { async playerSummary({ userId, ...range }: SportsbookPlayerSummaryInput) { await assertSportsbookPlayer(this.identity, userId); - const bet = sportsbookBet; - const allOf = (conditions: SQL[]) => sql`(${sql.join(conditions, sql` and `)})`; - const accepted = allOf([isNotNull(bet.acceptedAt), ...within(bet.acceptedAt, range)]); - const reserved = allOf([eq(bet.status, 'reserved'), ...within(bet.createdAt, range)]); - const staked = sql`(${accepted} or ${reserved})`; - const settled = allOf([eq(bet.status, 'settled'), ...within(bet.settledAt, range)]); - const open = inArray(bet.status, ['reserved', 'accepted']); + const totals = betTotals(range); return this.drizzle.db - .select({ - currency: bet.currency, - bets: sql`count(*) filter (where ${staked})`.mapWith(Number), - stake: sumOf(bet.stake, staked), - cashStake: sumOf(bet.cashStake, staked), - promotionStake: sumOf(sql`${bet.stake} - ${bet.cashStake}`, staked), - payout: sumOf(bet.payoutTotal, settled), - cashPayout: sumOf(sql`${bet.payoutTotal} - ${bet.promotionPaid}`, settled), - promotionPayout: sumOf(bet.promotionPaid, settled), - ggr: sql`(${sumOf(bet.cashStake, staked)} - ${sumOf(bet.payoutTotal, settled)})::numeric(38, 18)`, - openExposure: sumOf(bet.stake, open), - }) - .from(bet) - .where(and(eq(bet.userId, userId), or(staked, settled, open))) - .groupBy(bet.currency) - .orderBy(asc(bet.currency)); + .select(totals.columns) + .from(sportsbookBet) + .where(and(eq(sportsbookBet.userId, userId), totals.read)) + .groupBy(sportsbookBet.currency) + .orderBy(asc(sportsbookBet.currency)); } private async list({ page, limit, status }: ListSportsbookBetsInput, filters: SQL[]) { diff --git a/packages/core/src/sportsbook/betting/service/turnover-exposure.service.ts b/packages/core/src/sportsbook/betting/service/turnover-exposure.service.ts new file mode 100644 index 000000000..e6e75637b --- /dev/null +++ b/packages/core/src/sportsbook/betting/service/turnover-exposure.service.ts @@ -0,0 +1,89 @@ +import { and, asc, eq, lte, sql } from 'drizzle-orm'; +import type { DrizzleService } from '@openora/core/server'; +import { dayRangeInstants } from '@openora/core/contracts'; +import { player } from '@openora/core/pam/schema/profile'; +import { + OpenBetStatusSchema, + TURNOVER_EXPOSURE_MAX_CURRENCIES, + resolveTurnoverExposureRange, + type TurnoverExposure, + type TurnoverExposureInput, +} from '../contract/index.js'; +import { sportsbookBet } from '../schema/index.js'; +import { betTotals, openBet } from './bet-query.service.js'; + +/** The operator's sportsbook turnover over a range and what its open bets stand to pay now. */ +export class SportsbookTurnoverService { + constructor(private readonly drizzle: DrizzleService) {} + + async turnoverExposure( + { currency, largestOpen, ...dates }: TurnoverExposureInput, + now = new Date(), + ): Promise { + const range = resolveTurnoverExposureRange(dates, now); + const { from, to } = dayRangeInstants(range); + const totals = betTotals({ dateFrom: from.toISOString(), dateTo: to.toISOString() }); + const bet = sportsbookBet; + const inCurrency = currency ? eq(bet.currency, currency.toUpperCase()) : undefined; + const db = this.drizzle.db; + const ranked = db + .select({ + currency: bet.currency, + betId: sql`${bet.id}`.as('bet_id'), + playerId: sql`${player.id}`.as('player_id'), + status: bet.status, + stake: bet.stake, + potentialPayout: bet.potentialPayout, + legs: sql`jsonb_array_length(${bet.legs})`.as('legs'), + placedAt: bet.createdAt, + lastLegStartsAt: bet.lastLegStartsAt, + // The bet id breaks ties, so equal payouts always rank in the same order. + rank: sql`row_number() over (partition by ${bet.currency} order by ${bet.potentialPayout} desc, ${bet.id})`.as( + 'rank', + ), + }) + .from(bet) + .leftJoin(player, eq(player.userId, bet.userId)) + .where(and(openBet, inCurrency)) + .as('ranked'); + const [rows, largest] = await Promise.all([ + db + .select({ + ...totals.columns, + openBets: sql`count(*) filter (where ${openBet})`.mapWith(Number), + openPotentialPayout: sql`coalesce(sum(${bet.potentialPayout}) filter (where ${openBet}), 0)::numeric(38, 18)`, + }) + .from(bet) + .where(and(totals.read, inCurrency)) + .groupBy(bet.currency) + .orderBy(asc(bet.currency)) + .limit(TURNOVER_EXPOSURE_MAX_CURRENCIES + 1), + largestOpen === 0 + ? Promise.resolve([]) + : db + .select() + .from(ranked) + .where(lte(ranked.rank, largestOpen)) + .orderBy(asc(ranked.currency), asc(ranked.rank)), + ]); + return { + ...range, + currencies: rows.slice(0, TURNOVER_EXPOSURE_MAX_CURRENCIES).map((row) => ({ + ...row, + largestOpen: largest + .filter((open) => open.currency === row.currency) + .map((open) => ({ + betId: open.betId, + playerId: open.playerId, + status: OpenBetStatusSchema.parse(open.status), + stake: open.stake, + potentialPayout: open.potentialPayout, + legs: Number(open.legs), + placedAt: new Date(open.placedAt).toISOString(), + lastLegStartsAt: new Date(open.lastLegStartsAt).toISOString(), + })), + })), + truncated: rows.length > TURNOVER_EXPOSURE_MAX_CURRENCIES, + }; + } +} From 93d3e20e35d0b69af1e3fd9112ae8cef7d3ba015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:04 +0100 Subject: [PATCH 4/6] feat(bonus): bonus grants summary agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bonus.grants_summary, under bonus:view, reports per currency over 1 to 366 whole UTC days: the grants funded in range and the bonus they credited; the grants that ended in it - converted with the bonus paid out as cash, expired and forfeited with the balance they took away and the forfeit reasons, cancelled - split by source; and what the live grants hold now: their bonus balance, the wagering still required and those expiring within 7 days. It names no player and returns no note. Partial indexes on activated_at and closed_at serve the range reads; the closing ledger entries are read by grant id. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../grants-summary-tool.contract.test.ts | 33 + .../__tests__/grants-summary-tool.int.test.ts | 279 +++++ .../src/promo/bonus/contract/agent-tools.ts | 103 ++ .../core/src/promo/bonus/contract/index.ts | 1 + .../drizzle/migrations/0013_melted_morg.sql | 2 + .../migrations/meta/0013_snapshot.json | 1024 +++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/promo/bonus/plugin.ts | 7 +- packages/core/src/promo/bonus/schema/index.ts | 7 + .../bonus/service/grants-summary.service.ts | 201 ++++ 10 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/promo/bonus/__tests__/grants-summary-tool.contract.test.ts create mode 100644 packages/core/src/promo/bonus/__tests__/grants-summary-tool.int.test.ts create mode 100644 packages/core/src/promo/bonus/contract/agent-tools.ts create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/0013_melted_morg.sql create mode 100644 packages/core/src/promo/bonus/drizzle/migrations/meta/0013_snapshot.json create mode 100644 packages/core/src/promo/bonus/service/grants-summary.service.ts diff --git a/packages/core/src/promo/bonus/__tests__/grants-summary-tool.contract.test.ts b/packages/core/src/promo/bonus/__tests__/grants-summary-tool.contract.test.ts new file mode 100644 index 000000000..c423599c1 --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/grants-summary-tool.contract.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/core/server'; +import { GrantsSummaryInputSchema } from '../contract/index.js'; +import bonusPlugin from '../plugin.js'; + +describe('bonus.grants_summary input', () => { + const parse = (input: unknown) => z.safeParse(GrantsSummaryInputSchema, input); + + it('needs nothing, and narrows only by a currency or a source it knows', () => { + expect(parse({})).toEqual({ success: true, data: {} }); + expect(parse({ source: 'cashback', currency: 'usdt' }).success).toBe(true); + expect(parse({ source: 'loyalty' }).success).toBe(false); + }); + + it('spans at most 366 days', () => { + expect(parse({ dateFrom: '2025-01-01', dateTo: '2026-01-01' }).success).toBe(true); + expect(parse({ dateFrom: '2025-01-01', dateTo: '2026-01-02' }).success).toBe(false); + }); +}); + +describe('bonus agent surface registration', () => { + it('registers bonus.grants_summary within the kernel contract rules', async () => { + const registry = new ModuleRegistryImpl(new Container()); + registry.setOwner('bonus'); + + await bonusPlugin.register(registry); + + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([ + 'bonus.grants_summary', + ]); + }); +}); diff --git a/packages/core/src/promo/bonus/__tests__/grants-summary-tool.int.test.ts b/packages/core/src/promo/bonus/__tests__/grants-summary-tool.int.test.ts new file mode 100644 index 000000000..4a5869e02 --- /dev/null +++ b/packages/core/src/promo/bonus/__tests__/grants-summary-tool.int.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import type { BonusGrantEntryType, RunContext } from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { makeAuditWriter } from '../../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { promoGrant, promoGrantEntry } from '../schema/index.js'; +import bonusPlugin from '../plugin.js'; + +let db: TestDb; + +const RANGE = { dateFrom: '2026-01-01', dateTo: '2026-01-31' }; +const DAY_MS = 24 * 60 * 60 * 1000; +const AT = (iso: string) => new Date(iso); +const inDays = (days: number) => new Date(Date.now() + days * DAY_MS); +const amount = (value: number) => expect.stringMatching(new RegExp(`^${value}(\\.0+)?$`)); + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('bonus'); + bonusPlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() }, + catalogVersion: 'test', + correlationId: 'grants-summary-tool', +}); + +async function seedGrant( + values: Partial & { + status: (typeof promoGrant.$inferInsert)['status']; + grantedAmount: string; + }, +) { + return findOneOrThrow( + await db.drizzle.db + .insert(promoGrant) + .values({ + userId: randomUUID(), + currency: 'USD', + source: 'deposit', + sourceRef: randomUUID(), + terms: { wageringMultiplier: '3', expiryDays: 14, weights: [] }, + wageringRequired: '0', + expiresAt: inDays(14), + ...values, + }) + .returning(), + new Error('seedGrant: no row'), + ); +} + +// One transaction: the ledger check at commit wants the entries to sum to the grant's balance. +async function seedEntries( + grant: typeof promoGrant.$inferSelect, + entries: [BonusGrantEntryType, string][], +) { + await db.drizzle.db.insert(promoGrantEntry).values( + entries.map(([type, bonusAmount]) => ({ + grantId: grant.id, + userId: grant.userId, + currency: grant.currency, + type, + bonusAmount, + balanceAfter: '0', + })), + ); +} + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${promoGrantEntry}, ${promoGrant} RESTART IDENTITY CASCADE`, + ); +}); + +describe('bonus.grants_summary through the MCP kernel (real PG)', () => { + it('counts what was granted and how grants ended in range, and what the live ones hold now', async () => { + const kernel = bootKernel(); + const expiring = await seedGrant({ + status: 'active', + grantedAmount: '100', + bonusBalance: '80', + wageringRequired: '300', + wageringProgress: '100', + activatedAt: AT('2026-01-05T10:00:00.000Z'), + expiresAt: inDays(2), + }); + // Granted before the range: live now, not granted in it. + await seedGrant({ + status: 'active', + source: 'manual', + grantedAmount: '50', + bonusBalance: '50', + wageringRequired: '150', + activatedAt: AT('2025-12-20T10:00:00.000Z'), + expiresAt: inDays(30), + }); + const converted = await seedGrant({ + status: 'completed', + grantedAmount: '40', + wageringRequired: '120', + wageringProgress: '120', + activatedAt: AT('2026-01-02T10:00:00.000Z'), + closedAt: AT('2026-01-20T10:00:00.000Z'), + }); + await seedEntries(converted, [ + ['grant', '40'], + ['win', '20'], + ['convert', '-60'], + ]); + const expired = await seedGrant({ + status: 'expired', + source: 'cashback', + grantedAmount: '10', + activatedAt: AT('2026-01-03T10:00:00.000Z'), + closedAt: AT('2026-01-17T10:00:00.000Z'), + }); + await seedEntries(expired, [ + ['grant', '10'], + ['stake', '-3'], + ['expire', '-7'], + ]); + const forfeited = await seedGrant({ + status: 'forfeited', + forfeitReason: 'self_exclusion', + forfeitNote: 'free text that must stay out', + grantedAmount: '20', + activatedAt: AT('2026-01-04T10:00:00.000Z'), + closedAt: AT('2026-01-06T10:00:00.000Z'), + }); + await seedEntries(forfeited, [ + ['grant', '20'], + ['forfeit', '-20'], + ]); + await seedGrant({ + status: 'cancelled', + grantedAmount: '15', + closedAt: AT('2026-01-07T10:00:00.000Z'), + }); + await seedGrant({ status: 'pending', grantedAmount: '15' }); + // Funded in the range and taken away after it: granted in it, not forfeited in it. + await seedGrant({ + status: 'forfeited', + forfeitReason: 'admin', + grantedAmount: '30', + activatedAt: AT('2026-01-04T10:00:00.000Z'), + closedAt: AT('2026-02-02T10:00:00.000Z'), + }); + await seedGrant({ + status: 'active', + currency: 'EUR', + source: 'rain', + grantedAmount: '5', + bonusBalance: '5', + activatedAt: AT('2026-01-09T10:00:00.000Z'), + }); + + const result = await kernel.invokeTool('bonus.grants_summary', RANGE, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + ...RANGE, + currencies: [ + expect.objectContaining({ + currency: 'EUR', + granted: { count: 1, amount: amount(5) }, + bySource: [expect.objectContaining({ source: 'rain' })], + }), + { + currency: 'USD', + granted: { count: 5, amount: amount(200) }, + converted: { count: 1, amount: amount(60) }, + expired: { count: 1, amount: amount(7) }, + forfeited: { + count: 1, + amount: amount(20), + byReason: [{ reason: 'self_exclusion', count: 1 }], + }, + cancelled: 1, + bySource: [ + { + source: 'cashback', + granted: { count: 1, amount: amount(10) }, + converted: { count: 0, amount: amount(0) }, + }, + { + source: 'deposit', + granted: { count: 4, amount: amount(190) }, + converted: { count: 1, amount: amount(60) }, + }, + ], + outstanding: { + activeGrants: 2, + pendingGrants: 1, + bonusBalance: amount(130), + wageringRemaining: amount(350), + expiringSoon: { count: 1, amount: amount(80) }, + }, + }, + ], + truncated: false, + }, + }); + const served = JSON.stringify(result); + expect(served).not.toContain('free text'); + expect(served).not.toContain(expiring.userId); + }); + + it('narrows to one source and currency, and refuses a range over 366 days', async () => { + const kernel = bootKernel(); + await seedGrant({ + status: 'active', + source: 'cashback', + grantedAmount: '10', + bonusBalance: '10', + activatedAt: AT('2026-01-03T10:00:00.000Z'), + }); + await seedGrant({ + status: 'active', + grantedAmount: '100', + bonusBalance: '100', + activatedAt: AT('2026-01-03T10:00:00.000Z'), + }); + + expect( + await kernel.invokeTool( + 'bonus.grants_summary', + { ...RANGE, source: 'cashback', currency: 'usd' }, + runContext(), + ), + ).toMatchObject({ + ok: true, + output: { + currencies: [ + { + currency: 'USD', + granted: { count: 1, amount: amount(10) }, + outstanding: { activeGrants: 1, bonusBalance: amount(10) }, + }, + ], + }, + }); + expect( + await kernel.invokeTool( + 'bonus.grants_summary', + { dateFrom: '2025-01-01', dateTo: '2026-01-02' }, + runContext(), + ), + ).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); diff --git a/packages/core/src/promo/bonus/contract/agent-tools.ts b/packages/core/src/promo/bonus/contract/agent-tools.ts new file mode 100644 index 000000000..2fdf359fa --- /dev/null +++ b/packages/core/src/promo/bonus/contract/agent-tools.ts @@ -0,0 +1,103 @@ +import * as z from 'zod'; +import { + BONUS_FORFEIT_REASONS, + BONUS_GRANT_SOURCES, + BonusForfeitReasonSchema, + BonusGrantSourceSchema, + CurrencyTickerSchema, + MoneyAmountSchema, + checkDayRange, + defineMcpTool, + resolveDayRange, + type DayRangeInput, +} from '@openora/core/contracts'; + +const GRANTS_SUMMARY_DEFAULT_DAYS = 30; +export const GRANTS_SUMMARY_MAX_DAYS = 366; +export const GRANTS_SUMMARY_MAX_CURRENCIES = 20; +/** How far ahead `outstanding.expiringSoon` looks. */ +export const GRANTS_EXPIRING_SOON_DAYS = 7; + +/** bonus.grants_summary's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */ +export function resolveGrantsSummaryRange(input: DayRangeInput, now = new Date()) { + return resolveDayRange(input, GRANTS_SUMMARY_DEFAULT_DAYS, now); +} + +export const GrantsSummaryInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + currency: CurrencyTickerSchema.max(10).optional(), + source: BonusGrantSourceSchema.optional(), + }) + .superRefine((input, ctx) => + checkDayRange(resolveGrantsSummaryRange(input), GRANTS_SUMMARY_MAX_DAYS, ctx), + ); +export type GrantsSummaryInput = z.infer; + +const CountedAmountSchema = z.object({ + count: z.number().int().nonnegative(), + amount: MoneyAmountSchema, +}); + +export const GrantsSummaryCurrencySchema = z.object({ + currency: CurrencyTickerSchema, + granted: CountedAmountSchema, + converted: CountedAmountSchema, + expired: CountedAmountSchema, + forfeited: CountedAmountSchema.extend({ + byReason: z + .array(z.object({ reason: BonusForfeitReasonSchema, count: z.number().int().positive() })) + .max(BONUS_FORFEIT_REASONS.length), + }), + cancelled: z.number().int().nonnegative(), + bySource: z + .array( + z.object({ + source: BonusGrantSourceSchema, + granted: CountedAmountSchema, + converted: CountedAmountSchema, + }), + ) + .max(BONUS_GRANT_SOURCES.length), + outstanding: z.object({ + activeGrants: z.number().int().nonnegative(), + pendingGrants: z.number().int().nonnegative(), + bonusBalance: MoneyAmountSchema, + wageringRemaining: MoneyAmountSchema, + expiringSoon: CountedAmountSchema, + }), +}); +export type GrantsSummaryCurrency = z.infer; + +export const GrantsSummaryOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + currencies: z.array(GrantsSummaryCurrencySchema).max(GRANTS_SUMMARY_MAX_CURRENCIES), + truncated: z.boolean(), +}); +export type GrantsSummary = z.infer; + +export const grantsSummaryTool = defineMcpTool({ + id: 'bonus.grants_summary', + title: 'Bonus grants summary', + description: + 'Operator-wide bonus grants per currency over whole UTC days from dateFrom through dateTo ' + + '(defaults to the 30 days ending today; at most 366 days). granted: grants funded in range ' + + 'and the bonus they credited. Grants that ended in range: converted (wagering completed; ' + + 'amount is the bonus paid out as cash), expired and forfeited (amount is the bonus balance ' + + 'taken away; byReason counts why, e.g. self_exclusion or withdrawal_while_active) and ' + + 'cancelled (claimed, never funded). bySource splits granted and converted by what caused ' + + 'the grant (deposit, manual, cashback, race, ...). outstanding is now, whatever the range: ' + + 'active and pending grants, the bonus balance still on them (the liability), the wagering ' + + 'still required, and those expiring within 7 days. Narrow with currency or source. At most ' + + '20 currencies, alphabetical; truncated is true when more had grants. Amounts are decimal ' + + 'strings in the row currency, never summed across currencies. Names no player.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'bonus', action: 'view' }, + inputSchema: GrantsSummaryInputSchema, + outputSchema: GrantsSummaryOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'currencies', 'truncated'] }, + errors: [], +}); diff --git a/packages/core/src/promo/bonus/contract/index.ts b/packages/core/src/promo/bonus/contract/index.ts index 4ab4686a7..2a0567a15 100644 --- a/packages/core/src/promo/bonus/contract/index.ts +++ b/packages/core/src/promo/bonus/contract/index.ts @@ -452,3 +452,4 @@ export const bonusContract = { .output(paginated(PlayerGrantEntrySchema)), }, }; +export * from './agent-tools.js'; diff --git a/packages/core/src/promo/bonus/drizzle/migrations/0013_melted_morg.sql b/packages/core/src/promo/bonus/drizzle/migrations/0013_melted_morg.sql new file mode 100644 index 000000000..787f09fe3 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/0013_melted_morg.sql @@ -0,0 +1,2 @@ +CREATE INDEX "promo_grant_activated_at_idx" ON "promo_grant" USING btree ("activated_at") WHERE "promo_grant"."activated_at" is not null;--> statement-breakpoint +CREATE INDEX "promo_grant_closed_at_idx" ON "promo_grant" USING btree ("closed_at") WHERE "promo_grant"."closed_at" is not null; \ No newline at end of file diff --git a/packages/core/src/promo/bonus/drizzle/migrations/meta/0013_snapshot.json b/packages/core/src/promo/bonus/drizzle/migrations/meta/0013_snapshot.json new file mode 100644 index 000000000..9839e5217 --- /dev/null +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/0013_snapshot.json @@ -0,0 +1,1024 @@ +{ + "id": "4df768d6-6067-4406-aa0c-e26e225cb4de", + "prevId": "33bc791d-35db-49ee-a574-6dd58b23f37a", + "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 + }, + "forfeited_by": { + "name": "forfeited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "forfeit_note": { + "name": "forfeit_note", + "type": "text", + "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_id_user_id_currency_idx": { + "name": "promo_grant_id_user_id_currency_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_currency_expires_at_idx": { + "name": "promo_grant_user_id_currency_expires_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_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\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_user_id_created_at_idx": { + "name": "promo_grant_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_activated_at_idx": { + "name": "promo_grant_activated_at_idx", + "columns": [ + { + "expression": "activated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"activated_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_closed_at_idx": { + "name": "promo_grant_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant\".\"closed_at\" is not null", + "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_grant_entry": { + "name": "promo_grant_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "promo_grant_entry_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "bonus_amount": { + "name": "bonus_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "real_amount": { + "name": "real_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "wagering_delta": { + "name": "wagering_delta", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "balance_after": { + "name": "balance_after", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "external_round_id": { + "name": "external_round_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_transaction_id": { + "name": "wallet_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_grant_entry_user_id_currency_provider_name_external_round_id_idx": { + "name": "promo_grant_entry_user_id_currency_provider_name_external_round_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"promo_grant_entry\".\"external_round_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_entry_grant_id_created_at_idx": { + "name": "promo_grant_entry_grant_id_created_at_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "promo_grant_entry_user_id_created_at_idx": { + "name": "promo_grant_entry_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_grant_entry_grant_id_user_id_currency_promo_grant_id_user_id_currency_fk": { + "name": "promo_grant_entry_grant_id_user_id_currency_promo_grant_id_user_id_currency_fk", + "tableFrom": "promo_grant_entry", + "tableTo": "promo_grant", + "columnsFrom": ["grant_id", "user_id", "currency"], + "columnsTo": ["id", "user_id", "currency"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_offer": { + "name": "promo_offer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "promo_offer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_percent": { + "name": "match_percent", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "max_grant_amount": { + "name": "max_grant_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "terms": { + "name": "terms", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rules": { + "name": "rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "requires_opt_in": { + "name": "requires_opt_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_offer_status_valid_from_valid_until_idx": { + "name": "promo_offer_status_valid_from_valid_until_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_from", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "valid_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "promo_offer_key_unique": { + "name": "promo_offer_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "promo_offer_match_percent_positive": { + "name": "promo_offer_match_percent_positive", + "value": "\"promo_offer\".\"match_percent\" > 0 AND \"promo_offer\".\"max_grant_amount\" > 0 AND \"promo_offer\".\"min_deposit\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.promo_opt_in": { + "name": "promo_opt_in", + "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 + }, + "offer_id": { + "name": "offer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accumulated_deposit": { + "name": "accumulated_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_opt_in_user_id_offer_id_idx": { + "name": "promo_opt_in_user_id_offer_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_opt_in_offer_id_promo_offer_id_fk": { + "name": "promo_opt_in_offer_id_promo_offer_id_fk", + "tableFrom": "promo_opt_in", + "tableTo": "promo_offer", + "columnsFrom": ["offer_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promo_opt_in_deposit": { + "name": "promo_opt_in_deposit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "opt_in_id": { + "name": "opt_in_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "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_opt_in_deposit_opt_in_id_transaction_id_idx": { + "name": "promo_opt_in_deposit_opt_in_id_transaction_id_idx", + "columns": [ + { + "expression": "opt_in_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_opt_in_deposit_opt_in_id_promo_opt_in_id_fk": { + "name": "promo_opt_in_deposit_opt_in_id_promo_opt_in_id_fk", + "tableFrom": "promo_opt_in_deposit", + "tableTo": "promo_opt_in", + "columnsFrom": ["opt_in_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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(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", + "cooling_off", + "account_closed", + "account_deactivated", + "admin", + "player_opt_out", + "withdrawal_while_active", + "terms_breach" + ] + }, + "public.promo_grant_entry_type": { + "name": "promo_grant_entry_type", + "schema": "public", + "values": ["grant", "stake", "win", "reversal", "convert", "forfeit", "expire"] + }, + "public.promo_grant_source": { + "name": "promo_grant_source", + "schema": "public", + "values": [ + "deposit", + "manual", + "streak", + "rank", + "race", + "gift", + "rain", + "cashback", + "donation" + ] + }, + "public.promo_grant_status": { + "name": "promo_grant_status", + "schema": "public", + "values": ["pending", "active", "completed", "expired", "forfeited", "cancelled"] + }, + "public.promo_offer_status": { + "name": "promo_offer_status", + "schema": "public", + "values": ["draft", "active", "paused", "archived"] + }, + "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 1a89482a0..ae8a4db09 100644 --- a/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/promo/bonus/drizzle/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1793793608870, "tag": "0012_bonus_grant_source_donation", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1793961419458, + "tag": "0013_melted_morg", + "breakpoints": true } ] } diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index 8da9c57d3..4011c5bb3 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -37,11 +37,12 @@ import { GrantLifecycleService } from './service/grant-lifecycle.service.js'; import { createBonusLifecyclePort } from './service/bonus-lifecycle-port.service.js'; import { GrantReaderService, bonusGrantLedger } from './service/grant-reader.service.js'; import { GrantService } from './service/grant.service.js'; +import { GrantsSummaryService } from './service/grants-summary.service.js'; import { OfferService } from './service/offer.service.js'; import { WageringService } from './service/wagering.service.js'; import { WeightService } from './service/weight.service.js'; import { bonusBalanceChannel, createBonusRouter } from './router/index.js'; -import type { BonusBalanceChangeReason } from './contract/index.js'; +import { grantsSummaryTool, type BonusBalanceChangeReason } from './contract/index.js'; const logger = createLogger('promo-bonus'); @@ -105,6 +106,10 @@ export default { register(ctx) { ctx.provide(BONUS_GRANTS, (c) => new GrantService(c.get(AUDIT_WRITER))); ctx.provide(BONUS_GRANT_LEDGER, () => bonusGrantLedger); + ctx.mcp.tool(grantsSummaryTool, (c) => { + const summary = new GrantsSummaryService(c.get(DRIZZLE)); + return (input) => summary.grantsSummary(input); + }); ctx.provideSealed( BONUS_WAGERING, (c) => new WageringService(c.has(WAGER_TRACKING) ? c.get(WAGER_TRACKING) : undefined), diff --git a/packages/core/src/promo/bonus/schema/index.ts b/packages/core/src/promo/bonus/schema/index.ts index 808419128..6bdbe693a 100644 --- a/packages/core/src/promo/bonus/schema/index.ts +++ b/packages/core/src/promo/bonus/schema/index.ts @@ -256,6 +256,13 @@ export const promoGrant = pgTable( // The player's own grant history: every status, newest first, over the whole table rather // than just the live partition the index above serves. index('promo_grant_user_id_created_at_idx').on(t.userId, t.createdAt, t.id), + // The operator-wide grants summary: the grants funded, and the grants that ended, in a range. + index('promo_grant_activated_at_idx') + .on(t.activatedAt) + .where(sql`${t.activatedAt} is not null`), + index('promo_grant_closed_at_idx') + .on(t.closedAt) + .where(sql`${t.closedAt} is not null`), // 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`), diff --git a/packages/core/src/promo/bonus/service/grants-summary.service.ts b/packages/core/src/promo/bonus/service/grants-summary.service.ts new file mode 100644 index 000000000..d1582c4d6 --- /dev/null +++ b/packages/core/src/promo/bonus/service/grants-summary.service.ts @@ -0,0 +1,201 @@ +import { and, count, eq, gte, inArray, lte, or, sql, type SQL } from 'drizzle-orm'; +import type { PgColumn } from 'drizzle-orm/pg-core'; +import { moneyAdd, type DrizzleService } from '@openora/core/server'; +import { + dayRangeInstants, + type BonusForfeitReason, + type BonusGrantSource, +} from '@openora/core/contracts'; +import { + GRANTS_EXPIRING_SOON_DAYS, + GRANTS_SUMMARY_MAX_CURRENCIES, + resolveGrantsSummaryRange, + type GrantsSummary, + type GrantsSummaryCurrency, + type GrantsSummaryInput, +} from '../contract/index.js'; +import { promoGrant, promoGrantEntry } from '../schema/index.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const ZERO = '0'; + +const grant = promoGrant; +const entry = promoGrantEntry; +const sumOf = (amount: SQL | PgColumn, where?: SQL) => + where + ? sql`coalesce(sum(${amount}) filter (where ${where}), 0)::numeric(38, 18)` + : sql`coalesce(sum(${amount}), 0)::numeric(38, 18)`; + +type Counted = { count: number; amount: string }; + +const counted = (): Counted => ({ count: 0, amount: ZERO }); +const add = (into: Counted, more: Counted) => { + into.count += more.count; + into.amount = moneyAdd(into.amount, more.amount); +}; + +function emptyCurrency(currency: string): GrantsSummaryCurrency { + return { + currency, + granted: counted(), + converted: counted(), + expired: counted(), + forfeited: { ...counted(), byReason: [] }, + cancelled: 0, + bySource: [], + outstanding: { + activeGrants: 0, + pendingGrants: 0, + bonusBalance: ZERO, + wageringRemaining: ZERO, + expiringSoon: counted(), + }, + }; +} + +function sourceOf(row: GrantsSummaryCurrency, source: BonusGrantSource) { + let found = row.bySource.find((item) => item.source === source); + if (!found) { + found = { source, granted: counted(), converted: counted() }; + row.bySource.push(found); + } + return found; +} + +function reasonCount(row: GrantsSummaryCurrency, reason: BonusForfeitReason, total: number) { + const found = row.forfeited.byReason.find((item) => item.reason === reason); + if (found) { + found.count += total; + } else { + row.forfeited.byReason.push({ reason, count: total }); + } +} + +/** + * The operator's bonus grants per currency: what was granted and how grants ended in a range, read + * off the grants themselves and the bonus ledger entries that closed them, and what the live ones + * still hold now. + */ +export class GrantsSummaryService { + constructor(private readonly drizzle: DrizzleService) {} + + async grantsSummary( + { currency, source, ...dates }: GrantsSummaryInput, + now = new Date(), + ): Promise { + const range = resolveGrantsSummaryRange(dates, now); + const { from, to } = dayRangeInstants(range); + const narrowed = and( + currency ? eq(grant.currency, currency.toUpperCase()) : undefined, + source ? eq(grant.source, source) : undefined, + ); + const soon = new Date(now.getTime() + GRANTS_EXPIRING_SOON_DAYS * DAY_MS); + const active = eq(grant.status, 'active'); + const expiringSoon = sql`${active} and ${grant.expiresAt} <= ${soon}`; + const db = this.drizzle.db; + const [granted, ended, live] = await Promise.all([ + db + .select({ + currency: grant.currency, + source: grant.source, + count: count(), + amount: sumOf(grant.grantedAmount), + }) + .from(grant) + .where(and(gte(grant.activatedAt, from), lte(grant.activatedAt, to), narrowed)) + .groupBy(grant.currency, grant.source), + db + .select({ + currency: grant.currency, + source: grant.source, + status: grant.status, + reason: grant.forfeitReason, + count: sql`count(distinct ${grant.id})`.mapWith(Number), + // A closing entry is negative: the bonus balance that left the grant. + amount: sumOf(sql`-${entry.bonusAmount}`), + }) + .from(grant) + .leftJoin( + entry, + and( + eq(entry.grantId, grant.id), + or( + and(eq(grant.status, 'completed'), eq(entry.type, 'convert')), + and(eq(grant.status, 'expired'), eq(entry.type, 'expire')), + and(eq(grant.status, 'forfeited'), eq(entry.type, 'forfeit')), + ), + ), + ) + .where(and(gte(grant.closedAt, from), lte(grant.closedAt, to), narrowed)) + .groupBy(grant.currency, grant.source, grant.status, grant.forfeitReason), + db + .select({ + currency: grant.currency, + activeGrants: sql`count(*) filter (where ${active})`.mapWith(Number), + pendingGrants: sql`count(*) filter (where ${grant.status} = 'pending')`.mapWith( + Number, + ), + bonusBalance: sumOf(grant.bonusBalance, active), + wageringRemaining: sumOf( + sql`${grant.wageringRequired} - ${grant.wageringProgress}`, + active, + ), + expiringSoonCount: sql`count(*) filter (where ${expiringSoon})`.mapWith(Number), + expiringSoonAmount: sumOf(grant.bonusBalance, expiringSoon), + }) + .from(grant) + .where(and(inArray(grant.status, ['pending', 'active']), narrowed)) + .groupBy(grant.currency), + ]); + + const byCurrency = new Map(); + const rowOf = (code: string) => { + let row = byCurrency.get(code); + if (!row) { + row = emptyCurrency(code); + byCurrency.set(code, row); + } + return row; + }; + for (const { currency: code, source: cause, ...figures } of granted) { + const row = rowOf(code); + add(row.granted, figures); + add(sourceOf(row, cause).granted, figures); + } + for (const { currency: code, source: cause, status, reason, ...figures } of ended) { + const row = rowOf(code); + if (status === 'completed') { + add(row.converted, figures); + add(sourceOf(row, cause).converted, figures); + } else if (status === 'expired') { + add(row.expired, figures); + } else if (status === 'forfeited') { + add(row.forfeited, figures); + if (reason) { + reasonCount(row, reason, figures.count); + } + } else if (status === 'cancelled') { + row.cancelled += figures.count; + } + } + for (const { currency: code, expiringSoonCount, expiringSoonAmount, ...figures } of live) { + rowOf(code).outstanding = { + ...figures, + expiringSoon: { count: expiringSoonCount, amount: expiringSoonAmount }, + }; + } + + const currencies = [...byCurrency.values()].sort((a, b) => + a.currency.localeCompare(b.currency), + ); + for (const row of currencies) { + row.bySource.sort((a, b) => a.source.localeCompare(b.source)); + row.forfeited.byReason.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); + } + return { + ...range, + currencies: currencies.slice(0, GRANTS_SUMMARY_MAX_CURRENCIES), + truncated: currencies.length > GRANTS_SUMMARY_MAX_CURRENCIES, + }; + } +} From 8f0838bcdc851364585a83271ea05453e76849e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:05 +0100 Subject: [PATCH 5/6] test(testing): cover the gaming, sportsbook and bonus read tools end to end through the mcp kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel suite expects the three tools under their owners and calls each against rows seeded in a currency of its own: exactly the allow-listed keys, the game ranked with its figures, the open bet by player id without its event, the grant and the liability it holds, no user id in what is served, invalid_input for a range out of bounds, and forbidden - audited once - for an admin whose role lacks the tool's grant. The transport suite lists them under their model names. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../src/__tests__/mcp-kernel.e2e.test.ts | 236 ++++++++++++++++++ .../src/__tests__/mcp-transport.e2e.test.ts | 3 + 2 files changed, 239 insertions(+) diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index cda817ab3..a8d0f04e6 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -15,9 +15,12 @@ import { type RunContext, } from '@openora/core/contracts'; import { auditLog } from '@openora/core/audit/schema'; +import { game, gameProvider, gameRound } from '@openora/core/casino/schema/gaming'; import { kycVerification, rgExclusion, rgFlag, userLimit } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; import { playerTag, tag } from '@openora/core/pam/schema/tag'; +import { promoGrant, promoGrantEntry } from '@openora/core/promo/schema/bonus'; +import { sportsbookBet, sportsbookSession } from '@openora/core/sportsbook/schema/betting'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { registerAndMaterializePlayer, @@ -44,7 +47,9 @@ const REVIEWED = 'Reviewed, no issues.'; const NOTE_ROUTE_KEYS = ['actorId', 'content', 'createdAt', 'id', 'playerId', 'updatedAt']; const EXPECTED_TOOL_OWNERS = { + 'bonus.grants_summary': 'bonus', 'financial.summary': 'analytics', + 'gaming.top_games': 'gaming', 'ggr.summary': 'analytics', 'kyc.queue': 'compliance', 'kyc.status': 'compliance', @@ -53,6 +58,7 @@ const EXPECTED_TOOL_OWNERS = { 'rg.flags_queue': 'compliance', 'rg.player_state': 'compliance', 'players.deposit_compare': 'wallet', + 'sportsbook.turnover_exposure': 'betting', 'wallet.activity': 'wallet', 'withdrawals.queue': 'wallet', } as const; @@ -1491,3 +1497,233 @@ describe('compliance read tools serve RG and KYC facts by player id, under compl expect(rows[0]).toMatchObject({ action: 'mcp.tool.failed', resourceId: toolId }); }); }); + +describe('gaming, sportsbook and bonus read tools serve operator-wide figures per currency', () => { + // A currency and a month no other suite writes, so the figures are this suite's alone. + const CURRENCY = 'MCPX'; + const RANGE = { dateFrom: '2021-06-01', dateTo: '2021-06-30' }; + const IN_RANGE = new Date('2021-06-10T12:00:00.000Z'); + const EVENT_NAME = 'Hidden Final'; + let support: Awaited>; + let officer: Awaited>; + let bettor: Awaited>; + let gameId: string; + let betId: string; + + beforeAll(async () => { + support = await newStaff(app, 'mcp-no-report', 'customer-support-agent'); + officer = await newStaff(app, 'mcp-no-sportsbook', 'kyc-aml-officer'); + bettor = await newPlayer('sportsbook-open'); + const [provider] = await drizzle() + .insert(gameProvider) + .values({ slug: `mcp-studio-${randomUUID()}`, name: 'MCP Studio' }) + .returning({ id: gameProvider.id }); + const [row] = await drizzle() + .insert(game) + .values({ + name: 'MCP Aces', + slug: `mcp-aces-${randomUUID()}`, + providerId: provider?.id ?? '', + aggregator: 'direct', + reviewStatus: 'approved', + }) + .returning({ id: game.id }); + gameId = row?.id ?? ''; + await drizzle() + .insert(gameRound) + .values([ + { + gameId, + userId: bettor.userId, + status: 'completed', + betAmount: '100', + winAmount: '30', + currency: CURRENCY, + startedAt: IN_RANGE, + }, + { + gameId, + userId: randomUUID(), + status: 'completed', + betAmount: '40', + winAmount: '0', + currency: CURRENCY, + startedAt: IN_RANGE, + }, + ]); + const [session] = await drizzle() + .insert(sportsbookSession) + .values({ + userId: bettor.userId, + provider: 'book', + currency: CURRENCY, + countryCode: 'MT', + expiresAt: new Date(Date.now() + 60_000), + }) + .returning({ id: sportsbookSession.id }); + const [bet] = await drizzle() + .insert(sportsbookBet) + .values({ + userId: bettor.userId, + provider: 'book', + providerBetId: randomUUID(), + sessionId: session?.id ?? '', + currency: CURRENCY, + currencyDecimals: 2, + status: 'accepted', + stake: '20', + cashStake: '20', + voidPayout: '20', + potentialPayout: '120', + legs: [ + { eventId: 'mcp-event', eventName: EVENT_NAME, startsAt: '2030-01-01T00:00:00.000Z' }, + ], + lastLegStartsAt: new Date('2030-01-01T00:00:00.000Z'), + acceptedAt: IN_RANGE, + }) + .returning({ id: sportsbookBet.id }); + betId = bet?.id ?? ''; + const [grant] = await drizzle() + .insert(promoGrant) + .values({ + userId: bettor.userId, + currency: CURRENCY, + source: 'manual', + sourceRef: `mcp-${randomUUID()}`, + terms: { wageringMultiplier: '3', expiryDays: 14, weights: [] }, + grantedAmount: '25', + bonusBalance: '25', + wageringRequired: '75', + status: 'active', + activatedAt: IN_RANGE, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }) + .returning(); + if (!grant) { + throw new Error('no grant row'); + } + await drizzle().insert(promoGrantEntry).values({ + grantId: grant.id, + userId: grant.userId, + currency: CURRENCY, + type: 'grant', + bonusAmount: '25', + balanceAfter: '25', + }); + }); + + it('answers gaming.top_games with the game ranked in its currency, under report:view', async () => { + const output = outputOf( + await kernel.invokeTool( + 'gaming.top_games', + { ...RANGE, currency: CURRENCY }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('gaming.top_games')); + expect(listField(output, 'currencies')).toEqual([ + { + currency: CURRENCY, + totals: { + volume: expect.stringMatching(wholeAmount(140)), + revenue: expect.stringMatching(wholeAmount(110)), + roundsPlayed: 2, + uniquePlayers: 2, + gamesPlayed: 1, + }, + games: [ + { + gameId, + name: 'MCP Aces', + provider: 'MCP Studio', + gameType: 'casino', + volume: expect.stringMatching(wholeAmount(140)), + revenue: expect.stringMatching(wholeAmount(110)), + roundsPlayed: 2, + uniquePlayers: 2, + }, + ], + }, + ]); + expect(JSON.stringify(output)).not.toContain(bettor.userId); + }); + + it('answers sportsbook.turnover_exposure with the open bet by player id, without its event', async () => { + const output = outputOf( + await kernel.invokeTool( + 'sportsbook.turnover_exposure', + { ...RANGE, currency: CURRENCY }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('sportsbook.turnover_exposure')); + expect(listField(output, 'currencies')).toEqual([ + expect.objectContaining({ + currency: CURRENCY, + bets: 1, + stake: expect.stringMatching(wholeAmount(20)), + openBets: 1, + openPotentialPayout: expect.stringMatching(wholeAmount(120)), + largestOpen: [ + expect.objectContaining({ + betId, + playerId: bettor.playerId, + status: 'accepted', + legs: 1, + }), + ], + }), + ]); + const served = JSON.stringify(output); + expect(served).not.toContain(EVENT_NAME); + expect(served).not.toContain(bettor.userId); + }); + + it('answers bonus.grants_summary with the grant funded in range and the liability it holds', async () => { + const output = outputOf( + await kernel.invokeTool( + 'bonus.grants_summary', + { ...RANGE, currency: CURRENCY }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('bonus.grants_summary')); + expect(listField(output, 'currencies')).toEqual([ + expect.objectContaining({ + currency: CURRENCY, + granted: { count: 1, amount: expect.stringMatching(wholeAmount(25)) }, + bySource: [expect.objectContaining({ source: 'manual' })], + outstanding: expect.objectContaining({ + activeGrants: 1, + bonusBalance: expect.stringMatching(wholeAmount(25)), + wageringRemaining: expect.stringMatching(wholeAmount(75)), + }), + }), + ]); + expect(JSON.stringify(output)).not.toContain(bettor.userId); + + expect( + await kernel.invokeTool( + 'bonus.grants_summary', + { dateFrom: '2020-01-01', dateTo: '2021-06-30' }, + adminRun(adminId), + ), + ).toMatchObject({ ok: false, error: 'invalid_input' }); + }); + + it.each([ + ['gaming.top_games', 'report:view', () => support], + ['sportsbook.turnover_exposure', 'sportsbook:view', () => officer], + ['bonus.grants_summary', 'bonus:view', () => officer], + ] as const)('refuses %s to an admin whose roles lack %s', async (toolId, _grant, staff) => { + const run = adminRun(staff().userId); + + expect(await kernel.invokeTool(toolId, {}, run)).toEqual({ ok: false, error: 'forbidden' }); + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'mcp.tool.failed', resourceId: toolId }); + }); +}); diff --git a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts index 9160768d9..94102ad8d 100644 --- a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts @@ -303,7 +303,9 @@ describe('MCP transport, happy path through the SDK client', () => { const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ + 'bonus_grants_summary', 'financial_summary', + 'gaming_top_games', 'ggr_summary', 'kyc_queue', 'kyc_status', @@ -312,6 +314,7 @@ describe('MCP transport, happy path through the SDK client', () => { 'players_deposit_compare', 'rg_flags_queue', 'rg_player_state', + 'sportsbook_turnover_exposure', 'wallet_activity', 'withdrawals_queue', ]); From 976bda6f5ea36766518d92294fed63b538e571cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 11:53:06 +0100 Subject: [PATCH 6/6] docs(core): record the gaming, sportsbook and bonus read tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- ...-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md | 2 ++ docs/modules/gaming.md | 1 + docs/modules/sportsbook.md | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index c9784a0b3..6e2f2dbef 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -19,6 +19,8 @@ > **Update (2026-11-06)**: Compliance adds three read tools under `compliance:view`: `rg.player_state` (one player's responsible-gambling standing as `RG_STANDING_READER` reports it - exclusions in force, open flags, limits with a raise still waiting, and priced money windows - with no reason and no admin), `rg.flags_queue` (the open monitoring flags across all players, counted per type, the oldest by player id, without the flag's detail) and `kyc.queue` (each player's latest verification per tier waiting for a decision or a resubmission, the oldest pending by player id, without a decision reason, document or risk signal). The two queues join the player profile through its read-only schema, so they name a player by player id with no identity lookup per row. `rg.player_state` resolves the standing reader per call, so a kernel built without play eligibility still serves compliance's other tools. +> **Update (2026-11-06)**: Three more modules register operator-level read tools, each reporting per currency and never summing across currencies: `gaming.top_games` (gaming, `report:view`: the games ranked by completed rounds - volume, revenue, rounds and players - as the back office game performance report counts them), `sportsbook.turnover_exposure` (betting, `sportsbook:view`: stakes and payouts of a range as the per-player sportsbook summary counts them, the open bets' stakes and potential payout now, and the largest open bets by player id, without a selection, event or market) and `bonus.grants_summary` (bonus, `bonus:view`: grants funded in a range, how grants ended in it - converted, expired, forfeited with its reason, cancelled - by source, and the bonus balance and wagering live grants hold now, with no player). Each builds its reader from `DRIZZLE` alone, because the kernel builds every handler when it starts. The whole-UTC-day range the analytics tools take moved to the shared reporting contract, so every report tool resolves and bounds its days the same way. Reading the operation rather than one player adds partial indexes on `sportsbook_bet.settled_at` (settled bets) and on `promo_grant.activated_at` and `closed_at`. + ## Context An agent hub (a premium package outside this repo) runs models that read platform data and diff --git a/docs/modules/gaming.md b/docs/modules/gaming.md index 0e5f218d5..49fef15d8 100644 --- a/docs/modules/gaming.md +++ b/docs/modules/gaming.md @@ -10,6 +10,7 @@ The game catalog, category ordering, and round management module. `docs/catalog. - **Rule-based category membership** - a category can be populated by a rule instead of by hand: a pipeline of clauses over an operator-extensible catalog of rule kinds (built-ins: providers, tags, most played); see below. - **Player game favorites** - a player's hearted games, capped at 200; see below. - **Game review** - a newly imported game waits in a review queue, off, until an admin approves it or its provider auto-approves; see below. +- **Its agent surface** - `gaming.top_games`, a read tool under `report:view`, ranks the games per currency by the completed rounds of a range, with the figures the admin game performance report shows. It is served only through the MCP kernel (ADR-0042). - **Read ports** - `GAME_CATALOG_READER` for cross-module access (lobby sections, promotions) and `GAMING_COMMANDS` for wallet integration (`accumulateExternalRound`, `setGameAvailability`, `setGameAvailabilityInTransaction`) and catalogue imports (`notifyGamesCreated`, `notifyGamesChanged`). ## Game thumbnails diff --git a/docs/modules/sportsbook.md b/docs/modules/sportsbook.md index 468f1677f..e31a0661a 100644 --- a/docs/modules/sportsbook.md +++ b/docs/modules/sportsbook.md @@ -48,6 +48,10 @@ fields. This file does not repeat it. its payout leaves the period it first settled in, even when the reversal was collected. The ledger books a stake at reserve, not acceptance, and is cash-basis for a reopened bet, so a reversal left as debt or written off makes the ledger GGR lower than the summary's (ADR-0047). +- **Its agent surface.** `sportsbook.turnover_exposure`, a read tool under the `sportsbook` view + grant, totals every player's bets per currency by the player summary's rules, adds the open + bets' potential payout, and lists the largest open bets by player id, without their selections. + It is served only through the MCP kernel (ADR-0042). - **Sportsbook debt.** What a reversed settlement could not take back, per player and currency, with the withdrawal hold that lives exactly as long as the debt. - **Reconciliation findings.** Everything the ledger noticed but could not decide on its own, for