From c2767a837d86ba4edeae56289d39cdb1a07e23ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 5 Nov 2026 11:45:13 +0100 Subject: [PATCH 1/5] feat(iam): add the agent-chat permission for the back-office ai assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent hub gates its back-office assistant on `agent-chat:use`. Core only declares the grant: the assistant reads platform tools through the MCP kernel on the asking admin's own grants. The static admin role and the seeded admin role hold it, like `mcp-access`, and only the read-write level grants it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- packages/core/src/contracts/schemas/iam.ts | 3 +++ .../iam/__tests__/default-admin-roles.test.ts | 20 +++++++++++-------- .../src/iam/seed/data/default-admin-roles.ts | 1 + .../auth/__tests__/permission-levels.test.ts | 5 +++++ .../server/auth/__tests__/permissions.test.ts | 4 ++++ packages/core/src/server/auth/permissions.ts | 1 + 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/core/src/contracts/schemas/iam.ts b/packages/core/src/contracts/schemas/iam.ts index 62193787c..d4e162a76 100644 --- a/packages/core/src/contracts/schemas/iam.ts +++ b/packages/core/src/contracts/schemas/iam.ts @@ -57,6 +57,9 @@ export const adminStatement = { 'agent-proposal': ['view', 'approve', 'reject'] as const, 'agent-config': ['view', 'update'] as const, 'mcp-access': ['use'] as const, + // Checked by the agent hub, not by core: lets an admin use the back-office AI assistant, which + // reads platform tools on the admin's own grants. + 'agent-chat': ['use'] as const, 'mcp-token': ['view', 'revoke'] as const, 'support-chat': ['view'] as const, } as const; diff --git a/packages/core/src/iam/__tests__/default-admin-roles.test.ts b/packages/core/src/iam/__tests__/default-admin-roles.test.ts index db887acd5..3556ba3da 100644 --- a/packages/core/src/iam/__tests__/default-admin-roles.test.ts +++ b/packages/core/src/iam/__tests__/default-admin-roles.test.ts @@ -5,14 +5,15 @@ import { DEFAULT_ADMIN_ROLES } from '../seed/data/default-admin-roles.js'; const predefined = (key: string) => DEFAULT_ADMIN_ROLES.find((role) => role.key === key); describe('DEFAULT_ADMIN_ROLES', () => { - it('gives the predefined admin role read-write MCP access and token oversight', () => { + it('gives the predefined admin role read-write MCP access, token oversight and the AI assistant', () => { expect(predefined('admin')?.matrix).toMatchObject({ 'mcp-access': 'read_write', 'mcp-token': 'read_write', + 'agent-chat': 'read_write', }); }); - it.each(['mcp-access', 'mcp-token'] as const)( + it.each(['mcp-access', 'mcp-token', 'agent-chat'] as const)( 'grants the predefined admin role every %s action the static admin role holds', (resource) => { const level = predefined('admin')?.matrix[resource] ?? 'no_access'; @@ -29,13 +30,16 @@ describe('DEFAULT_ADMIN_ROLES', () => { expect(Object.keys(superAdmin?.matrix ?? {})).toEqual(Object.keys(statement)); }); - it('grants MCP access to no other predefined role', () => { - const withMcpAccess = DEFAULT_ADMIN_ROLES.filter( - (role) => !role.isSuperAdmin && role.matrix['mcp-access'] !== undefined, - ); + it.each(['mcp-access', 'agent-chat'] as const)( + 'grants %s to no other predefined role', + (resource) => { + const holders = DEFAULT_ADMIN_ROLES.filter( + (role) => !role.isSuperAdmin && role.matrix[resource] !== undefined, + ); - expect(withMcpAccess.map((role) => role.key)).toEqual(['admin']); - }); + expect(holders.map((role) => role.key)).toEqual(['admin']); + }, + ); it('holds every approval resource for the super admin alone', () => { const approvals = Object.keys(statement).filter((resource) => resource.endsWith('-approval')); diff --git a/packages/core/src/iam/seed/data/default-admin-roles.ts b/packages/core/src/iam/seed/data/default-admin-roles.ts index b43bfd619..f560837b5 100644 --- a/packages/core/src/iam/seed/data/default-admin-roles.ts +++ b/packages/core/src/iam/seed/data/default-admin-roles.ts @@ -52,6 +52,7 @@ export const DEFAULT_ADMIN_ROLES: readonly DefaultAdminRole[] = [ 'chat-command': RW, 'chat-moderation': RW, 'mcp-access': RW, + 'agent-chat': RW, 'mcp-token': RW, }, }, diff --git a/packages/core/src/server/auth/__tests__/permission-levels.test.ts b/packages/core/src/server/auth/__tests__/permission-levels.test.ts index 993ad099f..cbbc96be8 100644 --- a/packages/core/src/server/auth/__tests__/permission-levels.test.ts +++ b/packages/core/src/server/auth/__tests__/permission-levels.test.ts @@ -62,6 +62,11 @@ describe('levelToActions', () => { expect(levelToActions('content', 'read')).toEqual([]); }); + it.each(['mcp-access', 'agent-chat'])('grants %s only at read_write', (resource) => { + expect(levelToActions(resource, 'read')).toEqual([]); + expect(levelToActions(resource, 'read_write')).toEqual(['use']); + }); + it.each(PERMISSION_LEVELS)('grants nothing on an unknown resource at %s', (level) => { expect(levelToActions('tournaments', level)).toEqual([]); }); diff --git a/packages/core/src/server/auth/__tests__/permissions.test.ts b/packages/core/src/server/auth/__tests__/permissions.test.ts index 7cf1b424f..a23064777 100644 --- a/packages/core/src/server/auth/__tests__/permissions.test.ts +++ b/packages/core/src/server/auth/__tests__/permissions.test.ts @@ -21,12 +21,14 @@ describe('admin permission catalog', () => { agent: ['view', 'create', 'update', 'publish', 'run'], 'agent-proposal': ['view', 'approve', 'reject'], 'agent-config': ['view', 'update'], + 'agent-chat': ['use'], }); expect( adminRole.authorize({ agent: ['view', 'create', 'update', 'publish', 'run'], 'agent-proposal': ['view', 'approve', 'reject'], 'agent-config': ['view', 'update'], + 'agent-chat': ['use'], }).success, ).toBe(true); }); @@ -53,8 +55,10 @@ describe('holdsGrant', () => { it('falls back to the static role table without an assignment', () => { expect(holdsGrant({ role: 'admin', grants: null }, 'mcp-access', 'use')).toBe(true); + expect(holdsGrant({ role: 'admin', grants: null }, 'agent-chat', 'use')).toBe(true); expect(holdsGrant({ role: 'support', grants: null }, 'player', 'view')).toBe(true); expect(holdsGrant({ role: 'support', grants: null }, 'mcp-access', 'use')).toBe(false); + expect(holdsGrant({ role: 'support', grants: null }, 'agent-chat', 'use')).toBe(false); }); it('holds nothing for a role the static table does not know, grants or not', () => { diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index 6136aacf3..2e622ef17 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -45,6 +45,7 @@ export const adminRole = ac.newRole({ 'agent-proposal': ['view', 'approve', 'reject'], 'agent-config': ['view', 'update'], 'mcp-access': ['use'], + 'agent-chat': ['use'], 'mcp-token': ['view', 'revoke'], }); From 0dca93b912e98d1410108a3c5aa8bf4a43f5c4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 5 Nov 2026 11:50:34 +0100 Subject: [PATCH 2/5] feat(analytics): payments flow and financial summary agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-level read tools for the back-office assistant, both gated on `analytics:view`: - `payments.flow_daily` counts and sums deposits and withdrawals per UTC day or ISO week, type, status (every wallet transaction status) and currency over at most 92 days, 14 by default. One grouped query, cached like the other financial reports, capped at 1000 rows with a `truncated` flag. It names no player. - `financial.summary` wraps the existing summary and product split: deposits, withdrawals, net deposits, GGR, bonus cost and NGR per currency, rails folded together, plus stakes, payouts and GGR per product, over at most 366 days. Every list is capped. The day-range defaults and bounds of `ggr.summary` move into one helper the three tools share. Bonus cost keeps its meaning (bonus money converted to cash); the description says that cashback credits such as rakeback are not in it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../analytics-agent-registration.test.ts | 8 +- .../analytics-agent-tools.contract.test.ts | 79 ++++ .../analytics-agent-tools.int.test.ts | 415 ++++++++++++++++++ .../financial-summary-totals.test.ts | 78 ++++ .../src/analytics/contract/agent-tools.ts | 223 ++++++++-- packages/core/src/analytics/plugin.ts | 10 +- .../service/financial-analytics.service.ts | 175 +++++++- 7 files changed, 956 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/analytics/__tests__/analytics-agent-tools.contract.test.ts create mode 100644 packages/core/src/analytics/__tests__/analytics-agent-tools.int.test.ts create mode 100644 packages/core/src/analytics/__tests__/financial-summary-totals.test.ts diff --git a/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts b/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts index 608232d31..48475c55b 100644 --- a/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts +++ b/packages/core/src/analytics/__tests__/analytics-agent-registration.test.ts @@ -3,13 +3,17 @@ import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/c import analyticsPlugin from '../plugin.js'; describe('analytics agent surface registration', () => { - it('registers ggr.summary within the kernel contract rules', async () => { + it('registers its read tools within the kernel contract rules', async () => { const registry = new ModuleRegistryImpl(new Container()); registry.setOwner('analytics'); await analyticsPlugin.register(registry); - expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['ggr.summary']); + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([ + 'ggr.summary', + 'financial.summary', + 'payments.flow_daily', + ]); expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([]); }); }); diff --git a/packages/core/src/analytics/__tests__/analytics-agent-tools.contract.test.ts b/packages/core/src/analytics/__tests__/analytics-agent-tools.contract.test.ts new file mode 100644 index 000000000..814dea44c --- /dev/null +++ b/packages/core/src/analytics/__tests__/analytics-agent-tools.contract.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + FINANCIAL_SUMMARY_MAX_DAYS, + FinancialSummaryInputSchema, + PAYMENTS_FLOW_MAX_DAYS, + PaymentsFlowInputSchema, + resolveFinancialSummaryRange, + resolvePaymentsFlowRange, +} from '../contract/index.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const NOW = new Date('2026-03-15T12:00:00Z'); +const RANGE_START = '2024-01-01'; + +const dayOffset = (day: string, days: number) => + new Date(Date.parse(day) + days * DAY_MS).toISOString().slice(0, 10); + +describe('payments.flow_daily input', () => { + const parse = (input: unknown) => z.safeParse(PaymentsFlowInputSchema, input); + + it('defaults to day buckets over the 14 days ending on the day of now', () => { + expect(parse({})).toEqual({ success: true, data: { granularity: 'day' } }); + expect(resolvePaymentsFlowRange({}, NOW)).toEqual({ + dateFrom: '2026-03-02', + dateTo: '2026-03-15', + }); + }); + + it(`accepts a range of exactly ${PAYMENTS_FLOW_MAX_DAYS} days and refuses one day more`, () => { + const longest = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 91) }; + const tooLong = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 92) }; + + expect(parse(longest).success).toBe(true); + expect(parse(tooLong).error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]); + }); + + it('refuses a range that ends before it starts', () => { + const result = parse({ dateFrom: '2026-03-15', dateTo: '2026-03-14' }); + + expect(result.error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]); + }); + + it('takes only deposit and withdrawal types, each listed at most once in all', () => { + expect(parse({ types: ['withdrawal'] }).success).toBe(true); + expect(parse({ types: ['bet'] }).success).toBe(false); + expect(parse({ types: [] }).success).toBe(false); + expect(parse({ types: ['deposit', 'withdrawal', 'deposit'] }).success).toBe(false); + }); + + it('takes wallet transaction statuses only and day or week buckets only', () => { + expect(parse({ statuses: ['on_hold', 'refunded'] }).success).toBe(true); + expect(parse({ statuses: ['approved'] }).success).toBe(false); + expect(parse({ granularity: 'month' }).success).toBe(false); + }); +}); + +describe('financial.summary input', () => { + const parse = (input: unknown) => z.safeParse(FinancialSummaryInputSchema, input); + + it('defaults to the 30 days ending on the day of now', () => { + expect(resolveFinancialSummaryRange({}, NOW)).toEqual({ + dateFrom: '2026-02-14', + dateTo: '2026-03-15', + }); + }); + + it(`accepts a range of exactly ${FINANCIAL_SUMMARY_MAX_DAYS} days and refuses one day more`, () => { + const longest = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 365) }; + const tooLong = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 366) }; + + expect(parse(longest).success).toBe(true); + expect(parse(tooLong).error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]); + }); + + it('refuses a currency that is not a ticker', () => { + expect(parse({ currency: 'US-DOLLAR' }).success).toBe(false); + }); +}); diff --git a/packages/core/src/analytics/__tests__/analytics-agent-tools.int.test.ts b/packages/core/src/analytics/__tests__/analytics-agent-tools.int.test.ts new file mode 100644 index 000000000..f71b802ab --- /dev/null +++ b/packages/core/src/analytics/__tests__/analytics-agent-tools.int.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { + CACHE, + WALLET_TRANSACTION_STATUSES, + type RunContext, + type WalletRail, + type WalletTransactionStatus, + type WalletTransactionType, +} from '@openora/core/contracts'; +import { + Container, + DRIZZLE, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { migrate as migrateWallet } from '@openora/core/wallet/migrate'; +import { migrate as migrateGaming } from '@openora/core/casino/migrate/gaming'; +import { wallet, walletTransaction } from '@openora/core/wallet/schema'; +import { game, gameProvider, gameRound } from '@openora/core/casino/schema/gaming'; +import { makeAuditWriter, makeCache } from '../../testing/mock.js'; +import { PAYMENTS_FLOW_MAX_ROWS } from '../contract/index.js'; +import { FinancialAnalyticsService } from '../service/financial-analytics.service.js'; +import analyticsPlugin from '../plugin.js'; + +let db: TestDb; +let walletId: string; + +const FIRST_DAY = '2026-04-06'; +const LAST_DAY = '2026-04-14'; + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(CACHE, () => makeCache()); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('analytics'); + analyticsPlugin.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: 'admin', adminId: randomUUID() }, + catalogVersion: 'test', + correlationId: 'analytics-agent-tools', +}); + +type SeededTx = { + type: WalletTransactionType; + amount: string; + createdAt: string; + currency?: string; + status?: WalletTransactionStatus; + rail?: WalletRail | null; +}; + +const txRow = ({ + type, + amount, + createdAt, + currency = 'USD', + status = 'completed', + rail = null, +}: SeededTx) => ({ + walletId, + type, + amount, + currency, + status, + rail, + createdAt: new Date(createdAt), +}); + +async function seedTxs(txs: readonly SeededTx[]) { + await db.drizzle.db.insert(walletTransaction).values(txs.map(txRow)); +} + +async function seedCasinoRound(betAmount: string, winAmount: string, startedAt: string) { + const provider = findOneOrThrow( + await db.drizzle.db + .insert(gameProvider) + .values({ slug: `studio-${randomUUID()}`, name: 'Studio' }) + .returning(), + new Error('seed: no provider'), + ); + const row = findOneOrThrow( + await db.drizzle.db + .insert(game) + .values({ + name: 'casino', + slug: `game-${randomUUID()}`, + providerId: provider.id, + aggregator: 'agg', + gameType: 'casino', + }) + .returning(), + new Error('seed: no game'), + ); + await db.drizzle.db.insert(gameRound).values({ + gameId: row.id, + userId: randomUUID(), + currency: 'USD', + betAmount, + winAmount, + status: 'completed', + startedAt: new Date(startedAt), + }); +} + +beforeAll(async () => { + db = await createTestDb([migrateWallet, migrateGaming]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${walletTransaction}, ${wallet}, ${gameRound}, ${game}, ${gameProvider} RESTART IDENTITY CASCADE`, + ); + walletId = findOneOrThrow( + await db.drizzle.db + .insert(wallet) + .values({ userId: randomUUID(), currency: 'USD' }) + .returning(), + new Error('seed: no wallet'), + ).id; +}); + +describe('payments.flow_daily through the MCP kernel (real PG)', () => { + beforeEach(async () => { + await seedTxs([ + { type: 'deposit', amount: '100', createdAt: '2026-04-06T00:00:00Z' }, + { type: 'deposit', amount: '50.25', createdAt: '2026-04-06T23:59:59Z' }, + { type: 'deposit', amount: '70', createdAt: '2026-04-06T12:00:00Z', status: 'failed' }, + { type: 'withdrawal', amount: '30', createdAt: '2026-04-07T08:00:00Z', status: 'on_hold' }, + { type: 'withdrawal', amount: '15', createdAt: '2026-04-07T09:00:00Z', status: 'pending' }, + { type: 'deposit', amount: '0.5', createdAt: '2026-04-14T23:00:00Z', currency: 'BTC' }, + { + type: 'deposit', + amount: '1', + createdAt: '2026-04-13T10:00:00Z', + status: 'refunded', + currency: 'BTC', + }, + { type: 'bet', amount: '999', createdAt: '2026-04-07T10:00:00Z' }, + { type: 'deposit', amount: '999', createdAt: '2026-04-05T23:59:59Z' }, + { type: 'deposit', amount: '999', createdAt: '2026-04-15T00:00:00Z' }, + ]); + }); + + it('counts and sums deposits and withdrawals per UTC day, type, status and currency', async () => { + const result = await bootKernel().invokeTool( + 'payments.flow_daily', + { dateFrom: FIRST_DAY, dateTo: LAST_DAY }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + dateFrom: FIRST_DAY, + dateTo: LAST_DAY, + granularity: 'day', + rows: [ + { + bucket: '2026-04-06', + type: 'deposit', + status: 'completed', + currency: 'USD', + count: 2, + amount: '150.250000000000000000', + }, + { + bucket: '2026-04-06', + type: 'deposit', + status: 'failed', + currency: 'USD', + count: 1, + amount: '70.000000000000000000', + }, + { + bucket: '2026-04-07', + type: 'withdrawal', + status: 'pending', + currency: 'USD', + count: 1, + amount: '15.000000000000000000', + }, + { + bucket: '2026-04-07', + type: 'withdrawal', + status: 'on_hold', + currency: 'USD', + count: 1, + amount: '30.000000000000000000', + }, + { + bucket: '2026-04-13', + type: 'deposit', + status: 'refunded', + currency: 'BTC', + count: 1, + amount: '1.000000000000000000', + }, + { + bucket: '2026-04-14', + type: 'deposit', + status: 'completed', + currency: 'BTC', + count: 1, + amount: '0.500000000000000000', + }, + ], + truncated: false, + }, + }); + }); + + it('labels a week bucket by its Monday and narrows by type, status and currency whatever its case', async () => { + const kernel = bootKernel(); + + const weekly = await kernel.invokeTool( + 'payments.flow_daily', + { dateFrom: FIRST_DAY, dateTo: LAST_DAY, granularity: 'week', currency: 'btc' }, + runContext(), + ); + const held = await kernel.invokeTool( + 'payments.flow_daily', + { dateFrom: FIRST_DAY, dateTo: LAST_DAY, types: ['withdrawal'], statuses: ['on_hold'] }, + runContext(), + ); + + expect(weekly).toMatchObject({ + ok: true, + output: { + granularity: 'week', + rows: [ + { bucket: '2026-04-13', type: 'deposit', status: 'completed', currency: 'BTC', count: 1 }, + { bucket: '2026-04-13', type: 'deposit', status: 'refunded', currency: 'BTC', count: 1 }, + ], + }, + }); + expect(held).toMatchObject({ + ok: true, + output: { + rows: [{ bucket: '2026-04-07', status: 'on_hold', amount: '30.000000000000000000' }], + }, + }); + expect(held.ok && held.output['rows']).toHaveLength(1); + }); + + it('refuses a range longer than 92 days and a type that is not a payment', async () => { + const kernel = bootKernel(); + + const tooLong = await kernel.invokeTool( + 'payments.flow_daily', + { dateFrom: '2026-01-01', dateTo: '2026-04-03' }, + runContext(), + ); + const bets = await kernel.invokeTool('payments.flow_daily', { types: ['bet'] }, runContext()); + + expect(tooLong).toMatchObject({ + ok: false, + error: 'invalid_input', + issues: [expect.objectContaining({ path: 'dateFrom' })], + }); + expect(bets).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); + +describe('FinancialAnalyticsService.paymentsFlow at its row cap (real PG)', () => { + beforeEach(async () => { + const days = Array.from({ length: 63 }, (_, day) => + new Date(Date.parse('2026-01-01T12:00:00Z') + day * 24 * 60 * 60 * 1000).toISOString(), + ); + const rows = days.flatMap((createdAt) => + WALLET_TRANSACTION_STATUSES.flatMap((status) => + (['deposit', 'withdrawal'] as const).map((type) => ({ + type, + amount: '1', + createdAt, + status, + })), + ), + ); + await seedTxs(rows); + }); + + it('returns the oldest 1000 groups and says more matched', async () => { + const service = new FinancialAnalyticsService(db.drizzle, makeCache()); + + const flow = await service.paymentsFlow({ + dateFrom: '2026-01-01', + dateTo: '2026-03-04', + granularity: 'day', + }); + + expect(flow.rows).toHaveLength(PAYMENTS_FLOW_MAX_ROWS); + expect(flow.truncated).toBe(true); + expect(flow.rows[0]).toMatchObject({ + bucket: '2026-01-01', + type: 'deposit', + status: 'pending', + }); + expect(flow.rows.at(-1)).toMatchObject({ + bucket: '2026-03-04', + type: 'deposit', + status: 'refunded', + }); + }); +}); + +describe('financial.summary through the MCP kernel (real PG)', () => { + beforeEach(async () => { + await seedTxs([ + { type: 'deposit', amount: '500', createdAt: '2026-04-07T10:00:00Z', rail: 'fiat' }, + { type: 'deposit', amount: '100', createdAt: '2026-04-08T10:00:00Z' }, + { type: 'deposit', amount: '999', createdAt: '2026-04-08T11:00:00Z', status: 'pending' }, + { type: 'withdrawal', amount: '200', createdAt: '2026-04-09T10:00:00Z', rail: 'fiat' }, + { type: 'bonus', amount: '30', createdAt: '2026-04-09T11:00:00Z' }, + { type: 'cashback', amount: '7', createdAt: '2026-04-09T12:00:00Z' }, + { type: 'bet', amount: '100', createdAt: '2026-04-10T10:00:00Z' }, + { type: 'win', amount: '40', createdAt: '2026-04-10T11:00:00Z' }, + { + type: 'deposit', + amount: '2', + createdAt: '2026-04-11T10:00:00Z', + currency: 'BTC', + rail: 'crypto', + }, + { type: 'deposit', amount: '999', createdAt: '2026-04-15T00:00:00Z' }, + ]); + await seedCasinoRound('100', '40', '2026-04-10T10:00:00Z'); + }); + + it('totals each currency, leaves cashback out of bonus cost and splits stakes by product', async () => { + const result = await bootKernel().invokeTool( + 'financial.summary', + { dateFrom: FIRST_DAY, dateTo: LAST_DAY }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + dateFrom: FIRST_DAY, + dateTo: LAST_DAY, + currencies: [ + { + currency: 'BTC', + deposits: '2.000000000000000000', + withdrawals: '0', + netDeposits: '2.000000000000000000', + ggr: '0', + bonusCost: '0', + ngr: '0', + }, + { + currency: 'USD', + deposits: '600.000000000000000000', + withdrawals: '200.000000000000000000', + netDeposits: '370.000000000000000000', + ggr: '60.000000000000000000', + bonusCost: '30.000000000000000000', + ngr: '30.000000000000000000', + }, + ], + products: [ + { + product: 'casino', + currency: 'USD', + stakes: '100.000000000000000000', + payouts: '40.000000000000000000', + ggr: '60.000000000000000000', + }, + ], + truncated: false, + }, + }); + }); + + it('narrows to one currency whatever its case and refuses a range longer than 366 days', async () => { + const kernel = bootKernel(); + + const btc = await kernel.invokeTool( + 'financial.summary', + { dateFrom: FIRST_DAY, dateTo: LAST_DAY, currency: 'btc' }, + runContext(), + ); + const tooLong = await kernel.invokeTool( + 'financial.summary', + { dateFrom: '2025-01-01', dateTo: '2026-01-02' }, + runContext(), + ); + + expect(btc).toMatchObject({ + ok: true, + output: { currencies: [{ currency: 'BTC' }], products: [] }, + }); + expect(btc.ok && btc.output['currencies']).toHaveLength(1); + expect(tooLong).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); diff --git a/packages/core/src/analytics/__tests__/financial-summary-totals.test.ts b/packages/core/src/analytics/__tests__/financial-summary-totals.test.ts new file mode 100644 index 000000000..5577cc483 --- /dev/null +++ b/packages/core/src/analytics/__tests__/financial-summary-totals.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { totalsPerCurrency } from '../service/financial-analytics.service.js'; + +describe('totalsPerCurrency', () => { + it('folds the rails of a currency together and keeps every currency apart', () => { + const totals = totalsPerCurrency({ + deposits: [ + { currency: 'USDT', rail: 'crypto', total: '100.5' }, + { currency: 'USDT', rail: 'fiat', total: '20' }, + { currency: 'EUR', rail: null, total: '7' }, + ], + withdrawals: [{ currency: 'USDT', rail: 'crypto', total: '40' }], + netDeposits: [ + { currency: 'USDT', total: '75.5' }, + { currency: 'EUR', total: '7' }, + { currency: 'BTC', total: '-0.5' }, + ], + ggr: [ + { currency: 'USDT', total: '12' }, + { currency: 'EUR', total: '0' }, + { currency: 'BTC', total: '-1' }, + ], + bonusCost: [ + { currency: 'USDT', total: '5' }, + { currency: 'EUR', total: '0' }, + { currency: 'BTC', total: '0.5' }, + ], + ngr: [ + { currency: 'USDT', total: '7' }, + { currency: 'EUR', total: '0' }, + { currency: 'BTC', total: '-1.5' }, + ], + }); + + expect(totals).toEqual([ + { + currency: 'BTC', + deposits: '0', + withdrawals: '0', + netDeposits: '-0.5', + ggr: '-1', + bonusCost: '0.5', + ngr: '-1.5', + }, + { + currency: 'EUR', + deposits: '7.000000000000000000', + withdrawals: '0', + netDeposits: '7', + ggr: '0', + bonusCost: '0', + ngr: '0', + }, + { + currency: 'USDT', + deposits: '120.500000000000000000', + withdrawals: '40.000000000000000000', + netDeposits: '75.5', + ggr: '12', + bonusCost: '5', + ngr: '7', + }, + ]); + }); + + it('answers no currency for an empty summary', () => { + expect( + totalsPerCurrency({ + deposits: [], + withdrawals: [], + netDeposits: [], + ggr: [], + bonusCost: [], + ngr: [], + }), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/analytics/contract/agent-tools.ts b/packages/core/src/analytics/contract/agent-tools.ts index 03dddb3bb..f424c3d07 100644 --- a/packages/core/src/analytics/contract/agent-tools.ts +++ b/packages/core/src/analytics/contract/agent-tools.ts @@ -1,32 +1,86 @@ import * as z from 'zod'; -import { CurrencyTickerSchema, GranularitySchema, defineMcpTool } from '@openora/core/contracts'; +import { + CurrencyTickerSchema, + GameTypeSchema, + GranularitySchema, + MoneyAmountSchema, + SignedMoneyAmountSchema, + WALLET_TRANSACTION_STATUSES, + WalletTransactionStatusSchema, + WalletTransactionTypeSchema, + defineMcpTool, +} 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; +export const PAYMENTS_FLOW_MAX_DAYS = 92; +export const PAYMENTS_FLOW_MAX_ROWS = 1000; +const FINANCIAL_SUMMARY_DEFAULT_DAYS = 30; +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 ggr.summary call covers, both ends inclusive: `dateTo` defaults to the - * day of `now`, and `dateFrom` to the 30 days ending on `dateTo`. + * 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`. */ -export function resolveGgrSummaryRange( - { dateFrom, dateTo }: { dateFrom?: string; dateTo?: string }, - now = new Date(), -) { +function resolveDayRange( + { dateFrom, dateTo }: DayRangeInput, + defaultDays: number, + now: Date, +): DayRange { const to = dateTo ?? isoDay(now.getTime()); return { - dateFrom: dateFrom ?? isoDay(Date.parse(to) - (GGR_SUMMARY_DEFAULT_DAYS - 1) * DAY_MS), + dateFrom: dateFrom ?? isoDay(Date.parse(to) - (defaultDays - 1) * DAY_MS), dateTo: to, }; } -function ggrSummaryRangeDays({ dateFrom, dateTo }: { dateFrom: string; dateTo: string }) { +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); +} + +/** payments.flow_daily's days: `dateTo` defaults to the day of `now`, `dateFrom` to 14 days ending on it. */ +export function resolvePaymentsFlowRange(input: DayRangeInput, now = new Date()) { + return resolveDayRange(input, PAYMENTS_FLOW_DEFAULT_DAYS, now); +} + +/** financial.summary's days: `dateTo` defaults to the day of `now`, `dateFrom` to 30 days ending on it. */ +export function resolveFinancialSummaryRange(input: DayRangeInput, now = new Date()) { + return resolveDayRange(input, FINANCIAL_SUMMARY_DEFAULT_DAYS, now); +} + export const GgrSummaryInputSchema = z .object({ dateFrom: z.iso.date().optional(), @@ -34,24 +88,9 @@ export const GgrSummaryInputSchema = z currency: CurrencyTickerSchema.max(10).optional(), granularity: GranularitySchema.default('week'), }) - .superRefine((input, ctx) => { - const range = resolveGgrSummaryRange(input); - if (range.dateFrom > range.dateTo) { - ctx.addIssue({ - code: 'custom', - path: ['dateFrom'], - message: 'dateFrom must be on or before dateTo', - }); - return; - } - if (ggrSummaryRangeDays(range) > GGR_SUMMARY_MAX_DAYS) { - ctx.addIssue({ - code: 'custom', - path: ['dateFrom'], - message: `the range spans at most ${GGR_SUMMARY_MAX_DAYS} days`, - }); - } - }); + .superRefine((input, ctx) => + checkDayRange(resolveGgrSummaryRange(input), GGR_SUMMARY_MAX_DAYS, ctx), + ); export type GgrSummaryInput = z.infer; export const GgrSummaryOutputSchema = z.object({ @@ -79,3 +118,133 @@ export const ggrSummaryTool = defineMcpTool({ redact: { allow: ['dateFrom', 'dateTo', 'granularity', 'series'] }, errors: [], }); + +export const PaymentFlowTypeSchema = WalletTransactionTypeSchema.extract(['deposit', 'withdrawal']); +export const PAYMENT_FLOW_TYPES = PaymentFlowTypeSchema.options; +export type PaymentFlowType = z.infer; + +export const PaymentFlowGranularitySchema = GranularitySchema.extract(['day', 'week']); +export type PaymentFlowGranularity = z.infer; + +export const PaymentsFlowInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + types: z.array(PaymentFlowTypeSchema).min(1).max(PAYMENT_FLOW_TYPES.length).optional(), + statuses: z + .array(WalletTransactionStatusSchema) + .min(1) + .max(WALLET_TRANSACTION_STATUSES.length) + .optional(), + currency: CurrencyTickerSchema.max(10).optional(), + granularity: PaymentFlowGranularitySchema.default('day'), + }) + .superRefine((input, ctx) => + checkDayRange(resolvePaymentsFlowRange(input), PAYMENTS_FLOW_MAX_DAYS, ctx), + ); +export type PaymentsFlowInput = z.infer; + +export const PaymentFlowRowSchema = z.object({ + bucket: z.iso.date(), + type: PaymentFlowTypeSchema, + status: WalletTransactionStatusSchema, + currency: CurrencyTickerSchema, + count: z.number().int().nonnegative(), + amount: MoneyAmountSchema, +}); +export type PaymentFlowRow = z.infer; + +export const PaymentsFlowOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + granularity: PaymentFlowGranularitySchema, + rows: z.array(PaymentFlowRowSchema).max(PAYMENTS_FLOW_MAX_ROWS), + truncated: z.boolean(), +}); +export type PaymentsFlow = z.infer; + +export const paymentsFlowDailyTool = defineMcpTool({ + id: 'payments.flow_daily', + title: 'Payments flow', + description: + 'Operator-wide deposits and withdrawals as one row per UTC day (or ISO week, labelled by its ' + + 'Monday), type, status and currency, with how many there were and their summed amount. A row ' + + 'counts the transactions created in its bucket under the status they hold now: pending, ' + + 'processing, completed, failed, rejected, on_hold (manual review, AML holds included), ' + + 'cancelled or refunded. Covers whole UTC days from dateFrom through dateTo; defaults to the ' + + '14 days ending today, and a range spans at most 92 days. Narrow it with types, statuses or ' + + 'currency. At most 1000 rows, oldest bucket first; truncated is true when more matched, so ' + + 'narrow the range or the filters. Amounts are decimal strings in the row currency and are ' + + 'never summed across currencies. Names no player.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'analytics', action: 'view' }, + inputSchema: PaymentsFlowInputSchema, + outputSchema: PaymentsFlowOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'granularity', 'rows', 'truncated'] }, + errors: [], +}); + +export const FinancialSummaryInputSchema = z + .object({ + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), + currency: CurrencyTickerSchema.max(10).optional(), + }) + .superRefine((input, ctx) => + checkDayRange(resolveFinancialSummaryRange(input), FINANCIAL_SUMMARY_MAX_DAYS, ctx), + ); +export type FinancialSummaryInput = z.infer; + +export const FinancialCurrencyTotalsSchema = z.object({ + currency: CurrencyTickerSchema, + deposits: MoneyAmountSchema, + withdrawals: MoneyAmountSchema, + netDeposits: SignedMoneyAmountSchema, + ggr: SignedMoneyAmountSchema, + bonusCost: SignedMoneyAmountSchema, + ngr: SignedMoneyAmountSchema, +}); +export type FinancialCurrencyTotals = z.infer; + +export const FinancialSummaryOutputSchema = z.object({ + dateFrom: z.iso.date(), + dateTo: z.iso.date(), + currencies: z.array(FinancialCurrencyTotalsSchema).max(FINANCIAL_SUMMARY_MAX_CURRENCIES), + products: z + .array( + z.object({ + product: GameTypeSchema, + currency: CurrencyTickerSchema, + stakes: MoneyAmountSchema, + payouts: SignedMoneyAmountSchema, + ggr: SignedMoneyAmountSchema, + }), + ) + .max(FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS), + truncated: z.boolean(), +}); +export type FinancialSummaryOverview = z.infer; + +export const financialSummaryTool = defineMcpTool({ + id: 'financial.summary', + title: 'Financial summary', + description: + 'Operator-wide money totals per currency over whole UTC days from dateFrom through dateTo ' + + '(defaults to the 30 days ending today; a range spans at most 366 days): completed deposits ' + + 'and withdrawals, net deposits (deposits minus withdrawals minus bonus cost), GGR from the ' + + 'wallet ledger (bets minus bet reversals minus wins plus win reversals), bonus cost and NGR ' + + '(GGR minus bonus cost). Bonus cost is bonus money converted to cash once its wagering ' + + 'completed; rakeback and race, streak and rank-challenge rewards are paid as cashback and ' + + 'are not in it. products splits stakes, payouts and GGR by product from game rounds and the ' + + 'sportsbook ledger, so its GGR need not match the ledger GGR. At most 100 currencies and 300 ' + + 'product rows; truncated is true when a list was cut. Amounts are decimal strings, can be ' + + 'negative and are never summed across currencies.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'analytics', action: 'view' }, + inputSchema: FinancialSummaryInputSchema, + outputSchema: FinancialSummaryOutputSchema, + redact: { allow: ['dateFrom', 'dateTo', 'currencies', 'products', 'truncated'] }, + errors: [], +}); diff --git a/packages/core/src/analytics/plugin.ts b/packages/core/src/analytics/plugin.ts index e99ce9486..49ed9781c 100644 --- a/packages/core/src/analytics/plugin.ts +++ b/packages/core/src/analytics/plugin.ts @@ -1,7 +1,7 @@ import { CACHE } from '@openora/core/contracts'; import { ADMIN_GUARD, DRIZZLE } from '@openora/core/server'; import type { CoreTokenCatalog, Plugin, TypedContainer } from '@openora/core/server'; -import { ggrSummaryTool } from './contract/index.js'; +import { financialSummaryTool, ggrSummaryTool, paymentsFlowDailyTool } from './contract/index.js'; import { FinancialAnalyticsService } from './service/financial-analytics.service.js'; import { FunnelAnalyticsService } from './service/funnel-analytics.service.js'; import { createAnalyticsRouter } from './router/index.js'; @@ -18,6 +18,14 @@ export default { const financial = financialAnalytics(c); return (input) => financial.ggrSummary(input); }); + ctx.mcp.tool(financialSummaryTool, (c) => { + const financial = financialAnalytics(c); + return (input) => financial.financialSummary(input); + }); + ctx.mcp.tool(paymentsFlowDailyTool, (c) => { + const financial = financialAnalytics(c); + return (input) => financial.paymentsFlow(input); + }); ctx.routers.add('analytics', (c) => createAnalyticsRouter( diff --git a/packages/core/src/analytics/service/financial-analytics.service.ts b/packages/core/src/analytics/service/financial-analytics.service.ts index 66eb06b1c..c97fcd7f5 100644 --- a/packages/core/src/analytics/service/financial-analytics.service.ts +++ b/packages/core/src/analytics/service/financial-analytics.service.ts @@ -1,12 +1,46 @@ -import { and, eq, gte, inArray, isNotNull, lte, ne, sql, type SQL } from 'drizzle-orm'; +import { + and, + asc, + count, + eq, + gte, + inArray, + isNotNull, + lt, + lte, + ne, + sql, + type SQL, +} from 'drizzle-orm'; import { alias, type PgColumn } from 'drizzle-orm/pg-core'; -import type { CacheAdapter, GameType, Granularity } from '@openora/core/contracts'; +import type { + CacheAdapter, + GameType, + Granularity, + WalletTransactionStatus, +} from '@openora/core/contracts'; import { DrizzleService, moneyAdd, moneySubtract } from '@openora/core/server'; import { game, gameRound } from '@openora/core/casino/schema/gaming'; import { sportsbookBet, sportsbookBetTransaction } from '@openora/core/sportsbook/schema/betting'; import { walletTransaction } from '@openora/core/wallet/schema'; import { + FINANCIAL_SUMMARY_MAX_CURRENCIES, + FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS, + PAYMENTS_FLOW_MAX_ROWS, + PAYMENT_FLOW_TYPES, + resolveFinancialSummaryRange, resolveGgrSummaryRange, + resolvePaymentsFlowRange, + type FinancialCurrencyTotals, + type FinancialSummaryInput, + type FinancialSummaryOverview, + type MoneyByCurrency, + type MoneyByCurrencyRail, + type PaymentFlowGranularity, + type PaymentFlowRow, + type PaymentFlowType, + type PaymentsFlow, + type PaymentsFlowInput, type ProductRevenue, type FinancialGgrQuery, type FinancialSummary, @@ -68,6 +102,57 @@ function inRange( const total = (column: PgColumn | SQL) => sql`coalesce(sum(${column}), 0)`; +const DAY_MS = 24 * 60 * 60 * 1000; + +const utcCreatedAt = sql`(${walletTransaction.createdAt} at time zone 'UTC')`; +const FLOW_BUCKETS: Record> = { + day: sql`to_char(${utcCreatedAt}, 'YYYY-MM-DD')`, + week: sql`to_char(date_trunc('week', ${utcCreatedAt}), 'YYYY-MM-DD')`, +}; + +type PaymentsFlowQuery = { + dateFrom: string; + dateTo: string; + granularity: PaymentFlowGranularity; + types: readonly PaymentFlowType[]; + statuses: readonly WalletTransactionStatus[] | null; + currency: string | null; +}; + +const dayAfter = (isoDay: string) => new Date(Date.parse(isoDay) + DAY_MS); + +function sumByCurrency(rows: readonly MoneyByCurrencyRail[]): Map { + const sums = new Map(); + for (const row of rows) { + sums.set(row.currency, moneyAdd(sums.get(row.currency) ?? '0', row.total)); + } + return sums; +} + +function byCurrency(rows: readonly MoneyByCurrency[]): Map { + return new Map(rows.map((row) => [row.currency, row.total])); +} + +/** One row per currency of a financial summary, rails folded together. */ +export function totalsPerCurrency(summary: FinancialSummary): FinancialCurrencyTotals[] { + const deposits = sumByCurrency(summary.deposits); + const withdrawals = sumByCurrency(summary.withdrawals); + const netDeposits = byCurrency(summary.netDeposits); + const ggr = byCurrency(summary.ggr); + const bonusCost = byCurrency(summary.bonusCost); + const ngr = byCurrency(summary.ngr); + const currencies = new Set([...deposits.keys(), ...withdrawals.keys(), ...ggr.keys()]); + return [...currencies].sort().map((currency) => ({ + currency, + deposits: deposits.get(currency) ?? '0', + withdrawals: withdrawals.get(currency) ?? '0', + netDeposits: netDeposits.get(currency) ?? '0', + ggr: ggr.get(currency) ?? '0', + bonusCost: bonusCost.get(currency) ?? '0', + ngr: ngr.get(currency) ?? '0', + })); +} + export class FinancialAnalyticsService { private sportsbookLedgerFound = false; @@ -99,6 +184,53 @@ export class FinancialAnalyticsService { return { ...range, granularity, series }; } + async financialSummary( + { currency, ...dates }: FinancialSummaryInput, + now = new Date(), + ): Promise { + const range = resolveFinancialSummaryRange(dates, now); + const query = { + dateFrom: `${range.dateFrom}T00:00:00.000Z`, + dateTo: `${range.dateTo}T23:59:59.999Z`, + currency: currency?.toUpperCase(), + }; + const [summary, products] = await Promise.all([this.summary(query), this.products(query)]); + const currencies = totalsPerCurrency(summary); + return { + ...range, + currencies: currencies.slice(0, FINANCIAL_SUMMARY_MAX_CURRENCIES), + products: products.slice(0, FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS), + truncated: + currencies.length > FINANCIAL_SUMMARY_MAX_CURRENCIES || + products.length > FINANCIAL_SUMMARY_MAX_PRODUCT_ROWS, + }; + } + + /** + * Deposits and withdrawals per bucket, type, status and currency, over whole UTC days. A row + * holds the transactions created in its bucket under the status they hold now. + */ + async paymentsFlow( + { dateFrom, dateTo, types, statuses, currency, granularity }: PaymentsFlowInput, + now = new Date(), + ): Promise { + const range = resolvePaymentsFlowRange({ dateFrom, dateTo }, now); + const query: PaymentsFlowQuery = { + ...range, + granularity, + types: types ?? PAYMENT_FLOW_TYPES, + statuses: statuses ?? null, + currency: currency?.toUpperCase() ?? null, + }; + const rows = await this.cached('payments.flow', query, () => this.computePaymentsFlow(query)); + return { + ...range, + granularity, + rows: rows.slice(0, PAYMENTS_FLOW_MAX_ROWS), + truncated: rows.length > PAYMENTS_FLOW_MAX_ROWS, + }; + } + private async cached(scope: string, query: object, compute: () => Promise): Promise { const key = `analytics:${scope}:${JSON.stringify(query, Object.keys(query).sort())}`; const cached = await this.cache.get(key); @@ -277,6 +409,45 @@ export class FinancialAnalyticsService { ); } + private async computePaymentsFlow({ + dateFrom, + dateTo, + granularity, + types, + statuses, + currency, + }: PaymentsFlowQuery): Promise { + const bucket = FLOW_BUCKETS[granularity]; + const type = sql`${walletTransaction.type}`; + return this.drizzle.db + .select({ + bucket, + type, + status: walletTransaction.status, + currency: walletTransaction.currency, + count: count(), + amount: sql`sum(${walletTransaction.amount})`, + }) + .from(walletTransaction) + .where( + and( + inArray(walletTransaction.type, [...types]), + statuses ? inArray(walletTransaction.status, [...statuses]) : undefined, + currency ? eq(walletTransaction.currency, currency) : undefined, + gte(walletTransaction.createdAt, new Date(dateFrom)), + lt(walletTransaction.createdAt, dayAfter(dateTo)), + ), + ) + .groupBy(bucket, walletTransaction.type, walletTransaction.status, walletTransaction.currency) + .orderBy( + asc(bucket), + asc(walletTransaction.type), + asc(walletTransaction.status), + asc(walletTransaction.currency), + ) + .limit(PAYMENTS_FLOW_MAX_ROWS + 1); + } + private async computeGgr({ dateFrom, dateTo, From c49a42331d977f350bb2ed44fb4f75a99acf6c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 5 Nov 2026 12:00:37 +0100 Subject: [PATCH 3/5] feat(wallet): withdrawal queue and segment deposit comparison agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-level read tools for the back-office assistant: - `withdrawals.queue` (`withdrawal:view`) gives the queue's pending and on-hold counts, totals per currency and average pending wait, plus the oldest 1 to 20 queued withdrawals with their withdrawal id, player id, amount, status, wait, KYC status and the queue's risk tags. It reads no review note, address, hash, username or email. The risk tags the admin queue shows move into one helper both use. - `players.deposit_compare` (`transaction:view`) compares the completed deposits of a tagged segment between two windows of at most 92 days, each player priced into the fx pivot currency, and ranks the largest drops or rises (at most 50). It pages the segment through `PLAYER_SEGMENT_READER` and refuses a segment above 20 000 players as `segment_too_large`. Each page costs one query for its user ids and one array-parameter query for its deposits; a quote is fetched once per currency pair. A player with an unpriceable deposit is counted, never guessed. `IdentityReader` gains an optional batched `getUserIdsByPlayerIds` (with `userIdsOfPlayers`, which fails closed when it is missing), and the pivot pricing the wallet reader used moves into a module helper the comparison shares. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../src/contracts/adapters/identity-reader.ts | 19 + packages/core/src/contracts/adapters/index.ts | 2 +- .../identity-reader.service.int.test.ts | 30 ++ .../adapters/identity-reader.service.ts | 15 +- .../wallet/__tests__/deposit-compare.test.ts | 85 +++ .../wallet/__tests__/pivot-pricing.test.ts | 47 ++ .../wallet-agent-registration.test.ts | 8 +- .../wallet-operator-tools.contract.test.ts | 52 ++ .../wallet-operator-tools.int.test.ts | 487 ++++++++++++++++++ .../wallet/adapters/wallet-reader.service.ts | 18 +- .../core/src/wallet/contract/agent-tools.ts | 173 +++++++ packages/core/src/wallet/plugin.ts | 43 ++ .../wallet/service/deposit-compare.service.ts | 232 +++++++++ .../wallet/service/pivot-pricing.service.ts | 44 ++ .../core/src/wallet/service/wallet.service.ts | 154 ++++-- 15 files changed, 1362 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/wallet/__tests__/deposit-compare.test.ts create mode 100644 packages/core/src/wallet/__tests__/pivot-pricing.test.ts create mode 100644 packages/core/src/wallet/__tests__/wallet-operator-tools.contract.test.ts create mode 100644 packages/core/src/wallet/__tests__/wallet-operator-tools.int.test.ts create mode 100644 packages/core/src/wallet/service/deposit-compare.service.ts create mode 100644 packages/core/src/wallet/service/pivot-pricing.service.ts diff --git a/packages/core/src/contracts/adapters/identity-reader.ts b/packages/core/src/contracts/adapters/identity-reader.ts index 9f8c45b99..4776b7fda 100644 --- a/packages/core/src/contracts/adapters/identity-reader.ts +++ b/packages/core/src/contracts/adapters/identity-reader.ts @@ -36,6 +36,14 @@ export type IdentityReader = { * needs it must fail closed when it is missing rather than treat that as "no such player". */ getUserIdByPlayerId?(playerId: Player['id']): Promise; + /** + * {@link getUserIdByPlayerId} for many players in one query, keyed by the player id given; a + * player id that names no player is absent. Optional for the same reason, and a caller that + * needs it fails closed the same way. + */ + getUserIdsByPlayerIds?( + playerIds: readonly Player['id'][], + ): Promise>; }; export const IDENTITY_READER = createToken('IDENTITY_READER'); @@ -53,3 +61,14 @@ export async function userIdOfPlayer( } return reader.getUserIdByPlayerId(playerId); } + +/** `getUserIdsByPlayerIds` for a caller that cannot work without it; throws like {@link userIdOfPlayer}. */ +export async function userIdsOfPlayers( + reader: IdentityReader, + playerIds: readonly Player['id'][], +): Promise> { + if (!reader.getUserIdsByPlayerIds) { + throw new Error('the bound IDENTITY_READER cannot resolve player ids to user ids'); + } + return reader.getUserIdsByPlayerIds(playerIds); +} diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 6f27567d6..35eac850f 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -145,7 +145,7 @@ export { TAG_EVALUATION_COMMANDS } from './tag-evaluation-commands.js'; export type { UserCommands } from './identity.js'; export type { IdentityReader } from './identity-reader.js'; -export { userIdOfPlayer } from './identity-reader.js'; +export { userIdOfPlayer, userIdsOfPlayers } from './identity-reader.js'; export { IDENTITY_READER } from './identity-reader.js'; export type { diff --git a/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts b/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts index ede8c333c..83789c423 100644 --- a/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts +++ b/packages/core/src/pam/identity/__tests__/identity-reader.service.int.test.ts @@ -84,3 +84,33 @@ describe('IdentityReaderService.getUserIdByPlayerId (real PG)', () => { expect(await reader.getUserIdByPlayerId(randomUUID())).toBeNull(); }); }); + +describe('IdentityReaderService.getUserIdsByPlayerIds (real PG)', () => { + it('resolves many player ids in one call and leaves an unknown one out', async () => { + const reader = new IdentityReaderService(db.drizzle); + const rows = await db.drizzle.db + .insert(player) + .values([{ userId: randomUUID() }, { userId: randomUUID() }, { userId: randomUUID() }]) + .returning(); + const [first, second] = rows; + if (!first || !second) { + throw new Error('player insert returned too few rows'); + } + const unknown = randomUUID(); + + const resolved = await reader.getUserIdsByPlayerIds([first.id, second.id, unknown]); + + expect(resolved).toEqual( + new Map([ + [first.id, first.userId], + [second.id, second.userId], + ]), + ); + }); + + it('answers an empty map for no player ids', async () => { + expect(await new IdentityReaderService(db.drizzle).getUserIdsByPlayerIds([])).toEqual( + new Map(), + ); + }); +}); diff --git a/packages/core/src/pam/identity/adapters/identity-reader.service.ts b/packages/core/src/pam/identity/adapters/identity-reader.service.ts index b10ac2282..769524cc1 100644 --- a/packages/core/src/pam/identity/adapters/identity-reader.service.ts +++ b/packages/core/src/pam/identity/adapters/identity-reader.service.ts @@ -1,6 +1,6 @@ import { createLogger, DrizzleService, type DrizzleTx } from '@openora/core/server'; import type { IdentityReader, KycStatus, Player, User } from '@openora/core/contracts'; -import { and, eq, exists, inArray, isNull, lt, max, ne, or, SQL } from 'drizzle-orm'; +import { and, eq, exists, inArray, isNull, lt, max, ne, or, sql, SQL } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { session, user } from '../schema/index.js'; import { player } from '@openora/core/pam/schema/profile'; @@ -65,6 +65,19 @@ export class IdentityReaderService implements IdentityReader { return row?.userId ?? null; } + async getUserIdsByPlayerIds( + playerIds: readonly Player['id'][], + ): Promise> { + if (playerIds.length === 0) { + return new Map(); + } + const rows = await this.drizzle.db + .select({ id: player.id, userId: player.userId }) + .from(player) + .where(sql`${player.id} = ANY(${sql.param([...playerIds])}::uuid[])`); + return new Map(rows.map((row) => [row.id, row.userId])); + } + async getPlayerIdByUserIdSafe(userId: User['id']): Promise { try { return await this.getPlayerIdByUserId(userId); diff --git a/packages/core/src/wallet/__tests__/deposit-compare.test.ts b/packages/core/src/wallet/__tests__/deposit-compare.test.ts new file mode 100644 index 000000000..f565bc506 --- /dev/null +++ b/packages/core/src/wallet/__tests__/deposit-compare.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { changePercent, rankDepositChanges } from '../service/deposit-compare.service.js'; + +const DROPPED_MOST = '00000000-0000-4000-8000-000000000001'; +const DROPPED_LESS = '00000000-0000-4000-8000-000000000002'; +const DROPPED_TIED = '00000000-0000-4000-8000-000000000003'; +const ROSE = '00000000-0000-4000-8000-000000000004'; +const ROSE_MOST = '00000000-0000-4000-8000-000000000005'; +const UNCHANGED = '00000000-0000-4000-8000-000000000006'; +const NEW_DEPOSITOR = '00000000-0000-4000-8000-000000000007'; + +const players = [ + { playerId: ROSE, depositsA: '100', depositsB: '150', change: '50' }, + { playerId: DROPPED_LESS, depositsA: '100', depositsB: '90', change: '-10' }, + { playerId: UNCHANGED, depositsA: '40', depositsB: '40', change: '0' }, + { playerId: DROPPED_TIED, depositsA: '300', depositsB: '290', change: '-10' }, + { playerId: DROPPED_MOST, depositsA: '800', depositsB: '200', change: '-600' }, + { playerId: NEW_DEPOSITOR, depositsA: '0', depositsB: '75', change: '75' }, + { playerId: ROSE_MOST, depositsA: '1', depositsB: '1000.5', change: '999.5' }, +]; + +describe('rankDepositChanges', () => { + it('lists only the players whose deposits fell, largest drop first and ties by player id', () => { + expect(rankDepositChanges(players, 'largest_drop', 10)).toEqual({ + players: [ + { + playerId: DROPPED_MOST, + depositsA: '800', + depositsB: '200', + change: '-600', + changePct: -75, + }, + { + playerId: DROPPED_LESS, + depositsA: '100', + depositsB: '90', + change: '-10', + changePct: -10, + }, + { + playerId: DROPPED_TIED, + depositsA: '300', + depositsB: '290', + change: '-10', + changePct: -3.3, + }, + ], + truncated: false, + }); + }); + + it('lists only the players whose deposits rose, largest rise first', () => { + const ranked = rankDepositChanges(players, 'largest_rise', 10); + + expect(ranked.players.map((player) => [player.playerId, player.changePct])).toEqual([ + [ROSE_MOST, 99950], + [NEW_DEPOSITOR, null], + [ROSE, 50], + ]); + }); + + it('keeps the first limit players and says more qualified', () => { + const ranked = rankDepositChanges(players, 'largest_drop', 2); + + expect(ranked.players.map((player) => player.playerId)).toEqual([DROPPED_MOST, DROPPED_LESS]); + expect(ranked.truncated).toBe(true); + }); +}); + +describe('changePercent', () => { + it('rounds the change relative to the first window to one decimal', () => { + expect(changePercent({ playerId: ROSE, depositsA: '3', depositsB: '4', change: '1' })).toBe( + 33.3, + ); + expect(changePercent({ playerId: ROSE, depositsA: '3', depositsB: '1', change: '-2' })).toBe( + -66.7, + ); + }); + + it('has no percentage when the first window holds nothing', () => { + expect( + changePercent({ playerId: ROSE, depositsA: '0', depositsB: '5', change: '5' }), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/wallet/__tests__/pivot-pricing.test.ts b/packages/core/src/wallet/__tests__/pivot-pricing.test.ts new file mode 100644 index 000000000..f6b4bbb29 --- /dev/null +++ b/packages/core/src/wallet/__tests__/pivot-pricing.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { makeExchangeRateReader } from '../../testing/mock.js'; +import { pricedInPivot, withQuoteCache } from '../service/pivot-pricing.service.js'; + +describe('pricedInPivot', () => { + it('sums every currency in the pivot and needs no rate for a zero total', async () => { + const rates = makeExchangeRateReader('2'); + + const total = await pricedInPivot( + [ + { currency: 'usd', total: '10' }, + { currency: 'EUR', total: '5' }, + { currency: 'BTC', total: '0' }, + { currency: 'ETH', total: null }, + ], + 'USD', + rates, + ); + + expect(total).toBe('20.000000000000000000'); + expect(rates.convert).toHaveBeenCalledTimes(1); + }); + + it('is unknown when a non-zero currency cannot be priced', async () => { + expect(await pricedInPivot([{ currency: 'EUR', total: '5' }], 'USD', undefined)).toBeNull(); + }); +}); + +describe('withQuoteCache', () => { + it('asks the reader for each currency pair once and converts at that quote', async () => { + const rates = makeExchangeRateReader('3'); + const cached = withQuoteCache(rates); + + const converted = await Promise.all([ + cached.convert('2', 'EUR', 'USD'), + cached.convert('4', 'eur', 'usd'), + cached.convert('1', 'BTC', 'USD'), + ]); + + expect(converted).toEqual([ + '6.000000000000000000', + '12.000000000000000000', + '3.000000000000000000', + ]); + expect(rates.getRate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts b/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts index f1677ff35..5704ef7cb 100644 --- a/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-agent-registration.test.ts @@ -3,13 +3,17 @@ import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/c import walletPlugin from '../plugin.js'; describe('wallet agent surface registration', () => { - it('registers wallet.activity and hold_withdrawal within the kernel contract rules', async () => { + it('registers its read tools and hold_withdrawal within the kernel contract rules', async () => { const registry = new ModuleRegistryImpl(new Container()); registry.setOwner('wallet'); await walletPlugin.register(registry); - expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['wallet.activity']); + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([ + 'wallet.activity', + 'withdrawals.queue', + 'players.deposit_compare', + ]); expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([ 'hold_withdrawal', ]); diff --git a/packages/core/src/wallet/__tests__/wallet-operator-tools.contract.test.ts b/packages/core/src/wallet/__tests__/wallet-operator-tools.contract.test.ts new file mode 100644 index 000000000..30f8c3718 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-operator-tools.contract.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { DepositCompareInputSchema, WithdrawalsQueueInputSchema } from '../contract/index.js'; + +const WINDOW_A = { from: '2026-01-01', to: '2026-03-31' }; +const WINDOW_B = { from: '2026-04-01', to: '2026-07-01' }; + +describe('withdrawals.queue input', () => { + const parse = (input: unknown) => z.safeParse(WithdrawalsQueueInputSchema, input); + + it('defaults to the 10 oldest of both queued statuses', () => { + expect(parse({})).toEqual({ success: true, data: { limit: 10 } }); + }); + + it('takes a limit from 1 to 20 and only a queued status', () => { + expect(parse({ limit: '20', status: 'on_hold' }).success).toBe(true); + expect(parse({ limit: 0 }).success).toBe(false); + expect(parse({ limit: 21 }).success).toBe(false); + expect(parse({ status: 'completed' }).success).toBe(false); + }); +}); + +describe('players.deposit_compare input', () => { + const parse = (input: unknown) => z.safeParse(DepositCompareInputSchema, input); + + it('defaults to the 20 largest drops', () => { + expect(parse({ tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B })).toEqual({ + success: true, + data: { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B, order: 'largest_drop', limit: 20 }, + }); + }); + + it('accepts a window of exactly 92 days and refuses one day more, naming the window', () => { + const tooLong = { from: '2026-04-01', to: '2026-07-02' }; + + expect(parse({ tag: 'vip', windowA: WINDOW_A, windowB: tooLong }).error?.issues).toEqual([ + expect.objectContaining({ path: ['windowB', 'from'] }), + ]); + }); + + it('refuses a window that ends before it starts, a tag that is not a tag key and a limit above 50', () => { + const inverted = { from: '2026-03-31', to: '2026-01-01' }; + + expect(parse({ tag: 'vip', windowA: inverted, windowB: WINDOW_B }).error?.issues).toEqual([ + expect.objectContaining({ path: ['windowA', 'from'] }), + ]); + expect(parse({ tag: 'whales', windowA: WINDOW_A, windowB: WINDOW_B }).success).toBe(false); + expect(parse({ tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B, limit: 51 }).success).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-operator-tools.int.test.ts b/packages/core/src/wallet/__tests__/wallet-operator-tools.int.test.ts new file mode 100644 index 000000000..f47f01c46 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-operator-tools.int.test.ts @@ -0,0 +1,487 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + EVENT_BUS, + ModuleRegistryImpl, + findOneOrThrow, + moneyScaleBy, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + ADMIN_USER_DIRECTORY, + AUDIT_WRITER, + EXCHANGE_RATE_READER, + IDENTITY_READER, + PLAYER_SEGMENT_READER, + RATE_LIMITER, + type AdminPlayerSummary, + type AdminUserDirectory, + type ExchangeRateReader, + type IdentityReader, + type KycStatus, + type PlayerSegmentReader, + type RateLimiterAdapter, + type RunContext, + TagKeySchema, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { makeAuditWriter, makeEventBus, makeIdentityReader, mock } from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { wallet, walletAutoWithdrawalConfig, walletTransaction } from '../schema/index.js'; +import walletPlugin from '../plugin.js'; + +let db: TestDb; + +const HOUR_MS = 60 * 60 * 1000; +const REVIEW_NOTE = 'SAR filed: structuring across linked accounts'; +const PAYOUT_ADDRESS = 'bc1qoperatortoolsdestination0000000000000'; +const PAYOUT_HASH = '0xfeedfacecafebeef'; +const WINDOW_A = { from: '2026-01-01', to: '2026-03-31' }; +const WINDOW_B = { from: '2026-04-01', to: '2026-06-29' }; +const SEGMENT_PAGE = 2; +const DIRECTORY_NAME = 'queue-player'; +const DIRECTORY_EMAIL_DOMAIN = 'operator-tools.test'; + +type Seeded = { playerId: string; userId: string; walletId: string }; + +const userIdByPlayer = new Map(); +const directoryEntries = new Map(); +let segment: string[] = []; +let segmentCount: number | null = null; + +function identityReader(): IdentityReader { + return { + ...makeIdentityReader(), + getUserIdsByPlayerIds: vi.fn( + async (playerIds: readonly string[]) => + new Map( + playerIds.flatMap((id) => { + const userId = userIdByPlayer.get(id); + return userId ? [[id, userId] as const] : []; + }), + ), + ), + }; +} + +function segmentReader(): PlayerSegmentReader { + return { + describe: vi.fn(async (tags: readonly string[]) => + tags.map((tag) => ({ + tag: TagKeySchema.parse(tag), + name: tag, + playerCount: segmentCount ?? segment.length, + })), + ), + page: vi.fn(async ({ after }) => { + const ordered = [...segment].sort(); + const rest = after === undefined ? ordered : ordered.filter((id) => id > after); + const playerIds = rest.slice(0, SEGMENT_PAGE); + return { playerIds, next: rest.length > SEGMENT_PAGE ? (playerIds.at(-1) ?? null) : null }; + }), + resolve: vi.fn(async () => new Map()), + }; +} + +const EUR_RATE = '2'; + +const eurOnlyRates = (): ExchangeRateReader => ({ + getRate: vi.fn(async (from: string) => + from.toUpperCase() === 'EUR' ? { rate: EUR_RATE, asOf: new Date().toISOString() } : null, + ), + convert: vi.fn(async (amount: string, from: string) => + from.toUpperCase() === 'EUR' ? moneyScaleBy(amount, EUR_RATE) : null, + ), +}); + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(EVENT_BUS, () => makeEventBus()); + container.register(AUDIT_WRITER, () => makeAuditWriter()); + container.register(IDENTITY_READER, () => identityReader()); + container.register(ADMIN_USER_DIRECTORY, () => + mock({ + lookupPlayers: vi.fn(async (userIds: readonly string[]) => + userIds.flatMap((id) => directoryEntries.get(id) ?? []), + ), + }), + ); + container.register(RATE_LIMITER, () => mock>({})); + container.register(PLAYER_SEGMENT_READER, () => segmentReader()); + container.register(EXCHANGE_RATE_READER, () => eurOnlyRates()); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('wallet'); + walletPlugin.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: 'admin', adminId: randomUUID() }, + catalogVersion: 'test', + correlationId: 'wallet-operator-tools', +}); + +async function seedPlayer({ kycStatus }: { kycStatus?: KycStatus | null } = {}): Promise { + const playerId = randomUUID(); + const record = findOneOrThrow( + await db.drizzle.db + .insert(wallet) + .values({ userId: randomUUID(), currency: 'USD' }) + .returning(), + new Error('seedPlayer: query returned no row'), + ); + userIdByPlayer.set(playerId, record.userId); + if (kycStatus !== undefined) { + directoryEntries.set(record.userId, { + playerId, + userId: record.userId, + username: `${DIRECTORY_NAME}-${playerId}`, + email: `${playerId}@${DIRECTORY_EMAIL_DOMAIN}`, + kycStatus, + language: null, + avatarUrl: null, + createdAt: new Date(), + level: 1, + currency: 'USD', + }); + } + return { playerId, userId: record.userId, walletId: record.id }; +} + +async function seedTx( + walletId: string, + values: Pick & + Partial, +) { + return findOneOrThrow( + await db.drizzle.db + .insert(walletTransaction) + .values({ walletId, currency: 'USD', status: 'completed', ...values }) + .returning(), + new Error('seedTx: query returned no row'), + ); +} + +const hoursAgo = (hours: number) => new Date(Date.now() - hours * HOUR_MS); + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + userIdByPlayer.clear(); + directoryEntries.clear(); + segment = []; + segmentCount = null; + await db.drizzle.db.execute( + sql`TRUNCATE ${walletTransaction}, ${wallet}, ${walletAutoWithdrawalConfig} RESTART IDENTITY CASCADE`, + ); +}); + +describe('withdrawals.queue through the MCP kernel (real PG)', () => { + let frequent: Seeded; + let held: Seeded; + let unknown: Seeded; + let heldId: string; + let frequentId: string; + let unknownId: string; + + beforeEach(async () => { + await db.drizzle.db + .insert(walletAutoWithdrawalConfig) + .values({ fiatThreshold: '100', cryptoThreshold: '1' }); + frequent = await seedPlayer({ kycStatus: 'approved' }); + held = await seedPlayer({ kycStatus: 'verified' }); + unknown = await seedPlayer(); + frequentId = ( + await seedTx(frequent.walletId, { + type: 'withdrawal', + amount: '100', + status: 'pending', + createdAt: hoursAgo(5), + destinationAddress: PAYOUT_ADDRESS, + }) + ).id; + await seedTx(frequent.walletId, { type: 'withdrawal', amount: '40', createdAt: hoursAgo(20) }); + await seedTx(frequent.walletId, { + type: 'withdrawal', + amount: '30', + status: 'processing', + createdAt: hoursAgo(3), + }); + heldId = ( + await seedTx(held.walletId, { + type: 'withdrawal', + amount: '6000', + status: 'on_hold', + createdAt: hoursAgo(10), + reviewReason: REVIEW_NOTE, + destinationAddress: PAYOUT_ADDRESS, + txHash: PAYOUT_HASH, + }) + ).id; + unknownId = ( + await seedTx(unknown.walletId, { + type: 'withdrawal', + amount: '1', + currency: 'BTC', + status: 'pending', + createdAt: hoursAgo(2), + }) + ).id; + }); + + it('counts the queue and lists the longest waiting first, with no review note, address or hash', async () => { + const result = await bootKernel().invokeTool('withdrawals.queue', {}, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + pendingCount: 2, + onHoldCount: 1, + queuedTotals: [ + { currency: 'BTC', amount: '1.000000000000000000' }, + { currency: 'USD', amount: '6100.000000000000000000' }, + ], + avgPendingWaitHours: 3.5, + oldest: [ + { + withdrawalId: heldId, + playerId: held.playerId, + amount: '6000.000000000000000000', + currency: 'USD', + status: 'on_hold', + requestedAt: expect.any(String), + waitHours: 10, + kycStatus: 'approved', + riskTags: ['large_amount'], + }, + { + withdrawalId: frequentId, + playerId: frequent.playerId, + amount: '100.000000000000000000', + currency: 'USD', + status: 'pending', + requestedAt: expect.any(String), + waitHours: 5, + kycStatus: 'approved', + riskTags: ['high_frequency'], + }, + { + withdrawalId: unknownId, + playerId: null, + amount: '1.000000000000000000', + currency: 'BTC', + status: 'pending', + requestedAt: expect.any(String), + waitHours: 2, + kycStatus: null, + riskTags: ['large_amount'], + }, + ], + }, + }); + const served = JSON.stringify(result); + expect(served).not.toContain(REVIEW_NOTE); + expect(served).not.toContain(PAYOUT_ADDRESS); + expect(served).not.toContain(PAYOUT_HASH); + expect(served).not.toContain(frequent.userId); + expect(served).not.toContain(DIRECTORY_NAME); + expect(served).not.toContain(DIRECTORY_EMAIL_DOMAIN); + }); + + it('narrows the oldest to one status and refuses a limit above 20', async () => { + const kernel = bootKernel(); + + const pending = await kernel.invokeTool( + 'withdrawals.queue', + { status: 'pending', limit: '1' }, + runContext(), + ); + const tooMany = await kernel.invokeTool('withdrawals.queue', { limit: 21 }, runContext()); + + expect(pending).toMatchObject({ + ok: true, + output: { pendingCount: 2, onHoldCount: 1, oldest: [{ withdrawalId: frequentId }] }, + }); + expect(pending.ok && pending.output['oldest']).toHaveLength(1); + expect(tooMany).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); + +describe('players.deposit_compare through the MCP kernel (real PG)', () => { + let steady: Seeded; + let euro: Seeded; + let released: Seeded; + + beforeEach(async () => { + steady = await seedPlayer(); + euro = await seedPlayer(); + const riser = await seedPlayer(); + released = await seedPlayer(); + const unpriced = await seedPlayer(); + const idle = await seedPlayer(); + const outside = await seedPlayer(); + const notTagged = await seedPlayer(); + segment = [steady, euro, riser, released, unpriced, idle, outside].map((p) => p.playerId); + + await seedTx(steady.walletId, { + type: 'deposit', + amount: '100', + createdAt: new Date('2026-01-15T10:00:00Z'), + }); + await seedTx(steady.walletId, { + type: 'deposit', + amount: '40', + createdAt: new Date('2026-04-10T10:00:00Z'), + }); + await seedTx(steady.walletId, { + type: 'deposit', + amount: '500', + status: 'pending', + createdAt: new Date('2026-04-11T10:00:00Z'), + }); + await seedTx(euro.walletId, { + type: 'deposit', + amount: '100', + currency: 'EUR', + createdAt: new Date('2026-03-31T23:59:59Z'), + }); + await seedTx(euro.walletId, { + type: 'deposit', + amount: '25', + currency: 'EUR', + createdAt: new Date('2026-06-29T23:00:00Z'), + }); + await seedTx(riser.walletId, { + type: 'deposit', + amount: '10', + createdAt: new Date('2026-02-01T10:00:00Z'), + }); + await seedTx(riser.walletId, { + type: 'deposit', + amount: '30', + createdAt: new Date('2026-05-01T10:00:00Z'), + }); + await seedTx(released.walletId, { + type: 'deposit', + amount: '70', + createdAt: new Date('2026-03-30T10:00:00Z'), + reviewedAt: new Date('2026-04-02T10:00:00Z'), + }); + await seedTx(unpriced.walletId, { + type: 'deposit', + amount: '1', + currency: 'BTC', + createdAt: new Date('2026-01-20T10:00:00Z'), + }); + await seedTx(unpriced.walletId, { + type: 'deposit', + amount: '5', + createdAt: new Date('2026-04-20T10:00:00Z'), + }); + await seedTx(outside.walletId, { + type: 'deposit', + amount: '900', + createdAt: new Date('2026-06-30T00:00:00Z'), + }); + await seedTx(notTagged.walletId, { + type: 'deposit', + amount: '5000', + createdAt: new Date('2026-01-10T10:00:00Z'), + }); + }); + + it('ranks the largest drops in the pivot currency and totals every priced player', async () => { + const result = await bootKernel().invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B }, + runContext(), + ); + + expect(result).toEqual({ + ok: true, + output: { + tag: 'vip', + playersInSegment: 7, + pivotCurrency: 'USD', + totals: { windowA: '310.000000000000000000', windowB: '190.000000000000000000' }, + players: [ + { + playerId: euro.playerId, + depositsA: '200.000000000000000000', + depositsB: '50.000000000000000000', + change: '-150.000000000000000000', + changePct: -75, + }, + { + playerId: steady.playerId, + depositsA: '100.000000000000000000', + depositsB: '40.000000000000000000', + change: '-60.000000000000000000', + changePct: -60, + }, + ], + unpricedPlayers: 1, + truncated: false, + }, + }); + }); + + it('ranks the largest rises, a deposit released from a hold counting on its release day', async () => { + const result = await bootKernel().invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B, order: 'largest_rise', limit: '1' }, + runContext(), + ); + + expect(result).toMatchObject({ + ok: true, + output: { + players: [ + { + playerId: released.playerId, + depositsA: '0', + depositsB: '70.000000000000000000', + changePct: null, + }, + ], + truncated: true, + }, + }); + }); + + it('refuses a segment larger than it reads, and a window longer than 92 days', async () => { + segmentCount = 20_001; + const kernel = bootKernel(); + + const tooLarge = await kernel.invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B }, + runContext(), + ); + const tooLong = await kernel.invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: { from: '2026-04-01', to: '2026-07-02' } }, + runContext(), + ); + + expect(tooLarge).toEqual({ ok: false, error: 'segment_too_large' }); + expect(tooLong).toMatchObject({ ok: false, error: 'invalid_input' }); + }); +}); diff --git a/packages/core/src/wallet/adapters/wallet-reader.service.ts b/packages/core/src/wallet/adapters/wallet-reader.service.ts index 4a0558acd..8c7aeb404 100644 --- a/packages/core/src/wallet/adapters/wallet-reader.service.ts +++ b/packages/core/src/wallet/adapters/wallet-reader.service.ts @@ -1,4 +1,4 @@ -import { type DrizzleDb, DrizzleService, moneyCompare, sumInPivot } from '@openora/core/server'; +import { type DrizzleDb, DrizzleService, sumInPivot } from '@openora/core/server'; import { type WalletReader, type WalletBalancesReading, @@ -27,15 +27,12 @@ import { wallet, walletTransaction } from '../schema/index.js'; import { IN_FLIGHT_TRANSACTION_STATUSES, VELOCITY_IGNORED_STATUSES, + depositedAt, providerRefCondition, readWalletBalances, resolveWalletBalance, } from '../service/wallet.service.js'; - -// A held deposit reaches the balance when an operator releases it, which stamps reviewedAt; -// every other completed deposit has no reviewedAt. -const depositedAt = () => - sql`coalesce(${walletTransaction.reviewedAt}, ${walletTransaction.createdAt})`; +import { pricedInPivot } from '../service/pivot-pricing.service.js'; type CurrencyTotal = { currency: string; total: string | null }; @@ -353,14 +350,7 @@ export class WalletReaderService implements WalletReader { }; } - // A currency whose amounts cancel out needs no rate, so it cannot make the total unknown. private priced(rows: readonly CurrencyTotal[]): Promise { - return sumInPivot( - rows - .map((row) => ({ currency: row.currency, total: row.total ?? '0' })) - .filter((row) => moneyCompare(row.total, '0') !== 0), - this.pivotCurrency, - this.exchangeRateReader, - ); + return pricedInPivot(rows, this.pivotCurrency, this.exchangeRateReader); } } diff --git a/packages/core/src/wallet/contract/agent-tools.ts b/packages/core/src/wallet/contract/agent-tools.ts index 3a2d7a9cc..67c91c72d 100644 --- a/packages/core/src/wallet/contract/agent-tools.ts +++ b/packages/core/src/wallet/contract/agent-tools.ts @@ -1,17 +1,34 @@ import * as z from 'zod'; import { CurrencyTickerSchema, + KycStatusSchema, MoneyAmountSchema, NonEmptyReasonSchema, + SignedMoneyAmountSchema, + TagKeySchema, TimestampSchema, UuidSchema, WalletTransactionStatusSchema, defineActionType, defineMcpTool, + type WalletTransactionStatus, } from '@openora/core/contracts'; export const OPEN_WITHDRAWALS_LIMIT = 20; +// A withdrawal still waiting on a decision: the queue summary counts and totals only these, and +// an admin may approve or reject only these. +export const QUEUED_WITHDRAWAL_STATUSES = [ + 'pending', + 'on_hold', +] as const satisfies readonly WalletTransactionStatus[]; +export const QueuedWithdrawalStatusSchema = z.enum(QUEUED_WITHDRAWAL_STATUSES); +export type QueuedWithdrawalStatus = z.infer; + +export const WITHDRAWAL_RISK_TAGS = ['large_amount', 'high_frequency'] as const; +export const WithdrawalRiskTagSchema = z.enum(WITHDRAWAL_RISK_TAGS); +export type WithdrawalRiskTag = z.infer; + export const WalletActivityInputSchema = z.object({ playerId: UuidSchema, windowDays: z.coerce.number().int().min(1).max(365).default(30), @@ -68,6 +85,162 @@ export const walletActivityTool = defineMcpTool({ errors: ['player_not_found'], }); +export const WITHDRAWAL_QUEUE_MAX_ITEMS = 20; +export const WITHDRAWAL_QUEUE_MAX_CURRENCIES = 100; + +export const WithdrawalsQueueInputSchema = z.object({ + status: QueuedWithdrawalStatusSchema.optional(), + limit: z.coerce.number().int().min(1).max(WITHDRAWAL_QUEUE_MAX_ITEMS).default(10), +}); +export type WithdrawalsQueueInput = z.infer; + +export const WithdrawalsQueueOutputSchema = z.object({ + pendingCount: z.number().int().nonnegative(), + onHoldCount: z.number().int().nonnegative(), + queuedTotals: z + .array(z.object({ currency: CurrencyTickerSchema, amount: MoneyAmountSchema })) + .max(WITHDRAWAL_QUEUE_MAX_CURRENCIES), + avgPendingWaitHours: z.number().nonnegative().nullable(), + oldest: z + .array( + z.object({ + withdrawalId: UuidSchema, + playerId: UuidSchema.nullable(), + amount: MoneyAmountSchema, + currency: CurrencyTickerSchema, + status: WalletTransactionStatusSchema, + requestedAt: TimestampSchema, + waitHours: z.number().nonnegative(), + kycStatus: KycStatusSchema.nullable(), + riskTags: z.array(WithdrawalRiskTagSchema).max(WITHDRAWAL_RISK_TAGS.length), + }), + ) + .max(WITHDRAWAL_QUEUE_MAX_ITEMS), +}); +export type WithdrawalsQueue = z.infer; + +export const withdrawalsQueueTool = defineMcpTool({ + id: 'withdrawals.queue', + title: 'Withdrawal queue', + description: + 'The withdrawal queue across all players: how many withdrawals wait for a decision, pending ' + + 'or on_hold (held for manual review, AML holds included), their totals per currency (at most ' + + '100), the average hours the pending ones have waited, and the ones waiting longest, oldest ' + + 'first (limit 1 to 20, default 10; status narrows them to pending or on_hold). Each carries ' + + 'its withdrawalId and playerId, amount, currency, status, request time, hours waited, the ' + + "player's KYC status and risk tags: large_amount (5000 or more in the fx pivot currency, or " + + 'no rate to price it) and high_frequency (as many recent withdrawal requests as the ' + + 'auto-approval velocity check counts as frequent). Use wallet.activity or player.summary for ' + + "one player's detail. Amounts are decimal strings and never summed across currencies.", + class: 'read', + schemaVersion: 1, + iam: { resource: 'withdrawal', action: 'view' }, + inputSchema: WithdrawalsQueueInputSchema, + outputSchema: WithdrawalsQueueOutputSchema, + redact: { + allow: ['pendingCount', 'onHoldCount', 'queuedTotals', 'avgPendingWaitHours', 'oldest'], + }, + errors: [], +}); + +export const DEPOSIT_COMPARE_MAX_WINDOW_DAYS = 92; +export const DEPOSIT_COMPARE_MAX_PLAYERS = 50; +export const DEPOSIT_COMPARE_MAX_SEGMENT = 20_000; + +export const DEPOSIT_COMPARE_ORDERS = ['largest_drop', 'largest_rise'] as const; +export const DepositCompareOrderSchema = z.enum(DEPOSIT_COMPARE_ORDERS); +export type DepositCompareOrder = z.infer; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const DepositWindowSchema = z.object({ from: z.iso.date(), to: z.iso.date() }); +export type DepositWindow = z.infer; + +function checkDepositWindow( + { from, to }: DepositWindow, + key: 'windowA' | 'windowB', + ctx: z.core.$RefinementCtx, +) { + if (from > to) { + ctx.addIssue({ code: 'custom', path: [key, 'from'], message: 'from must be on or before to' }); + return; + } + if ((Date.parse(to) - Date.parse(from)) / DAY_MS + 1 > DEPOSIT_COMPARE_MAX_WINDOW_DAYS) { + ctx.addIssue({ + code: 'custom', + path: [key, 'from'], + message: `a window spans at most ${DEPOSIT_COMPARE_MAX_WINDOW_DAYS} days`, + }); + } +} + +export const DepositCompareInputSchema = z + .object({ + tag: TagKeySchema, + windowA: DepositWindowSchema, + windowB: DepositWindowSchema, + order: DepositCompareOrderSchema.default('largest_drop'), + limit: z.coerce.number().int().min(1).max(DEPOSIT_COMPARE_MAX_PLAYERS).default(20), + }) + .superRefine((input, ctx) => { + checkDepositWindow(input.windowA, 'windowA', ctx); + checkDepositWindow(input.windowB, 'windowB', ctx); + }); +export type DepositCompareInput = z.infer; + +export const PlayerDepositChangeSchema = z.object({ + playerId: UuidSchema, + depositsA: MoneyAmountSchema, + depositsB: MoneyAmountSchema, + change: SignedMoneyAmountSchema, + changePct: z.number().nullable(), +}); +export type PlayerDepositChange = z.infer; + +export const DepositCompareOutputSchema = z.object({ + tag: TagKeySchema, + playersInSegment: z.number().int().nonnegative(), + pivotCurrency: CurrencyTickerSchema, + totals: z.object({ windowA: MoneyAmountSchema, windowB: MoneyAmountSchema }), + players: z.array(PlayerDepositChangeSchema).max(DEPOSIT_COMPARE_MAX_PLAYERS), + unpricedPlayers: z.number().int().nonnegative(), + truncated: z.boolean(), +}); +export type DepositCompare = z.infer; + +export const playersDepositCompareTool = defineMcpTool({ + id: 'players.deposit_compare', + title: 'Segment deposit comparison', + description: + 'Compares the completed deposits of the players holding a tag (eg vip) between two windows ' + + 'of whole UTC days, windowA the baseline and windowB the comparison, each at most 92 days. A ' + + 'deposit counts on the day it reached the balance, and each player is priced into the fx ' + + 'pivot currency at the current rates. players lists at most 50 (limit, default 20) whose ' + + 'deposits fell (order largest_drop, the default) or rose (largest_rise), largest change ' + + 'first: change is depositsB minus depositsA, changePct is that as a percentage of depositsA ' + + '(null when it is 0); truncated is true when more players qualified. totals sums both ' + + 'windows over every priced player of the segment. A player with a deposit in a currency ' + + 'that has no rate is left out of both and counted in unpricedPlayers. A segment of more ' + + 'than 20000 players is refused as segment_too_large. Amounts are decimal strings.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'transaction', action: 'view' }, + inputSchema: DepositCompareInputSchema, + outputSchema: DepositCompareOutputSchema, + redact: { + allow: [ + 'tag', + 'playersInSegment', + 'pivotCurrency', + 'totals', + 'players', + 'unpricedPlayers', + 'truncated', + ], + }, + errors: ['segment_too_large'], +}); + export const HoldWithdrawalPayloadSchema = z.object({ playerId: UuidSchema, withdrawalId: UuidSchema, diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 4ad2e9b9a..72e50be39 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -20,6 +20,7 @@ import { PLATFORM_CONFIG, RATE_LIMITER, PLAYER_TAGS, + PLAYER_SEGMENT_READER, TAG_EVALUATION_COMMANDS, PLAY_ELIGIBILITY, RG_LIMITS, @@ -47,7 +48,9 @@ import { } from '@openora/core/contracts'; import { holdWithdrawalAction, + playersDepositCompareTool, walletActivityTool, + withdrawalsQueueTool, type WalletBalanceChangeReason, } from './contract/index.js'; import { @@ -56,6 +59,7 @@ import { WithdrawalNotPendingError, } from './service/wallet.service.js'; import { WalletCommandsService } from './service/wallet-commands.service.js'; +import { DepositCompareService, SegmentTooLargeError } from './service/deposit-compare.service.js'; import { CustodySweepService, CUSTODY_SWEEP_QUEUE, @@ -81,6 +85,13 @@ const DEFAULT_SWEEP_CRON = '*/15 * * * *'; const WALLET_RECONCILIATION_QUEUE = queue('wallet-reconciliation'); const WalletReconciliationJobSchema = z.object({ runId: UuidSchema.optional() }); +function depositCompareRefusal(err: unknown): never { + if (err instanceof SegmentTooLargeError) { + throw new McpToolError('segment_too_large'); + } + throw err; +} + function holdRefusalCode(err: unknown) { if (err instanceof WithdrawalNotFoundError) { return 'withdrawal_not_found'; @@ -278,6 +289,38 @@ export default { }; }); + ctx.mcp.tool(withdrawalsQueueTool, (c) => { + const wallet = walletService(c); + return (input) => wallet.withdrawalQueueOverview(input); + }); + + ctx.mcp.tool(playersDepositCompareTool, (c) => { + const identity = c.get(IDENTITY_READER); + if (!identity.getUserIdsByPlayerIds) { + logger.warn( + 'the bound IDENTITY_READER has no getUserIdsByPlayerIds - players.deposit_compare will answer internal_error', + ); + } + if (!c.has(PLAYER_SEGMENT_READER)) { + logger.warn( + 'no PLAYER_SEGMENT_READER is bound (the tag module binds it) - players.deposit_compare will answer internal_error', + ); + return async () => { + throw new Error('players.deposit_compare needs a bound PLAYER_SEGMENT_READER'); + }; + } + const deposits = new DepositCompareService({ + drizzle: c.get(DRIZZLE), + segments: c.get(PLAYER_SEGMENT_READER), + identity, + rates: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, + pivotCurrency: resolveExchangeRatePivot( + c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).exchangeRate : undefined, + ), + }); + return (input) => deposits.compare(input).catch(depositCompareRefusal); + }); + ctx.actions.register(holdWithdrawalAction, (c) => { const identity = c.get(IDENTITY_READER); const wallet = walletService(c); diff --git a/packages/core/src/wallet/service/deposit-compare.service.ts b/packages/core/src/wallet/service/deposit-compare.service.ts new file mode 100644 index 000000000..08c603fea --- /dev/null +++ b/packages/core/src/wallet/service/deposit-compare.service.ts @@ -0,0 +1,232 @@ +import { and, eq, or, sql } from 'drizzle-orm'; +import { + DrizzleService, + makeConflictError, + moneyAdd, + moneyCompare, + moneyDivide, + moneyScaleBy, + moneySubtract, +} from '@openora/core/server'; +import { + MAX_PLAYER_SEGMENT_PAGE, + userIdsOfPlayers, + type ExchangeRateReader, + type IdentityReader, + type Player, + type PlayerSegmentReader, + type TagKey, + type User, +} from '@openora/core/contracts'; +import { wallet, walletTransaction } from '../schema/index.js'; +import { + DEPOSIT_COMPARE_MAX_SEGMENT, + type DepositCompare, + type DepositCompareInput, + type DepositCompareOrder, + type DepositWindow, + type PlayerDepositChange, +} from '../contract/index.js'; +import { depositedAt } from './wallet.service.js'; +import { pricedInPivot, withQuoteCache } from './pivot-pricing.service.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const SegmentTooLargeError = makeConflictError( + 'SegmentTooLargeError', + `a deposit comparison reads at most ${DEPOSIT_COMPARE_MAX_SEGMENT} players`, +); + +type PricedPlayer = Omit; + +type DepositRow = { userId: User['id']; currency: string; depositsA: string; depositsB: string }; + +export type DepositCompareDeps = { + drizzle: DrizzleService; + segments: PlayerSegmentReader; + identity: IdentityReader; + rates: ExchangeRateReader | undefined; + pivotCurrency: string; +}; + +/** `change` as a percentage of `depositsA` to one decimal; null when there is nothing to compare to. */ +export function changePercent({ depositsA, change }: PricedPlayer): number | null { + if (moneyCompare(depositsA, '0') === 0) { + return null; + } + return Math.round(Number(moneyDivide(moneyScaleBy(change, '100'), depositsA)) * 10) / 10; +} + +/** + * The players whose deposits moved the way `order` asks, largest change first and ties by + * player id, at most `limit` of them. + */ +export function rankDepositChanges( + players: readonly PricedPlayer[], + order: DepositCompareOrder, + limit: number, +) { + const direction = order === 'largest_drop' ? -1 : 1; + const moved = players + .filter((player) => moneyCompare(player.change, '0') === direction) + .sort( + (left, right) => + direction * moneyCompare(right.change, left.change) || + left.playerId.localeCompare(right.playerId), + ); + return { + players: moved + .slice(0, limit) + .map((player) => ({ ...player, changePct: changePercent(player) })), + truncated: moved.length > limit, + }; +} + +function groupByUser(rows: readonly DepositRow[]): Map { + const byUser = new Map(); + for (const row of rows) { + byUser.set(row.userId, [...(byUser.get(row.userId) ?? []), row]); + } + return byUser; +} + +const startOf = (isoDay: string) => new Date(isoDay); +const endOf = (isoDay: string) => new Date(Date.parse(isoDay) + DAY_MS); + +function inWindow({ from, to }: DepositWindow) { + return sql`${depositedAt()} >= ${startOf(from)}::timestamptz and ${depositedAt()} < ${endOf(to)}::timestamptz`; +} + +/** + * Completed deposits of a tagged segment in two windows, priced per player into the fx pivot. + * Reads the segment a page at a time: one query resolves a page's user ids and one sums its + * deposits. + */ +export class DepositCompareService { + constructor(private readonly deps: DepositCompareDeps) {} + + async compare({ + tag, + windowA, + windowB, + order, + limit, + }: DepositCompareInput): Promise { + const [segment] = await this.deps.segments.describe([tag]); + if ((segment?.playerCount ?? 0) > DEPOSIT_COMPARE_MAX_SEGMENT) { + throw new SegmentTooLargeError(); + } + const rates = this.deps.rates && withQuoteCache(this.deps.rates); + const priced: PricedPlayer[] = []; + let playersInSegment = 0; + let unpricedPlayers = 0; + for await (const playerIds of this.segmentPages(tag)) { + playersInSegment += playerIds.length; + const page = await this.pricePage(playerIds, { windowA, windowB }, rates); + priced.push(...page.priced); + unpricedPlayers += page.unpriced; + } + const totals = priced.reduce( + (sum, player) => ({ + windowA: moneyAdd(sum.windowA, player.depositsA), + windowB: moneyAdd(sum.windowB, player.depositsB), + }), + { windowA: '0', windowB: '0' }, + ); + return { + tag, + playersInSegment, + pivotCurrency: this.deps.pivotCurrency, + totals, + ...rankDepositChanges(priced, order, limit), + unpricedPlayers, + }; + } + + private async *segmentPages(tag: TagKey): AsyncGenerator { + let after: Player['id'] | undefined; + let read = 0; + do { + const page = await this.deps.segments.page({ + tags: [tag], + limit: MAX_PLAYER_SEGMENT_PAGE, + ...(after === undefined ? {} : { after }), + }); + read += page.playerIds.length; + if (read > DEPOSIT_COMPARE_MAX_SEGMENT) { + throw new SegmentTooLargeError(); + } + yield page.playerIds; + after = page.next ?? undefined; + } while (after !== undefined); + } + + private async pricePage( + playerIds: readonly Player['id'][], + windows: { windowA: DepositWindow; windowB: DepositWindow }, + rates: ExchangeRateReader | undefined, + ) { + const userIdByPlayer = await userIdsOfPlayers(this.deps.identity, playerIds); + const rowsByUser = groupByUser(await this.depositsOf([...userIdByPlayer.values()], windows)); + const priced: PricedPlayer[] = []; + let unpriced = 0; + for (const [playerId, userId] of userIdByPlayer) { + const rows = rowsByUser.get(userId); + if (!rows) { + continue; + } + const [depositsA, depositsB] = await Promise.all([ + this.priced( + rows.map((row) => ({ currency: row.currency, total: row.depositsA })), + rates, + ), + this.priced( + rows.map((row) => ({ currency: row.currency, total: row.depositsB })), + rates, + ), + ]); + if (depositsA === null || depositsB === null) { + unpriced += 1; + continue; + } + priced.push({ playerId, depositsA, depositsB, change: moneySubtract(depositsB, depositsA) }); + } + return { priced, unpriced }; + } + + private priced( + rows: readonly { currency: string; total: string }[], + rates: ExchangeRateReader | undefined, + ) { + return pricedInPivot(rows, this.deps.pivotCurrency, rates); + } + + private async depositsOf( + userIds: readonly User['id'][], + { windowA, windowB }: { windowA: DepositWindow; windowB: DepositWindow }, + ): Promise { + if (userIds.length === 0) { + return []; + } + const sumIn = (window: DepositWindow) => + sql`coalesce(sum(${walletTransaction.amount}) filter (where ${inWindow(window)}), 0)`; + return this.deps.drizzle.db + .select({ + userId: wallet.userId, + currency: walletTransaction.currency, + depositsA: sumIn(windowA), + depositsB: sumIn(windowB), + }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where( + and( + sql`${wallet.userId} = ANY(${sql.param([...userIds])}::uuid[])`, + eq(walletTransaction.type, 'deposit'), + eq(walletTransaction.status, 'completed'), + or(inWindow(windowA), inWindow(windowB)), + ), + ) + .groupBy(wallet.userId, walletTransaction.currency); + } +} diff --git a/packages/core/src/wallet/service/pivot-pricing.service.ts b/packages/core/src/wallet/service/pivot-pricing.service.ts new file mode 100644 index 000000000..5ca3907a8 --- /dev/null +++ b/packages/core/src/wallet/service/pivot-pricing.service.ts @@ -0,0 +1,44 @@ +import { moneyCompare, moneyScaleBy, sumInPivot } from '@openora/core/server'; +import type { ExchangeRateQuote, ExchangeRateReader } from '@openora/core/contracts'; + +type CurrencyTotal = { currency: string; total: string | null }; + +/** + * Per-currency totals summed in the pivot currency; null when a currency with a non-zero total + * has no rate. A currency whose amounts cancel out needs no rate, so it cannot make the total + * unknown. + */ +export function pricedInPivot( + rows: readonly CurrencyTotal[], + pivotCurrency: string, + rates: ExchangeRateReader | undefined, +): Promise { + return sumInPivot( + rows + .map((row) => ({ currency: row.currency, total: row.total ?? '0' })) + .filter((row) => moneyCompare(row.total, '0') !== 0), + pivotCurrency, + rates, + ); +} + +/** + * `rates` answering each currency pair from the first quote it gave, so one read that prices + * many players asks for a pair's rate once. + */ +export function withQuoteCache(rates: ExchangeRateReader): ExchangeRateReader { + const quotes = new Map>(); + const getRate = (from: string, to: string) => { + const key = `${from.toUpperCase()}:${to.toUpperCase()}`; + const quote = quotes.get(key) ?? rates.getRate(from, to); + quotes.set(key, quote); + return quote; + }; + return { + getRate, + convert: async (amount, from, to) => { + const quote = await getRate(from, to); + return quote ? moneyScaleBy(amount, quote.rate) : null; + }, + }; +} diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 35582f37e..39f7b18ce 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -130,8 +130,16 @@ import type { WithdrawalAddress, CreateWithdrawalAddressInput, WalletReferenceConversion, + QueuedWithdrawalStatus, + WithdrawalRiskTag, + WithdrawalsQueue, + WithdrawalsQueueInput, +} from '../contract/index.js'; +import { + OPEN_WITHDRAWALS_LIMIT, + QUEUED_WITHDRAWAL_STATUSES, + WITHDRAWAL_QUEUE_MAX_CURRENCIES, } from '../contract/index.js'; -import { OPEN_WITHDRAWALS_LIMIT } from '../contract/index.js'; const logger = createLogger('wallet'); @@ -892,9 +900,10 @@ export const VELOCITY_IGNORED_STATUSES = [ 'cancelled', ] as const satisfies readonly WalletTransactionStatus[]; -// A withdrawal still waiting on a decision: the queue summary counts and totals only these, and -// an admin may approve or reject only these. -const QUEUED_WITHDRAWAL_STATUSES = ['pending', 'on_hold'] as const; +// A held deposit reaches the balance when an operator releases it, which stamps reviewedAt; +// every other completed deposit has no reviewedAt. +export const depositedAt = () => + sql`coalesce(${walletTransaction.reviewedAt}, ${walletTransaction.createdAt})`; export const IN_FLIGHT_TRANSACTION_STATUSES = [ 'pending', @@ -905,6 +914,10 @@ export const IN_FLIGHT_TRANSACTION_STATUSES = [ const ACTIVITY_TRANSACTION_TYPES = ['deposit', 'withdrawal', 'bet', 'win', 'win_reversal'] as const; const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; + +/** A duration in hours to one decimal, never below zero. */ +const hoursIn = (ms: number) => Math.round((Math.max(0, ms) / HOUR_MS) * 10) / 10; const DAILY_CAP_WINDOW_MS = 24 * 60 * 60 * 1000; @@ -2502,42 +2515,21 @@ export class WalletService { } async listWithdrawals(filters: WithdrawalQueueFilter) { - const db = this.drizzle.db; const { page, limit } = filters; const { matching, byUserId } = await this.matchingWithdrawals(filters, { withPlayers: true }); const start = pageToOffset(page, limit); const pageRows = matching.slice(start, start + limit); - // One batched velocity query for the page (no N+1); shares the window + threshold + query the auto-approval evaluator uses. - // The tag is a display hint, so an unseeded install (no config row) just drops it rather - // than failing the queue - the auto-approval read path is the one that fails closed. - const pageWalletIds = [...new Set(pageRows.map((r) => r.tx.walletId))]; - const frequentWalletIds = await this.frequentWithdrawalWalletIds( - db, - pageWalletIds, - await this.getAutoWithdrawalConfigOrNull(), - ); - const pivotCurrency = resolveExchangeRatePivot(this.platformConfig?.exchangeRate); const pageUserIds = [...new Set(pageRows.map((r) => r.userId))]; - const [lockedUserIds, heldUserIds] = await Promise.all([ + const [riskTags, lockedUserIds, heldUserIds] = await Promise.all([ + this.withdrawalRiskTags(pageRows.map((r) => r.tx)), this.withdrawalLockedUserIds(pageUserIds), this.withdrawalHeldUserIds(pageUserIds), ]); - const items: WithdrawalQueueItem[] = await mapConcurrent(pageRows, 5, async (r) => { + const items: WithdrawalQueueItem[] = pageRows.map((r) => { const summary = byUserId.get(r.userId); - const riskTags: string[] = []; - // The queue tag is priced in the fx pivot, same as auto-approval - a raw compare let a - // 5000 DOGE withdrawal read as "large" next to a 5000 BTC one that dwarfs it. No rate - // for the coin flags it anyway: never let a compliance total quietly undercount. - const pivotAmount = await this.toPivotAmount(r.tx.amount, r.tx.currency, pivotCurrency); - if (pivotAmount === null || moneyCompare(pivotAmount, LARGE_WITHDRAWAL_THRESHOLD) >= 0) { - riskTags.push('large_amount'); - } - if (frequentWalletIds.has(r.tx.walletId)) { - riskTags.push('high_frequency'); - } return { transactionId: r.tx.id, userId: r.userId, @@ -2550,7 +2542,7 @@ export class WalletService { rail: r.tx.rail ?? null, status: r.tx.status, kycStatus: summary?.kycStatus ?? null, - riskTags, + riskTags: riskTags.get(r.tx.id) ?? [], withdrawalLocked: lockedUserIds.has(r.userId), withdrawalHeld: heldUserIds.has(r.userId), requestedAt: r.tx.createdAt.toISOString(), @@ -2563,6 +2555,110 @@ export class WalletService { return { items, total: matching.length, page, limit }; } + /** + * The queue's headline figures across every player and the withdrawals waiting longest for a + * decision, oldest first, without the addresses, hashes, names or review notes the admin + * queue shows. + */ + async withdrawalQueueOverview( + { status, limit }: WithdrawalsQueueInput, + now = new Date(), + ): Promise { + const [summary, oldest] = await Promise.all([ + this.summarizeWithdrawals({}), + this.oldestQueuedWithdrawals(status ? [status] : QUEUED_WITHDRAWAL_STATUSES, limit), + ]); + return { + pendingCount: summary.pendingCount, + onHoldCount: summary.onHoldCount, + queuedTotals: summary.queuedTotals.slice(0, WITHDRAWAL_QUEUE_MAX_CURRENCIES), + avgPendingWaitHours: + summary.avgPendingWaitSeconds === null + ? null + : hoursIn(summary.avgPendingWaitSeconds * 1000), + oldest: oldest.map(({ requestedAt, ...item }) => ({ + ...item, + requestedAt: requestedAt.toISOString(), + waitHours: hoursIn(now.getTime() - requestedAt.getTime()), + })), + }; + } + + private async oldestQueuedWithdrawals( + statuses: readonly QueuedWithdrawalStatus[], + limit: number, + ) { + const rows = await this.drizzle.db + .select({ + id: walletTransaction.id, + walletId: walletTransaction.walletId, + amount: walletTransaction.amount, + currency: walletTransaction.currency, + status: walletTransaction.status, + createdAt: walletTransaction.createdAt, + userId: wallet.userId, + }) + .from(walletTransaction) + .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) + .where( + and( + eq(walletTransaction.type, 'withdrawal'), + inArray(walletTransaction.status, [...statuses]), + ), + ) + .orderBy(asc(walletTransaction.createdAt), asc(walletTransaction.id)) + .limit(limit); + const userIds = [...new Set(rows.map((row) => row.userId))]; + const [players, riskTags] = await Promise.all([ + this.directory && userIds.length > 0 ? this.directory.lookupPlayers(userIds) : [], + this.withdrawalRiskTags(rows), + ]); + const playerByUserId = new Map(players.map((player) => [player.userId, player])); + return rows.map((row) => { + const player = playerByUserId.get(row.userId); + return { + withdrawalId: row.id, + playerId: player?.playerId ?? null, + amount: row.amount, + currency: row.currency, + status: row.status, + requestedAt: row.createdAt, + kycStatus: player?.kycStatus ? normalizeKycStatus(player.kycStatus) : null, + riskTags: riskTags.get(row.id) ?? [], + }; + }); + } + + /** The queue's risk tags of each withdrawal, keyed by its id. */ + private async withdrawalRiskTags( + withdrawals: readonly Pick[], + ): Promise> { + // One batched velocity query for the page (no N+1); shares the window + threshold + query the auto-approval evaluator uses. + // The tag is a display hint, so an unseeded install (no config row) just drops it rather + // than failing the queue - the auto-approval read path is the one that fails closed. + const frequentWalletIds = await this.frequentWithdrawalWalletIds( + this.drizzle.db, + [...new Set(withdrawals.map((w) => w.walletId))], + await this.getAutoWithdrawalConfigOrNull(), + ); + const pivotCurrency = resolveExchangeRatePivot(this.platformConfig?.exchangeRate); + const tags = await mapConcurrent(withdrawals, 5, async (w) => { + const riskTags: WithdrawalRiskTag[] = []; + // The queue tag is priced in the fx pivot, same as auto-approval - a raw compare let a + // 5000 DOGE withdrawal read as "large" next to a 5000 BTC one that dwarfs it. No rate + // for the coin flags it anyway: never let a compliance total quietly undercount. + const pivotAmount = await this.toPivotAmount(w.amount, w.currency, pivotCurrency); + if (pivotAmount === null || moneyCompare(pivotAmount, LARGE_WITHDRAWAL_THRESHOLD) >= 0) { + riskTags.push('large_amount'); + } + if (frequentWalletIds.has(w.walletId)) { + riskTags.push('high_frequency'); + } + return [w.id, riskTags] as const; + }); + return new Map(tags); + } + /** * Approve: Pending -> Processing (commit + emit), send to the vendor, then Completed on * success, Failed + refund on a PaymentRejectedError, or held in Processing when the From af8e4262cb50691e1ccebee3e624b52cd9497368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 5 Nov 2026 12:06:38 +0100 Subject: [PATCH 4/5] test(testing): cover the operator 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 now expects the eight read tools and calls each new one against seeded ledger rows: exactly the allow-listed keys, realistic totals (every status of a payment day, cashback outside bonus cost, the oldest held withdrawal, a vip segment's drop in the pivot currency), no review note, address, email or user id in what is served, and `invalid_input` for an out-of-bounds range, window or limit. An admin whose seeded role lacks the tool's grant gets `forbidden`, audited once. The transport suite lists the new tools under their model names. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../src/__tests__/mcp-kernel.e2e.test.ts | 388 +++++++++++++++++- .../src/__tests__/mcp-transport.e2e.test.ts | 6 +- 2 files changed, 391 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index fa949bc20..d5286e919 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -18,10 +18,12 @@ import { auditLog } from '@openora/core/audit/schema'; import { kycVerification } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; import { playerTag, tag } from '@openora/core/pam/schema/tag'; -import { walletTransaction } from '@openora/core/wallet/schema'; +import { wallet, walletTransaction } from '@openora/core/wallet/schema'; import { registerAndMaterializePlayer, asAdmin, + assignTag, + newStaff, type TestDb, type TestApp, type TestClient, @@ -42,10 +44,14 @@ const REVIEWED = 'Reviewed, no issues.'; const NOTE_ROUTE_KEYS = ['actorId', 'content', 'createdAt', 'id', 'playerId', 'updatedAt']; const EXPECTED_TOOL_OWNERS = { + 'financial.summary': 'analytics', 'ggr.summary': 'analytics', 'kyc.status': 'compliance', + 'payments.flow_daily': 'analytics', 'player.summary': 'player-management', + 'players.deposit_compare': 'wallet', 'wallet.activity': 'wallet', + 'withdrawals.queue': 'wallet', } as const; const EXPECTED_ACTION_OWNERS = { @@ -175,6 +181,14 @@ function stringField(record: Record, key: string): string { return value; } +function listField(record: Record, key: string): Record[] { + const value = record[key]; + if (!Array.isArray(value)) { + throw new Error(`expected "${key}" to be an array, got ${JSON.stringify(value)}`); + } + return value.map(object); +} + // Amounts come back as fixed-scale decimal strings (eg `30.000000000000000000`). function wholeAmount(units: number): RegExp { return new RegExp(`^${units}(\\.0+)?$`); @@ -263,6 +277,25 @@ async function requestUsdWithdrawal(client: TestClient, amount: string): Promise return stringField(body, 'transactionId'); } +async function walletIdOf(userId: string): Promise { + const [row] = await drizzle() + .select({ id: wallet.id }) + .from(wallet) + .where(eq(wallet.userId, userId)); + if (!row) { + throw new Error(`no wallet row for user ${userId}`); + } + return row.id; +} + +async function queuedWithdrawalCount(status: 'pending' | 'on_hold'): Promise { + const [row] = await drizzle() + .select({ total: sql`count(*)::int` }) + .from(walletTransaction) + .where(and(eq(walletTransaction.type, 'withdrawal'), eq(walletTransaction.status, status))); + return row?.total ?? 0; +} + async function usdBalance(client: TestClient): Promise { const res = await client.get('/wallet/balances'); expect(res.status).toBe(200); @@ -297,7 +330,7 @@ afterAll(async () => { }); describe('MCP kernel registries match the generated catalog', () => { - it('serves exactly the four read tools and five action types, each under its owning plugin', () => { + it('serves exactly the eight read tools and five action types, each under its owning plugin', () => { const tools = kernel.listTools(); const actions = kernel.listActionTypes(); @@ -943,3 +976,354 @@ describe('add_note writes one note per approved proposal', () => { expect(await auditRowsOf('mcp.action.executed', proposalId)).toHaveLength(2); }); }); + +describe('operator read tools answer only their declared keys, under their own grants', () => { + const FLOW_CURRENCY = 'ZEC'; + const FLOW_RANGE = { dateFrom: '2026-02-01', dateTo: '2026-02-14' }; + const WINDOW_A = { from: '2026-02-01', to: '2026-02-28' }; + const WINDOW_B = { from: '2026-05-01', to: '2026-05-31' }; + const AML_NOTE = 'aml review: deposits structured below the reporting line'; + const PAYOUT_ADDRESS = 'bc1qmcpkerneloperatortoolsdestination000'; + const HELD_AT = '2025-12-01T10:00:00.000Z'; + let analyst: Awaited>; + let marketer: Awaited>; + let dropping: Awaited>; + let rising: Awaited>; + let payer: Awaited>; + let heldWithdrawalId: string; + + const seed = ( + walletId: string, + rows: readonly Pick< + typeof walletTransaction.$inferInsert, + 'type' | 'amount' | 'currency' | 'status' | 'createdAt' + >[], + ) => + drizzle() + .insert(walletTransaction) + .values(rows.map((row) => ({ ...row, walletId }))); + + beforeAll(async () => { + analyst = await newStaff(app, 'mcp-no-analytics', 'kyc-aml-officer'); + marketer = await newStaff(app, 'mcp-no-payments', 'marketing-banners'); + dropping = await newPlayer('deposits-dropping'); + rising = await newPlayer('deposits-rising'); + payer = await newPlayer('payments-flow'); + await depositUsd(dropping.client, '1'); + await depositUsd(rising.client, '1'); + await depositUsd(payer.client, '1'); + const droppingWallet = await walletIdOf(dropping.userId); + const risingWallet = await walletIdOf(rising.userId); + const payerWallet = await walletIdOf(payer.userId); + const at = (iso: string) => new Date(iso); + + await seed(payerWallet, [ + { + type: 'deposit', + amount: '2', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-02T10:00:00Z'), + }, + { + type: 'deposit', + amount: '3', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-02T11:00:00Z'), + }, + { + type: 'deposit', + amount: '1', + currency: FLOW_CURRENCY, + status: 'failed', + createdAt: at('2026-02-03T10:00:00Z'), + }, + { + type: 'withdrawal', + amount: '1.5', + currency: FLOW_CURRENCY, + status: 'rejected', + createdAt: at('2026-02-03T11:00:00Z'), + }, + { + type: 'withdrawal', + amount: '1', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-06T10:00:00Z'), + }, + { + type: 'bonus', + amount: '0.5', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-05T10:00:00Z'), + }, + { + type: 'cashback', + amount: '0.25', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-05T11:00:00Z'), + }, + { + type: 'bet', + amount: '4', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-05T12:00:00Z'), + }, + { + type: 'win', + amount: '1', + currency: FLOW_CURRENCY, + status: 'completed', + createdAt: at('2026-02-05T13:00:00Z'), + }, + ]); + await seed(droppingWallet, [ + { + type: 'deposit', + amount: '300', + currency: 'USD', + status: 'completed', + createdAt: at('2026-02-10T10:00:00Z'), + }, + { + type: 'deposit', + amount: '100', + currency: 'USD', + status: 'completed', + createdAt: at('2026-05-10T10:00:00Z'), + }, + ]); + await seed(risingWallet, [ + { + type: 'deposit', + amount: '50', + currency: 'USD', + status: 'completed', + createdAt: at('2026-02-11T10:00:00Z'), + }, + { + type: 'deposit', + amount: '80', + currency: 'USD', + status: 'completed', + createdAt: at('2026-05-11T10:00:00Z'), + }, + ]); + const [held] = await drizzle() + .insert(walletTransaction) + .values({ + walletId: droppingWallet, + type: 'withdrawal', + amount: '6000', + currency: 'USD', + status: 'on_hold', + createdAt: at(HELD_AT), + reviewReason: AML_NOTE, + destinationAddress: PAYOUT_ADDRESS, + }) + .returning({ id: walletTransaction.id }); + if (!held) { + throw new Error('the held withdrawal was not seeded'); + } + heldWithdrawalId = held.id; + await assignTag(admin, dropping.playerId, 'vip'); + await assignTag(admin, rising.playerId, 'vip'); + }); + + it('answers payments.flow_daily with every status of the day, and refuses a range longer than 92 days', async () => { + const output = outputOf( + await kernel.invokeTool( + 'payments.flow_daily', + { ...FLOW_RANGE, currency: FLOW_CURRENCY }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('payments.flow_daily')); + expect(output).toEqual({ + ...FLOW_RANGE, + granularity: 'day', + rows: [ + { + bucket: '2026-02-02', + type: 'deposit', + status: 'completed', + currency: FLOW_CURRENCY, + count: 2, + amount: expect.stringMatching(wholeAmount(5)), + }, + { + bucket: '2026-02-03', + type: 'deposit', + status: 'failed', + currency: FLOW_CURRENCY, + count: 1, + amount: expect.stringMatching(wholeAmount(1)), + }, + { + bucket: '2026-02-03', + type: 'withdrawal', + status: 'rejected', + currency: FLOW_CURRENCY, + count: 1, + amount: '1.500000000000000000', + }, + { + bucket: '2026-02-06', + type: 'withdrawal', + status: 'completed', + currency: FLOW_CURRENCY, + count: 1, + amount: expect.stringMatching(wholeAmount(1)), + }, + ], + truncated: false, + }); + + const tooLong = await kernel.invokeTool( + 'payments.flow_daily', + { dateFrom: '2026-01-01', dateTo: '2026-04-03' }, + adminRun(adminId), + ); + expect(tooLong).toMatchObject({ + ok: false, + error: 'invalid_input', + issues: [expect.objectContaining({ path: 'dateFrom' })], + }); + }); + + it('answers financial.summary per currency with cashback outside bonus cost, and refuses a range longer than 366 days', async () => { + const output = outputOf( + await kernel.invokeTool( + 'financial.summary', + { dateFrom: '2026-02-01', dateTo: '2026-02-28', currency: FLOW_CURRENCY.toLowerCase() }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('financial.summary')); + expect(output).toEqual({ + dateFrom: '2026-02-01', + dateTo: '2026-02-28', + currencies: [ + { + currency: FLOW_CURRENCY, + deposits: expect.stringMatching(wholeAmount(5)), + withdrawals: expect.stringMatching(wholeAmount(1)), + netDeposits: '3.500000000000000000', + ggr: expect.stringMatching(wholeAmount(3)), + bonusCost: '0.500000000000000000', + ngr: '2.500000000000000000', + }, + ], + products: [], + truncated: false, + }); + + const tooLong = await kernel.invokeTool( + 'financial.summary', + { dateFrom: '2025-01-01', dateTo: '2026-01-02' }, + adminRun(adminId), + ); + expect(tooLong).toMatchObject({ ok: false, error: 'invalid_input' }); + }); + + it('answers withdrawals.queue with the oldest held withdrawal and none of its review note, address or owner', async () => { + const output = outputOf( + await kernel.invokeTool('withdrawals.queue', { status: 'on_hold' }, adminRun(adminId)), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('withdrawals.queue')); + expect(output).toMatchObject({ + pendingCount: await queuedWithdrawalCount('pending'), + onHoldCount: await queuedWithdrawalCount('on_hold'), + }); + const [oldest] = listField(output, 'oldest'); + expect(oldest).toEqual({ + withdrawalId: heldWithdrawalId, + playerId: dropping.playerId, + amount: expect.stringMatching(wholeAmount(6000)), + currency: 'USD', + status: 'on_hold', + requestedAt: HELD_AT, + waitHours: expect.any(Number), + kycStatus: 'pending', + riskTags: ['large_amount'], + }); + expect(Number(oldest?.['waitHours'])).toBeGreaterThan(24); + expect(listField(output, 'oldest').every((item) => item['status'] === 'on_hold')).toBe(true); + const served = JSON.stringify(output); + expect(served).not.toContain(AML_NOTE); + expect(served).not.toContain(PAYOUT_ADDRESS); + expect(served).not.toContain(dropping.email); + expect(served).not.toContain(dropping.userId); + + const tooMany = await kernel.invokeTool('withdrawals.queue', { limit: 21 }, adminRun(adminId)); + expect(tooMany).toMatchObject({ ok: false, error: 'invalid_input' }); + }); + + it('answers players.deposit_compare with the drop in the pivot currency, and refuses a window longer than 92 days', async () => { + const output = outputOf( + await kernel.invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('players.deposit_compare')); + expect(output).toMatchObject({ + tag: 'vip', + pivotCurrency: 'USD', + totals: { + windowA: expect.stringMatching(wholeAmount(350)), + windowB: expect.stringMatching(wholeAmount(180)), + }, + players: [ + { + playerId: dropping.playerId, + depositsA: expect.stringMatching(wholeAmount(300)), + depositsB: expect.stringMatching(wholeAmount(100)), + change: '-200.000000000000000000', + changePct: -66.7, + }, + ], + unpricedPlayers: 0, + truncated: false, + }); + expect(listField(output, 'players')).toHaveLength(1); + expect(output['playersInSegment']).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(output)).not.toContain(dropping.userId); + + const tooLong = await kernel.invokeTool( + 'players.deposit_compare', + { tag: 'vip', windowA: WINDOW_A, windowB: { from: '2026-05-01', to: '2026-08-01' } }, + adminRun(adminId), + ); + expect(tooLong).toMatchObject({ + ok: false, + error: 'invalid_input', + issues: [expect.objectContaining({ path: 'windowB.from' })], + }); + }); + + it.each([ + ['payments.flow_daily', {}, 'analyst'], + ['financial.summary', {}, 'analyst'], + ['withdrawals.queue', {}, 'marketer'], + ['players.deposit_compare', { tag: 'vip', windowA: WINDOW_A, windowB: WINDOW_B }, 'marketer'], + ] as const)('refuses %s to an admin whose roles lack its grant', async (toolId, input, who) => { + const caller = who === 'analyst' ? analyst : marketer; + const run = adminRun(caller.userId); + + expect(await kernel.invokeTool(toolId, input, 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 ab33cd77f..c4d8f5e8a 100644 --- a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts @@ -296,17 +296,21 @@ describe('MCP tokens', () => { }); describe('MCP transport, happy path through the SDK client', () => { - it('lists the four read tools under model names, without propose tools or personal keys', async () => { + it('lists the read tools under model names, without propose tools or personal keys', async () => { const { token } = await issueToken(admin); const client = await connect(token); const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ + 'financial_summary', 'ggr_summary', 'kyc_status', + 'payments_flow_daily', 'player_summary', + 'players_deposit_compare', 'wallet_activity', + 'withdrawals_queue', ]); const summary = tools.find((tool) => tool.name === 'player_summary'); const properties = Object.keys(summary?.outputSchema?.properties ?? {}); From 15b6783b56df708ac2246b39aacec8bcd7f3af48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 5 Nov 2026 12:07:13 +0100 Subject: [PATCH 5/5] docs(core): record the operator read tools and the player-id key rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0042 gains a dated update: the four operator-level read tools and their grants, the bounds and caps every one of them keeps, the free text and identifiers none of them returns, the rule that a player is named only by player id under a `playerId`-style key because consumers pseudonymise by key name, and the `agent-chat:use` grant. The wallet module doc lists its two new tools. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- ...mcp-kernel-two-registries-behind-one-enforcing-wrapper.md | 2 ++ docs/modules/wallet.md | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 335f5a6b1..8a64e3a32 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 @@ -15,6 +15,8 @@ > **Update (2026-11-03)**: Every audit row of one proposal's execution carries the run's correlation id, the module's rows as well as the kernel's. The kernel runs the executor (not the precondition) inside a request context that carries the id - the active request's, keeping its trace id, caller and audit trail, or a fresh one when the execution runs outside a request. `AUDIT_WRITER` fills an entry's unset `correlationId` from that context (an explicit value, `null` included, wins), and the event bus copies it into the event envelope and the outbox row, so a row the audit module writes later from an event emitted by the executor carries it too; a topic that maps a bulk operation id keeps that id. The id is not in the hash chain, as before. +> **Update (2026-11-05)**: Core registers operator-level read tools beside the per-player ones, so an admin's assistant can answer questions about the whole operation: `payments.flow_daily` and `financial.summary` (analytics, `analytics:view`), `withdrawals.queue` (`withdrawal:view`) and `players.deposit_compare` (`transaction:view`). Each bounds its date range or windows, caps every list it returns and says so in its description, and returns none of the free text an admin or a check wrote (a review reason, a note, a decision reason), no address, hash or provider reference, and no name or email. A tool output names a player only by player id, under a key named `playerId` or `playerIds` or ending in `PlayerId` or `PlayerIds`: a consumer pseudonymises players by key name, so a player id under another key, or a user id under any key, would reach the model as it is. `players.deposit_compare` reads its population through `PLAYER_SEGMENT_READER`, refuses a segment above 20 000 players, and resolves each page of player ids to user ids in one query with the optional `IDENTITY_READER.getUserIdsByPlayerIds`. The iam catalog gains `agent-chat:use`, which an agent hub checks before an admin may use its back-office assistant; core enforces nothing on it, grants it where it grants `mcp-access`, and every tool call is still checked against the asking admin's grant for that tool. + ## Context An agent hub (a premium package outside this repo) runs models that read platform data and diff --git a/docs/modules/wallet.md b/docs/modules/wallet.md index 7b14f580d..add6b561d 100644 --- a/docs/modules/wallet.md +++ b/docs/modules/wallet.md @@ -34,7 +34,10 @@ does not repeat it. containers, the sweep, reconciliation findings, and the withdrawal approval path. - **Its agent surface.** A read tool over a player's balances, recent money movement and withdrawals still awaiting a decision, and an action type that holds a player's waiting - withdrawal for manual review. Both are served only through the MCP kernel (ADR-0042). + withdrawal for manual review. Two operator-level read tools: the withdrawal queue (its counts, + totals, average wait and oldest waiting withdrawals, with no review note, address or hash), + and a tagged segment's completed deposits compared between two windows, priced into the fx + pivot. All are served only through the MCP kernel (ADR-0042). - **Read-only money facts for other modules.** Lifetime deposits and withdrawals, the last deposit, turnover and recent withdrawal requests, read through the wallet's read port rather than its tables. Every total is priced into the fx pivot at today's rates and reads as