From 4d302e01b604c4de42e3e4f7402117ad40db06f4 Mon Sep 17 00:00:00 2001 From: Marek Chmielowski Date: Tue, 4 Aug 2026 07:45:07 +0200 Subject: [PATCH 1/5] feat(wallet): tag-based exclusions for auto-withdrawal (BF-319) Move the auto-withdrawal exclusion tag list from static platform config into the DB-backed wallet_auto_withdrawal_config singleton row, so a Super Admin can edit it at runtime with no deploy - mirroring fiatThreshold/cryptoThreshold from BF-211. A non-removable compliance floor (withdrawal_review, kyc_rejected, multi_account, high_risk, bonus_abuser) is unioned into the DB-configured set server-side at evaluation time, so a `.set` call can widen exclusions but never narrow them below the floor. --- .../src/contracts/schemas/platform-config.ts | 3 - packages/core/src/wallet/AGENTS.md | 3 +- ...-auto-withdrawal-config.router.int.test.ts | 51 +- .../wallet-auto-withdrawal.int.test.ts | 111 ++- packages/core/src/wallet/contract/index.ts | 3 + .../drizzle/migrations/0006_lush_morg.sql | 1 + .../migrations/meta/0006_snapshot.json | 638 ++++++++++++++++++ .../drizzle/migrations/meta/_journal.json | 7 + packages/core/src/wallet/schema/index.ts | 8 + .../seed/seed-auto-withdrawal-config.ts | 4 +- .../core/src/wallet/service/wallet.service.ts | 76 ++- .../qa-bf211-auto-withdrawal-config-plugin.ts | 5 +- .../qa-bf319-exclude-risk-flags-plugin.ts | 26 + ...st-wallet-auto-withdrawal-config-plugin.ts | 10 +- ...-wallet-auto-withdrawal-config.e2e.test.ts | 28 +- ...-withdrawal-exclude-risk-flags.e2e.test.ts | 406 +++++++++++ 16 files changed, 1325 insertions(+), 55 deletions(-) create mode 100644 packages/core/src/wallet/drizzle/migrations/0006_lush_morg.sql create mode 100644 packages/core/src/wallet/drizzle/migrations/meta/0006_snapshot.json create mode 100644 packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts create mode 100644 packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 2ad1ee90..408af8b1 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; import { LimitsSchema } from './igaming-config.js'; -import { TagKeySchema } from './tag.js'; import { MoneyAmountSchema } from './common.js'; import { createToken } from '../adapters/token.js'; @@ -51,8 +50,6 @@ export const KycConfigSchema = z export const AutoWithdrawalConfigSchema = z .object({ enabled: z.boolean().default(false), - /** Active tag keys that veto auto-approval and force manual review. */ - excludeRiskFlags: z.array(TagKeySchema).default([]), /** Amount cap on auto-approved payouts per player per trailing 24h. Absent = no cap. */ dailyCapAmount: MoneyAmountSchema.optional(), /** Count cap on auto-approved payouts per player per trailing 24h. Absent = no cap. */ diff --git a/packages/core/src/wallet/AGENTS.md b/packages/core/src/wallet/AGENTS.md index 06a70bb0..255eac37 100644 --- a/packages/core/src/wallet/AGENTS.md +++ b/packages/core/src/wallet/AGENTS.md @@ -23,7 +23,8 @@ Queue `riskTags` are DB-backed heuristics, not a risk engine: `large_amount` (>= After the hold commits, `maybeAutoApprove` decides WHO approves - system or manual queue - never whether the request succeeds. Strictly fail-closed: it NEVER throws out of `withdraw()`; any error or ambiguous branch leaves the row `pending`. -- Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `wallet_auto_withdrawal_config` singleton row - DB-backed, Super-Admin-editable at runtime via `autoWithdrawalConfig.set`, no redeploy needed, BF-211) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting `excludeRiskFlags` (via `PLAYER_TAGS`; port unbound while flags are configured => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded. +- Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `wallet_auto_withdrawal_config` singleton row - DB-backed, Super-Admin-editable at runtime via `autoWithdrawalConfig.set`, no redeploy needed, BF-211) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting the effective exclusion set (via `PLAYER_TAGS`; port unbound while the set is non-empty => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded. The per-player rule overrides only the THRESHOLD gate - it never bypasses the tag-exclusion gate. +- Tag-exclusion set (BF-319): `wallet_auto_withdrawal_config.excludeRiskFlags` (DB column, Super-Admin-editable via `autoWithdrawalConfig.set`, admin submits the full replacement array every call) UNIONED with `COMPLIANCE_FLOOR_TAGS` (`wallet.service.ts` - `withdrawal_review`, `kyc_rejected`, `multi_account`, `high_risk`, `bonus_abuser`), a hardcoded, non-removable server-side floor. The union means the DB column can only ever widen exclusions, never narrow them below the floor - `.set` with an array omitting a floor tag still leaves that tag excluded. The floor makes the exclusion set always non-empty in practice, so `PLAYER_TAGS` is now an effectively-required dependency for auto-approval to ever succeed, not just when an operator opts into exclusions. - Only the daily-cap check runs under the per-user advisory lock (atomic with the `processing` flip); the other gates run before it. - System-actor marker: `reviewedBy = null`, `reviewReason = 'auto-approved'`. Reuses `flipToProcessing`/`settleApproved` - the same two-phase sequence as manual approve. - Every auto-approval writes an `AUDIT_WRITER` entry (`actorType: 'system'`) capturing the full rationale BEFORE the PSP call, so the AML/SAR trail survives a PSP failure. diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts index c8a1b742..4dd038ba 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts @@ -8,6 +8,7 @@ import type { PaymentAdapter, PaymentWebhookVerifier, PlatformConfig, + PlayerTags, } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { @@ -73,6 +74,12 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial({ + getActiveTagKeys: vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, []]))), + }); const service = new WalletService({ drizzle: db.drizzle, events: makeEventBus(), @@ -85,6 +92,7 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial(platformConfig) : undefined, + riskTags, }); const router = createWalletRouter( service, @@ -111,6 +119,9 @@ describe('wallet auto-withdrawal-config routes', () => { const result = await call(router.autoWithdrawalConfig.get, {}, { context: CTX }); expect(result).toMatchObject({ fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000' }); + expect(result.excludeRiskFlags).toEqual( + expect.arrayContaining(['high_risk', 'bonus_abuser', 'kyc_rejected']), + ); }); it('get: rejects payments-manager', async () => { @@ -135,7 +146,7 @@ describe('wallet auto-withdrawal-config routes', () => { await expect( call( router.autoWithdrawalConfig.set, - { fiatThreshold: '500', cryptoThreshold: '1' }, + { fiatThreshold: '500', cryptoThreshold: '1', excludeRiskFlags: [] }, { context: CTX }, ), ).rejects.toBeInstanceOf(ORPCError); @@ -149,7 +160,7 @@ describe('wallet auto-withdrawal-config routes', () => { await expect( call( router.autoWithdrawalConfig.set, - { fiatThreshold: '500', cryptoThreshold: '1' }, + { fiatThreshold: '500', cryptoThreshold: '1', excludeRiskFlags: [] }, { context: CTX }, ), ).rejects.toBeInstanceOf(ORPCError); @@ -163,7 +174,7 @@ describe('wallet auto-withdrawal-config routes', () => { await expect( call( router.autoWithdrawalConfig.set, - { fiatThreshold: '-1', cryptoThreshold: '1' }, + { fiatThreshold: '-1', cryptoThreshold: '1', excludeRiskFlags: [] }, { context: CTX }, ), ).rejects.toThrow(); @@ -175,7 +186,7 @@ describe('wallet auto-withdrawal-config routes', () => { await expect( call( router.autoWithdrawalConfig.set, - { fiatThreshold: '1', cryptoThreshold: '-1' }, + { fiatThreshold: '1', cryptoThreshold: '-1', excludeRiskFlags: [] }, { context: CTX }, ), ).rejects.toThrow(); @@ -187,30 +198,32 @@ describe('wallet auto-withdrawal-config routes', () => { await expect( call( router.autoWithdrawalConfig.set, - { fiatThreshold: '10000000000', cryptoThreshold: '1' }, + { fiatThreshold: '10000000000', cryptoThreshold: '1', excludeRiskFlags: [] }, { context: CTX }, ), ).rejects.toThrow(); }); - it('set: super-admin updates both thresholds, GET reflects immediately, and writes an admin audit entry with before/after', async () => { + it('set: super-admin updates both thresholds and excludeRiskFlags, GET reflects immediately, and writes an admin audit entry with before/after', async () => { const { router, audit } = routerWith(superAdminGuard()); const result = await call( router.autoWithdrawalConfig.set, - { fiatThreshold: '500', cryptoThreshold: '1' }, + { fiatThreshold: '500', cryptoThreshold: '1', excludeRiskFlags: ['bonus_abuser'] }, { context: CTX }, ); expect(result).toMatchObject({ fiatThreshold: '500.00000000', cryptoThreshold: '1.00000000', + excludeRiskFlags: ['bonus_abuser'], updatedBy: CALLER_ID, }); const fetched = await call(router.autoWithdrawalConfig.get, {}, { context: CTX }); expect(fetched).toMatchObject({ fiatThreshold: '500.00000000', cryptoThreshold: '1.00000000', + excludeRiskFlags: ['bonus_abuser'], }); expect(audit.recordInTransaction).toHaveBeenCalledWith( expect.anything(), @@ -219,20 +232,36 @@ describe('wallet auto-withdrawal-config routes', () => { actorType: 'admin', action: 'wallet.auto_withdrawal_config.set', resourceType: 'auto_withdrawal_config', - before: { fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000' }, - after: { fiatThreshold: '500.00000000', cryptoThreshold: '1.00000000' }, + before: { + fiatThreshold: '0.00000000', + cryptoThreshold: '0.00000000', + // The beforeEach seed omits excludeRiskFlags, so the column's migration + // DEFAULT (the compliance floor) is what "before" captures here. + excludeRiskFlags: [ + 'high_risk', + 'bonus_abuser', + 'kyc_rejected', + 'withdrawal_review', + 'multi_account', + ], + }, + after: { + fiatThreshold: '500.00000000', + cryptoThreshold: '1.00000000', + excludeRiskFlags: ['bonus_abuser'], + }, }), ); }); it('end-to-end: after a super-admin sets the fiat threshold, a withdrawal below it auto-approves and one above it stays pending, both leaving an audit trail', async () => { const { router, audit, service } = routerWith(superAdminGuard(), { - autoWithdrawal: { enabled: true, excludeRiskFlags: [] }, + autoWithdrawal: { enabled: true }, }); await call( router.autoWithdrawalConfig.set, - { fiatThreshold: '100', cryptoThreshold: '0' }, + { fiatThreshold: '100', cryptoThreshold: '0', excludeRiskFlags: [] }, { context: CTX }, ); diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts index 770daea5..f1af254d 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts @@ -22,7 +22,7 @@ import { autoWithdrawalRule, walletAutoWithdrawalConfig, } from '../schema/index.js'; -import { WalletService } from '../service/wallet.service.js'; +import { WalletService, COMPLIANCE_FLOOR_TAGS } from '../service/wallet.service.js'; let db: TestDb; @@ -34,6 +34,10 @@ type ServiceOptions = { // default ('0'/'0' = auto-approval off until configured). fiatThreshold?: string; cryptoThreshold?: string; + // The DB row's tag-exclusion column (BF-319). Undefined = let the column's + // migration DEFAULT apply (the 5 compliance-floor tags); pass an explicit + // array (incl. []) to seed exactly that set instead. + excludeRiskFlags?: readonly TagKey[]; // Leaves the singleton config row unseeded, to exercise the row-missing // fail-closed path. skipConfigSeed?: boolean; @@ -46,6 +50,7 @@ async function makeService({ autoWithdrawal, fiatThreshold, cryptoThreshold, + excludeRiskFlags, skipConfigSeed = false, kycStatus = 'verified', directoryThrows = false, @@ -56,6 +61,7 @@ async function makeService({ singletonKey: 'global', fiatThreshold: fiatThreshold ?? '0', cryptoThreshold: cryptoThreshold ?? '0', + ...(excludeRiskFlags !== undefined ? { excludeRiskFlags: [...excludeRiskFlags] } : {}), }); } const events = makeEventBus(); @@ -77,9 +83,7 @@ async function makeService({ }), }); const platformConfig = mock( - autoWithdrawal - ? { autoWithdrawal: { enabled: true, excludeRiskFlags: [], ...autoWithdrawal } } - : {}, + autoWithdrawal ? { autoWithdrawal: { enabled: true, ...autoWithdrawal } } : {}, ); const svc = new WalletService({ drizzle: db.drizzle, @@ -312,8 +316,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { it('stays pending when the player carries an excluded risk flag', async () => { const { svc } = await makeService({ - autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + autoWithdrawal: {}, fiatThreshold: '1000', + excludeRiskFlags: ['bonus_abuser'], riskTags: ['bonus_abuser'], }); const w = await seedWallet(); @@ -353,13 +358,15 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { ), ), }); - await db.drizzle.db - .insert(walletAutoWithdrawalConfig) - .values({ singletonKey: 'global', fiatThreshold: '1000', cryptoThreshold: '0' }); + await db.drizzle.db.insert(walletAutoWithdrawalConfig).values({ + singletonKey: 'global', + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['withdrawal_review'], + }); const platformConfig = mock({ autoWithdrawal: { enabled: true, - excludeRiskFlags: ['withdrawal_review'], }, }); const payment = mock({ @@ -402,8 +409,12 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { it('auto-approves when the player carries only unrelated tags', async () => { const { svc } = await makeService({ - autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + autoWithdrawal: {}, fiatThreshold: '1000', + // Explicit empty array proves an unrelated-tag player only trips the + // non-removable compliance floor (which 'vip' is not part of), not an + // empty exclusion set colliding with the column's non-empty DB default. + excludeRiskFlags: [], riskTags: ['vip'], }); const w = await seedWallet(); @@ -420,8 +431,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { it('fails closed to pending when risk flags are configured but the tags port is unbound', async () => { const { svc } = await makeService({ - autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + autoWithdrawal: {}, fiatThreshold: '1000', + excludeRiskFlags: ['bonus_abuser'], riskTags: 'unbound', }); const w = await seedWallet(); @@ -436,6 +448,81 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { expect(result.status).toBe('pending'); }); + it('a floor tag stays pending even when the DB row explicitly sets excludeRiskFlags to an empty array (the floor cannot be zeroed out)', async () => { + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', + excludeRiskFlags: [], + riskTags: ['high_risk'], + }); + const w = await seedWallet(); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(result.status).toBe('pending'); + }); + + it("setAutoWithdrawalConfig cannot weaken the compliance floor - a player carrying a floor tag OMITTED from the admin's submitted excludeRiskFlags still stays pending", async () => { + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', + riskTags: ['bonus_abuser'], + }); + const submitted = COMPLIANCE_FLOOR_TAGS.filter((t) => t !== 'bonus_abuser'); + await svc.setAutoWithdrawalConfig(randomUUID(), { + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: [...submitted], + }); + const w = await seedWallet(); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(result.status).toBe('pending'); + }); + + it('a per-player auto_withdrawal_rule override that clears the threshold gate does NOT bypass the tag-exclusion gate - a floor-tagged player still stays pending', async () => { + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '10', + riskTags: ['kyc_rejected'], + }); + const w = await seedWallet(); + await svc.setAutoWithdrawalRule({ + userId: w.userId, + threshold: '1000', + reason: 'trusted, but still carries a floor tag', + createdBy: randomUUID(), + }); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + expect(result.status).toBe('pending'); + }); + + it('an upgraded install with a pre-existing config row (no explicit excludeRiskFlags) gets the migration DEFAULT floor tags without any admin edit', async () => { + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); + + const config = await svc.getAutoWithdrawalConfig(); + + expect(new Set(config.excludeRiskFlags)).toEqual(new Set(COMPLIANCE_FLOOR_TAGS)); + }); + it('stays pending on the large_amount heuristic regardless of the threshold', async () => { const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '100000' }); const w = await seedWallet(); @@ -850,10 +937,12 @@ describe('WalletService auto-withdrawal config methods (real PG)', () => { const updated = await svc.setAutoWithdrawalConfig(adminId, { fiatThreshold: '2500', cryptoThreshold: '3', + excludeRiskFlags: ['bonus_abuser'], }); expect(updated.fiatThreshold).toBe('2500.00000000'); expect(updated.cryptoThreshold).toBe('3.00000000'); + expect(updated.excludeRiskFlags).toEqual(['bonus_abuser']); expect(updated.updatedBy).toBe(adminId); expect(await svc.getAutoWithdrawalConfig()).toMatchObject({ fiatThreshold: '2500.00000000', diff --git a/packages/core/src/wallet/contract/index.ts b/packages/core/src/wallet/contract/index.ts index a4230a99..cba4e565 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { KycStatusSchema, MoneyAmountSchema, + TagKeySchema, TimestampSchema, UuidSchema, WalletRailSchema, @@ -137,6 +138,7 @@ export const WalletAutoWithdrawalConfigSchema = z.object({ id: UuidSchema, fiatThreshold: MoneyAmountSchema, cryptoThreshold: MoneyAmountSchema, + excludeRiskFlags: z.array(TagKeySchema), updatedBy: UuidSchema.nullable(), updatedAt: TimestampSchema, createdAt: TimestampSchema, @@ -155,6 +157,7 @@ const WalletAutoWithdrawalThresholdSchema = MoneyAmountSchema.refine( export const SetWalletAutoWithdrawalConfigInputSchema = z.object({ fiatThreshold: WalletAutoWithdrawalThresholdSchema, cryptoThreshold: WalletAutoWithdrawalThresholdSchema, + excludeRiskFlags: z.array(TagKeySchema), }); export const ApproveWithdrawalInputSchema = z.object({ withdrawalId: UuidSchema }); diff --git a/packages/core/src/wallet/drizzle/migrations/0006_lush_morg.sql b/packages/core/src/wallet/drizzle/migrations/0006_lush_morg.sql new file mode 100644 index 00000000..3c824db0 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0006_lush_morg.sql @@ -0,0 +1 @@ +ALTER TABLE "wallet_auto_withdrawal_config" ADD COLUMN "exclude_risk_flags" text[] DEFAULT ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[] NOT NULL; \ No newline at end of file diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0006_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..b7a155e6 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,638 @@ +{ + "id": "3d78e70f-0ee9-4049-87df-137db89043d2", + "prevId": "004d59f7-7a59-4271-9518-0a856c6b62c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_auto_withdrawal_config": { + "name": "wallet_auto_withdrawal_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "fiat_threshold": { + "name": "fiat_threshold", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "crypto_threshold": { + "name": "crypto_threshold", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "exclude_risk_flags": { + "name": "exclude_risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_auto_withdrawal_config_singletonKey_unique": { + "name": "wallet_auto_withdrawal_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(18, 8)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": ["deposit", "withdrawal", "bet", "win", "loss", "bonus", "tip", "gift", "rain"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index c988c830..7413a568 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1785409961214, "tag": "0005_gigantic_dust", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1785793698153, + "tag": "0006_lush_morg", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/schema/index.ts b/packages/core/src/wallet/schema/index.ts index 756f0d9f..e41a8178 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -15,6 +15,7 @@ import { WALLET_TRANSACTION_TYPES, type WalletRail, type WalletTransactionStatus, + type TagKey, } from '@openora/core/contracts'; // Enum values derive from the canonical tuples so the DB enum can never drift from @@ -131,6 +132,13 @@ export const walletAutoWithdrawalConfig = pgTable('wallet_auto_withdrawal_config singletonKey: text().notNull().unique().default('global'), fiatThreshold: decimal({ precision: 18, scale: 8 }).notNull(), cryptoThreshold: decimal({ precision: 18, scale: 8 }).notNull(), + excludeRiskFlags: text() + .array() + .$type() + .notNull() + .default( + sql`ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]`, + ), updatedBy: uuid(), updatedAt: timestamp({ withTimezone: true }) .notNull() diff --git a/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts index b86570f7..e134fd23 100644 --- a/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts +++ b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts @@ -5,7 +5,9 @@ import { walletAutoWithdrawalConfig } from '../schema/index.js'; * Idempotently seeds the singleton global auto-withdrawal config row. Zero * thresholds reproduce today's "off until a Super Admin configures it" * default (see wallet.service.ts's evaluateAutoApproval: a threshold <= 0 - * never auto-approves). + * never auto-approves). Does not set `excludeRiskFlags` - the column's + * migration-level DEFAULT (the five compliance-floor tags) applies on + * insert, same idempotent-no-op reasoning as the thresholds. */ export async function seedAutoWithdrawalConfig(db: DrizzleDb): Promise { await db diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 461d9325..989d8c8f 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -132,6 +132,17 @@ function namespacedIdempotencyKey(namespace: string, rawKey: string): string { // moneyToNumber is the sanctioned JS conversion point for this heuristic comparison only. const LARGE_WITHDRAWAL_THRESHOLD = '5000'; +// Non-removable compliance floor (BF-319): always unioned into the DB-editable +// wallet_auto_withdrawal_config.excludeRiskFlags, so a Super Admin can widen the +// exclusion set at runtime but can never narrow it below this set via `.set`. +export const COMPLIANCE_FLOOR_TAGS: readonly TagKey[] = [ + 'withdrawal_review', + 'kyc_rejected', + 'multi_account', + 'high_risk', + 'bonus_abuser', +]; + // ponytail: >=3 withdrawals in a 24h window flags velocity; a flat count, not a per-tier rule. const HIGH_FREQUENCY_WINDOW_MS = 24 * 60 * 60 * 1000; const HIGH_FREQUENCY_MIN_COUNT = 3; @@ -146,6 +157,7 @@ type AutoApprovalDecision = { thresholdSource: 'per-player' | 'global'; kycStatus: KycStatus; riskTagsEvaluated: TagKey[]; + effectiveExcludeTags: TagKey[]; dailyCapAmount: string | null; dailyCapCount: number | null; cumulativeAmountUsed: string; @@ -155,7 +167,7 @@ type AutoApprovalDecision = { // The pre-lock portion of the decision, resolvable without the advisory-locked cap read. type AutoApprovalGates = Pick< AutoApprovalDecision, - 'threshold' | 'thresholdSource' | 'kycStatus' | 'riskTagsEvaluated' + 'threshold' | 'thresholdSource' | 'kycStatus' | 'riskTagsEvaluated' | 'effectiveExcludeTags' >; function toAutoWithdrawalRuleDto(row: AutoWithdrawalRuleRow): AutoWithdrawalRule { @@ -175,6 +187,7 @@ function toAutoWithdrawalConfigDto(row: WalletAutoWithdrawalConfigRow): WalletAu id: row.id, fiatThreshold: row.fiatThreshold, cryptoThreshold: row.cryptoThreshold, + excludeRiskFlags: row.excludeRiskFlags, updatedBy: row.updatedBy, updatedAt: row.updatedAt.toISOString(), createdAt: row.createdAt.toISOString(), @@ -1029,12 +1042,18 @@ export class WalletService { return null; } - const riskTags = await this.autoApprovalRiskTags(userId, cfg.excludeRiskFlags); + // The DB-editable exclusion set can only ever be widened by a Super Admin - the + // compliance floor is unioned in so `.set` can never weaken it. + const effectiveExcludeTags = [ + ...new Set([...threshold.config.excludeRiskFlags, ...COMPLIANCE_FLOOR_TAGS]), + ]; + + const riskTags = await this.autoApprovalRiskTags(userId); // null = exclusions configured but the lookup port is unavailable => fail closed. if (riskTags === null) { return null; } - if (riskTags.some((t) => cfg.excludeRiskFlags.includes(t))) { + if (riskTags.some((t) => effectiveExcludeTags.includes(t))) { return null; } @@ -1048,13 +1067,18 @@ export class WalletService { thresholdSource: threshold.source, kycStatus, riskTagsEvaluated: riskTags, + effectiveExcludeTags, }; } private async resolveAutoThreshold( userId: User['id'], rail: WalletRail, - ): Promise<{ value: string; source: 'per-player' | 'global' } | null> { + ): Promise<{ + value: string; + source: 'per-player' | 'global'; + config: WalletAutoWithdrawalConfig; + } | null> { // Always read the global singleton first, even when a per-player override may end up // winning below - an unseeded install (missing row) must fail closed for EVERY player, // not just those without an override. getAutoWithdrawalConfig() throws when absent. @@ -1064,10 +1088,10 @@ export class WalletService { .from(autoWithdrawalRule) .where(eq(autoWithdrawalRule.userId, userId)); if (rule) { - return { value: rule.threshold, source: 'per-player' }; + return { value: rule.threshold, source: 'per-player', config }; } const global = rail === 'crypto' ? config.cryptoThreshold : config.fiatThreshold; - return { value: global, source: 'global' }; + return { value: global, source: 'global', config }; } // The row always exists in a properly-seeded install (seeded once, BF-211) - a @@ -1103,7 +1127,11 @@ export class WalletService { // the threshold change too, or the config could change with no audit trail (BF-211 review). async setAutoWithdrawalConfig( adminId: User['id'], - { fiatThreshold, cryptoThreshold }: { fiatThreshold: string; cryptoThreshold: string }, + { + fiatThreshold, + cryptoThreshold, + excludeRiskFlags, + }: { fiatThreshold: string; cryptoThreshold: string; excludeRiskFlags: TagKey[] }, meta?: ClientMeta, ): Promise { return this.drizzle.db.transaction(async (txn) => { @@ -1113,10 +1141,16 @@ export class WalletService { .where(eq(walletAutoWithdrawalConfig.singletonKey, 'global')); const rows = await txn .insert(walletAutoWithdrawalConfig) - .values({ singletonKey: 'global', fiatThreshold, cryptoThreshold, updatedBy: adminId }) + .values({ + singletonKey: 'global', + fiatThreshold, + cryptoThreshold, + excludeRiskFlags, + updatedBy: adminId, + }) .onConflictDoUpdate({ target: walletAutoWithdrawalConfig.singletonKey, - set: { fiatThreshold, cryptoThreshold, updatedBy: adminId }, + set: { fiatThreshold, cryptoThreshold, excludeRiskFlags, updatedBy: adminId }, }) .returning(); const config = toAutoWithdrawalConfigDto( @@ -1129,9 +1163,17 @@ export class WalletService { resourceType: 'auto_withdrawal_config', resourceId: config.id, before: before - ? { fiatThreshold: before.fiatThreshold, cryptoThreshold: before.cryptoThreshold } + ? { + fiatThreshold: before.fiatThreshold, + cryptoThreshold: before.cryptoThreshold, + excludeRiskFlags: before.excludeRiskFlags, + } : null, - after: { fiatThreshold: config.fiatThreshold, cryptoThreshold: config.cryptoThreshold }, + after: { + fiatThreshold: config.fiatThreshold, + cryptoThreshold: config.cryptoThreshold, + excludeRiskFlags: config.excludeRiskFlags, + }, ...meta, }); return config; @@ -1147,14 +1189,10 @@ export class WalletService { return summary?.kycStatus ?? null; } - // Active tag keys; [] when no exclusions configured or none carried; null when configured but the port is unbound (fail closed). - private async autoApprovalRiskTags( - userId: User['id'], - excludeRiskFlags: readonly TagKey[], - ): Promise { - if (excludeRiskFlags.length === 0) { - return []; - } + // Active tag keys; null when the port is unbound (fail closed) - the compliance floor + // (BF-319, see COMPLIANCE_FLOOR_TAGS) makes the caller's exclusion set permanently non-empty, + // so PLAYER_TAGS is effectively a hard dependency for auto-approval, not just an opt-in one. + private async autoApprovalRiskTags(userId: User['id']): Promise { if (!this.riskTags) { return null; } diff --git a/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts index 871d0ee0..5f07b418 100644 --- a/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts @@ -4,7 +4,9 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the BF-211 QA suite: autoWithdrawal enabled with NO // fiatThreshold/cryptoThreshold here (BF-211 moved those to the DB-backed // wallet_auto_withdrawal_config singleton row - AutoWithdrawalConfigSchema no longer -// has these fields at all). kyc.gateWithdrawals stays false so KYC-not-passing +// has these fields at all). excludeRiskFlags also moved off this static schema (BF-319) - +// the DB row's excludeRiskFlags column, seeded via seedAutoWithdrawalConfig's migration +// default, drives exclusion now. kyc.gateWithdrawals stays false so KYC-not-passing // scenarios exercise the auto-approval KYC gate, not the withdraw-time one. export default definePlugin({ id: 'qa-bf211-auto-withdrawal-config', @@ -15,7 +17,6 @@ export default definePlugin({ kyc: { gateWithdrawals: false }, autoWithdrawal: { enabled: true, - excludeRiskFlags: [], dailyCapAmount: '1000000', dailyCapCount: 1000, }, diff --git a/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts b/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts new file mode 100644 index 00000000..8d33d8ad --- /dev/null +++ b/packages/testing/src/__tests__/fixtures/qa-bf319-exclude-risk-flags-plugin.ts @@ -0,0 +1,26 @@ +import { definePlugin } from '@openora/core/server'; +import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; + +// PLATFORM_CONFIG overlay for the BF-319 QA suite (excludeRiskFlags moved off static +// config onto the DB-backed wallet_auto_withdrawal_config singleton's excludeRiskFlags +// column). autoWithdrawal.enabled with no fiatThreshold/cryptoThreshold/excludeRiskFlags +// here - all three now live exclusively on the DB row (BF-211 moved the thresholds, +// BF-319 moves the exclusion list). Caps set high so they never interfere with the +// tag-exclusion gate under test. kyc.gateWithdrawals stays false so a not-yet-verified +// player hits the auto-approval KYC gate, not the withdraw-time one. +export default definePlugin({ + id: 'qa-bf319-exclude-risk-flags', + dependsOn: ['identity'], + register(ctx) { + ctx.provide(PLATFORM_CONFIG, () => + definePlatformConfig({ + kyc: { gateWithdrawals: false }, + autoWithdrawal: { + enabled: true, + dailyCapAmount: '1000000', + dailyCapCount: 1000, + }, + }), + ); + }, +}); diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index f6c6d8e2..2a110e2e 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -3,9 +3,12 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (the fiat // threshold - '2' - is BF-211's DB-backed wallet_auto_withdrawal_config singleton, seeded by the -// test file's own beforeAll, not this static config), high_risk/bonus_abuser excluded, caps set -// high. kyc.gateWithdrawals stays false so the KYC-not-passing scenario hits the auto-approval KYC -// gate, not the withdraw-time one. Append last so this binding wins. +// test file's own beforeAll, not this static config), caps set high. high_risk/bonus_abuser +// exclusion is BF-319's non-removable compliance floor (COMPLIANCE_FLOOR_TAGS, +// wallet.service.ts), always unioned in from the DB row regardless of what this static config +// sets - no excludeRiskFlags field here any more. kyc.gateWithdrawals stays false so the +// KYC-not-passing scenario hits the auto-approval KYC gate, not the withdraw-time one. Append +// last so this binding wins. export default definePlugin({ id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], @@ -15,7 +18,6 @@ export default definePlugin({ kyc: { gateWithdrawals: false }, autoWithdrawal: { enabled: true, - excludeRiskFlags: ['high_risk', 'bonus_abuser'], dailyCapAmount: '1000', dailyCapCount: 100, }, diff --git a/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts b/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts index a79fd6fc..29f28b94 100644 --- a/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts +++ b/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts @@ -219,6 +219,7 @@ describe('BF-211 authz: auto-withdrawal-config is super-admin only (real DB-back const setRes = await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '1', cryptoThreshold: '1', + excludeRiskFlags: [], }); expect(setRes.status).toBe(200); }); @@ -230,6 +231,7 @@ describe('BF-211 authz: auto-withdrawal-config is super-admin only (real DB-back const setRes = await paymentsManager.put('/wallet/auto-withdrawal-config', { fiatThreshold: '1', cryptoThreshold: '1', + excludeRiskFlags: [], }); expect(setRes.status).toBe(403); }); @@ -241,6 +243,7 @@ describe('BF-211 authz: auto-withdrawal-config is super-admin only (real DB-back const setRes = await plainAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '1', cryptoThreshold: '1', + excludeRiskFlags: [], }); expect(setRes.status).toBe(403); }); @@ -252,6 +255,7 @@ describe('BF-211 authz: auto-withdrawal-config is super-admin only (real DB-back const setRes = await bootstrapAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '1', cryptoThreshold: '1', + excludeRiskFlags: [], }); expect(setRes.status).toBe(200); }); @@ -267,6 +271,7 @@ describe('BF-211 validation: threshold input', () => { const res = await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '-1', cryptoThreshold: '0', + excludeRiskFlags: [], }); expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); @@ -276,6 +281,7 @@ describe('BF-211 validation: threshold input', () => { const res = await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '0', cryptoThreshold: 'not-a-number', + excludeRiskFlags: [], }); expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); @@ -286,6 +292,7 @@ describe('BF-211 validation: threshold input', () => { await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '-999', cryptoThreshold: '0', + excludeRiskFlags: [], }); const after = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); expect(after).toMatchObject({ @@ -300,6 +307,7 @@ describe('BF-211 happy path: set -> immediate GET -> below/above threshold -> au const setRes = await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '100', cryptoThreshold: '0.01', + excludeRiskFlags: [], }); expect(setRes.status).toBe(200); const set = await readJson(setRes); @@ -314,6 +322,7 @@ describe('BF-211 happy path: set -> immediate GET -> below/above threshold -> au await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '100', cryptoThreshold: '0.01', + excludeRiskFlags: [], }); const configAuditRes = await superAdmin.get( @@ -383,6 +392,7 @@ describe('BF-211 immediate effect: two consecutive config changes in one run', ( await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '10', cryptoThreshold: '0', + excludeRiskFlags: [], }); const first = await readJson( await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), @@ -392,6 +402,7 @@ describe('BF-211 immediate effect: two consecutive config changes in one run', ( await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '30', cryptoThreshold: '0', + excludeRiskFlags: [], }); const second = await readJson( await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), @@ -401,6 +412,7 @@ describe('BF-211 immediate effect: two consecutive config changes in one run', ( await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '5', cryptoThreshold: '0', + excludeRiskFlags: [], }); const third = await readJson( await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), @@ -414,6 +426,7 @@ describe('BF-211 precedence: per-player auto_withdrawal_rule vs the global confi await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '10', cryptoThreshold: '0', + excludeRiskFlags: [], }); const email = `bf211-rule-above-${randomUUID()}@e2e.test`; const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); @@ -440,6 +453,7 @@ describe('BF-211 precedence: per-player auto_withdrawal_rule vs the global confi await superAdmin.put('/wallet/auto-withdrawal-config', { fiatThreshold: '1000', cryptoThreshold: '0', + excludeRiskFlags: [], }); const email = `bf211-rule-below-${randomUUID()}@e2e.test`; const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); @@ -506,6 +520,7 @@ describe('BF-211 fail-closed: the singleton config row is missing', () => { const res = await client.put('/wallet/auto-withdrawal-config', { fiatThreshold: '100', cryptoThreshold: '1', + excludeRiskFlags: [], }); expect(res.status).toBe(200); const body = await readJson(res); @@ -520,21 +535,28 @@ describe('BF-211 fail-closed: the singleton config row is missing', () => { }); describe('BF-211 regression spot-check: static platform-config.yaml AutoWithdrawalConfigSchema', () => { - it('still accepts the fields that stay static: enabled, excludeRiskFlags, dailyCapAmount, dailyCapCount', () => { + it('still accepts the fields that stay static: enabled, dailyCapAmount, dailyCapCount', () => { const parsed = AutoWithdrawalConfigSchema.parse({ enabled: true, - excludeRiskFlags: ['high_risk'], dailyCapAmount: '5000', dailyCapCount: 10, }); expect(parsed).toMatchObject({ enabled: true, - excludeRiskFlags: ['high_risk'], dailyCapAmount: '5000', dailyCapCount: 10, }); }); + it('BF-319: excludeRiskFlags moved to the DB-backed wallet_auto_withdrawal_config singleton and is no longer a static schema field', () => { + expect(() => + AutoWithdrawalConfigSchema.parse({ + enabled: true, + excludeRiskFlags: ['high_risk'], + }), + ).toThrow('excludeRiskFlags'); + }); + it('BUG: fiatThreshold/cryptoThreshold were removed from the static schema but two shipped e2e fixtures still set them, breaking pnpm build and bootTestApp at runtime', () => { expect(() => definePlatformConfig({ diff --git a/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts new file mode 100644 index 00000000..b9cadccb --- /dev/null +++ b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { eq } from 'drizzle-orm'; +import { loadExtensions, DRIZZLE, type Container } from '@openora/core/server'; +import { user } from '@openora/core/pam/schema/identity'; +import { adminRole, adminRoleAssignment } from '@openora/core/iam/schema'; +import { walletAutoWithdrawalConfig } from '@openora/core/wallet/schema'; +import { + setupTestDb, + bootTestApp, + asPlayer, + seedMinimal, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +/** + * Independent QA verification of BF-319 (wallet auto-withdrawal tag-exclusion list moved + * from static platform config to the DB-backed `wallet_auto_withdrawal_config` singleton's + * `excludeRiskFlags` column, runtime-editable via `autoWithdrawalConfig.set`, always UNIONED + * with a non-removable server-side `COMPLIANCE_FLOOR_TAGS` set at evaluation time), driven + * through the REAL app (bootTestApp: real Hono + oRPC + Postgres + Redis + real tag module) + * rather than the implementer's own unit/router-level tests (which mock PLAYER_TAGS / + * AdminGuard and never exercise a real tag assignment through the real tag module + real + * IAM RBAC resolver end to end) - same rationale as the sibling BF-211 QA suite this file + * extends. + */ + +let db: TestDb; +let appMain: TestApp; +let superAdmin: TestClient; + +// oxlint-disable-next-line typescript/no-explicit-any -- ad-hoc JSON shape assertions in tests +async function readJson(res: Response): Promise { + return res.json(); +} + +async function registerAndMaterializePlayer(app: TestApp['app'], email: string) { + const registerRes = await app.request('/identity/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password: 'password123', name: 'BF-319 QA Player' }), + }); + if (!registerRes.ok) { + throw new Error(`register failed (${registerRes.status}): ${await registerRes.text()}`); + } + const client = await asPlayer(app, { email }); + const profileRes = await client.get('/profile'); + if (!profileRes.ok) { + throw new Error( + `profile materialize failed (${profileRes.status}): ${await profileRes.text()}`, + ); + } + const profile = (await profileRes.json()) as { id: string; userId: string }; + return { client, playerId: profile.id, userId: profile.userId }; +} + +async function verifyKyc(admin: TestClient, userId: string) { + const res = await admin.post(`/compliance/players/${userId}/kyc/override`, { + status: 'approved', + reason: 'BF-319 QA fixture verification', + }); + if (res.status !== 200) { + throw new Error(`verifyKyc failed (${res.status}): ${await res.text()}`); + } +} + +async function assignTag(admin: TestClient, playerId: string, tagKey: string) { + const res = await admin.post(`/player/${playerId}/player-tag`, { + tagKey, + assignReason: 'BF-319 QA fixture - manual risk-tag seed', + assignActor: 'manual', + }); + if (res.status !== 200) { + throw new Error(`assignTag(${tagKey}) failed (${res.status}): ${await res.text()}`); + } + return readJson(res); +} + +// Bootstrap a super-admin the same way the sibling BF-211 suite does: stamp the static +// user.role='admin' coarse gate, then attach the real DB-backed super-admin role +// assignment - both are required before the DB-backed permission resolver becomes +// authoritative (see BF-211 suite's own doc comment on AdminGuard.assert's two-stage gate). +async function makeSuperAdmin(app: TestApp['app'], container: Container, email: string) { + const { client, userId } = await registerAndMaterializePlayer(app, email); + const drizzle = container.get(DRIZZLE).db; + await drizzle.update(user).set({ role: 'admin' }).where(eq(user.id, userId)); + const [role] = await drizzle.select().from(adminRole).where(eq(adminRole.key, 'super-admin')); + if (!role) { + throw new Error("makeSuperAdmin: no seeded admin_role with key='super-admin'"); + } + await drizzle + .insert(adminRoleAssignment) + .values({ userId, roleId: role.id }) + .onConflictDoNothing(); + return { client, userId }; +} + +async function setConfig(input: { + fiatThreshold: string; + cryptoThreshold: string; + excludeRiskFlags: string[]; +}) { + const res = await superAdmin.put('/wallet/auto-withdrawal-config', input); + if (res.status !== 200) { + throw new Error(`setConfig failed (${res.status}): ${await res.text()}`); + } + return readJson(res); +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + const basePlugins = await loadExtensions(); + + const fixture = fileURLToPath( + new URL('./fixtures/qa-bf319-exclude-risk-flags-plugin.ts', import.meta.url), + ); + + appMain = await bootTestApp({ + plugins: [...basePlugins, { id: 'qa-bf319-exclude-risk-flags', path: fixture }], + databaseUrl: db.url, + }); + + await seedMinimal(appMain.container, { playerCount: 0 }); + + const superAdminEmail = `bf319-super-admin-${randomUUID()}@e2e.test`; + const created = await makeSuperAdmin(appMain.app, appMain.container, superAdminEmail); + superAdmin = created.client; +}, 60_000); + +afterAll(async () => { + await appMain?.close(); + await db?.dispose(); +}); + +describe('BF-319 upgraded install: the migration DEFAULT floor tags gate before any admin ever edits excludeRiskFlags', () => { + it('a singleton row seeded with NO explicit excludeRiskFlags (column DEFAULT applies) gates a floor-tagged player and lets a clean player through', async () => { + // Directly insert the singleton row the way `seedAutoWithdrawalConfig` does in + // production - a positive threshold so the tag gate (not the threshold gate) is what's + // under test, and deliberately OMITTING excludeRiskFlags so the column's migration-level + // DEFAULT (the 5 compliance-floor tags) applies, matching a genuine "just upgraded, + // never touched by an admin" install. Delete any pre-existing row first: this file's + // apps share one physical test database across the whole `test:integration` run (per + // @openora/testing's AGENTS.md), so a sibling suite may have already seeded/edited the + // singleton before this file runs - this test needs the row to be genuinely + // untouched-since-insert to prove the DEFAULT, not whatever a previous file left behind. + const configDb = appMain.container.get(DRIZZLE).db; + await configDb.delete(walletAutoWithdrawalConfig); + await configDb + .insert(walletAutoWithdrawalConfig) + .values({ singletonKey: 'global', fiatThreshold: '1000', cryptoThreshold: '0' }); + + const getRes = await superAdmin.get('/wallet/auto-withdrawal-config'); + expect(getRes.status).toBe(200); + const got = await readJson(getRes); + expect(new Set(got.excludeRiskFlags)).toEqual( + new Set(['high_risk', 'bonus_abuser', 'kyc_rejected', 'withdrawal_review', 'multi_account']), + ); + + const taggedEmail = `bf319-preexisting-tagged-${randomUUID()}@e2e.test`; + const tagged = await registerAndMaterializePlayer(appMain.app, taggedEmail); + await verifyKyc(superAdmin, tagged.userId); + await assignTag(superAdmin, tagged.playerId, 'high_risk'); + await tagged.client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const taggedRes = await readJson( + await tagged.client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(taggedRes.status).toBe('pending'); + + const cleanEmail = `bf319-preexisting-clean-${randomUUID()}@e2e.test`; + const clean = await registerAndMaterializePlayer(appMain.app, cleanEmail); + await verifyKyc(superAdmin, clean.userId); + await clean.client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const cleanRes = await readJson( + await clean.client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(cleanRes.status).toBe('completed'); + }); +}); + +describe('BF-319 immediate effect: PUT excludeRiskFlags, GET reflects it right away, no restart needed', () => { + it('a super-admin widens the exclusion set with a non-floor tag and GET reflects it immediately', async () => { + const set = await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['vip'], + }); + expect(set.excludeRiskFlags).toEqual(['vip']); + + const got = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + expect(got.excludeRiskFlags).toEqual(['vip']); + }); + + it('a player carrying the newly-configured tag is now excluded from auto-approval', async () => { + await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', excludeRiskFlags: ['vip'] }); + + const email = `bf319-widened-tag-${randomUUID()}@e2e.test`; + const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await assignTag(superAdmin, playerId, 'vip'); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('pending'); + }); +}); + +describe('BF-319 compliance floor: submitting excludeRiskFlags without a floor tag does not weaken enforcement', () => { + it('an admin submits an empty excludeRiskFlags array, GET reflects the empty array, but a floor-tagged player still routes to review', async () => { + const set = await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: [], + }); + expect(set.excludeRiskFlags).toEqual([]); + const got = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + expect(got.excludeRiskFlags).toEqual([]); + + const email = `bf319-empty-array-floor-${randomUUID()}@e2e.test`; + const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await assignTag(superAdmin, playerId, 'high_risk'); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('pending'); + }); + + it("an admin's excludeRiskFlags submission that omits a specific floor tag still leaves that tag excluded (union, never narrowed)", async () => { + // Submit every floor tag EXCEPT kyc_rejected, plus a widen tag - proves the floor + // tag missing from the admin's own list is still enforced via the server-side union. + await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['withdrawal_review', 'multi_account', 'high_risk', 'bonus_abuser'], + }); + + const email = `bf319-omitted-floor-tag-${randomUUID()}@e2e.test`; + const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await assignTag(superAdmin, playerId, 'kyc_rejected'); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('pending'); + }); +}); + +describe('BF-319 effective set = floor UNION configured: excluded regardless of amount when otherwise eligible', () => { + it('a player carrying a configured (non-floor) tag stays pending even for a tiny, well-under-threshold amount', async () => { + await setConfig({ + fiatThreshold: '5000', + cryptoThreshold: '0', + excludeRiskFlags: ['vip'], + }); + + const email = `bf319-tiny-amount-excluded-${randomUUID()}@e2e.test`; + const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await assignTag(superAdmin, playerId, 'vip'); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '1', currency: 'USD' }), + ); + expect(res.status).toBe('pending'); + }); +}); + +describe('BF-319 regression (no weakening): a player with no excluded tag, under threshold, KYC-approved still auto-approves', () => { + it('auto-approves exactly as BF-211 behaved, with the exclusion set configured but not held by this player', async () => { + await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['vip'], + }); + + const email = `bf319-no-regression-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('completed'); + }); +}); + +describe('BF-319 per-player override does not bypass the tag-exclusion gate', () => { + it('a per-player threshold override far above the amount still leaves a floor-tagged player pending', async () => { + await setConfig({ + fiatThreshold: '10', + cryptoThreshold: '0', + excludeRiskFlags: [], + }); + + const email = `bf319-rule-override-floor-tag-${randomUUID()}@e2e.test`; + const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await assignTag(superAdmin, playerId, 'multi_account'); + await superAdmin.put(`/wallet/auto-withdrawal-rules/${userId}`, { + threshold: '10000', + reason: 'BF-319 QA: trusted-looking player, still carries a floor tag', + }); + + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('pending'); + }); +}); + +describe('BF-319 audit trail', () => { + it("setAutoWithdrawalConfig's audit record captures the excludeRiskFlags before/after diff", async () => { + await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', excludeRiskFlags: ['vip'] }); + const set = await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['vip', 'large_depositor'], + }); + + const auditRes = await superAdmin.get( + `/audit/logs?action=wallet.auto_withdrawal_config.set&limit=1`, + ); + const audit = await readJson(auditRes); + expect(audit.items.length).toBeGreaterThanOrEqual(1); + const entry = audit.items[0]; + expect(entry.before.excludeRiskFlags).toEqual(['vip']); + expect(entry.after.excludeRiskFlags).toEqual(['vip', 'large_depositor']); + expect(set.excludeRiskFlags).toEqual(['vip', 'large_depositor']); + }); + + it('the auto-approval audit record includes effectiveExcludeTags (floor UNION configured), not just riskTagsEvaluated', async () => { + await setConfig({ + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: ['large_depositor'], + }); + + const email = `bf319-audit-effective-tags-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const res = await readJson( + await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), + ); + expect(res.status).toBe('completed'); + + const auditRes = await superAdmin.get( + `/audit/logs?resourceId=${res.transactionId}&action=wallet.withdrawal.auto_approved`, + ); + const audit = await readJson(auditRes); + expect(audit.items.length).toBeGreaterThanOrEqual(1); + const after = audit.items[0].after; + expect(new Set(after.effectiveExcludeTags)).toEqual( + new Set([ + 'large_depositor', + 'withdrawal_review', + 'kyc_rejected', + 'multi_account', + 'high_risk', + 'bonus_abuser', + ]), + ); + expect(after.riskTagsEvaluated).toEqual([]); + }); +}); + +describe('BF-319 validation: excludeRiskFlags is now a required input field', () => { + it('a PUT omitting excludeRiskFlags entirely is rejected with a validation error, not silently defaulted', async () => { + const before = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + + const res = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '42', + cryptoThreshold: '0', + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + + const after = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + expect(after).toMatchObject({ + fiatThreshold: before.fiatThreshold, + cryptoThreshold: before.cryptoThreshold, + excludeRiskFlags: before.excludeRiskFlags, + }); + }); + + it('a PUT with excludeRiskFlags containing an unknown tag key is rejected', async () => { + const res = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '1', + cryptoThreshold: '1', + excludeRiskFlags: ['not_a_real_tag_key'], + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); +}); From e8bdf09c2aa4af13f2ea858574a17d8335d69e29 Mon Sep 17 00:00:00 2001 From: Marek Chmielowski Date: Tue, 4 Aug 2026 10:12:40 +0200 Subject: [PATCH 2/5] fix(wallet): remove non-removable compliance floor from auto-withdrawal exclusions (BF-319) Product owner rejected the hardcoded COMPLIANCE_FLOOR_TAGS union added in c981b27: the ticket AC requires the exclusion list be fully Super-Admin configurable with no floor. excludeRiskFlags is now the DB value verbatim; the 5-tag migration default remains only as a starting value, not an enforced minimum. --- packages/core/src/wallet/AGENTS.md | 2 +- .../wallet-auto-withdrawal.int.test.ts | 54 ++++++++----------- .../core/src/wallet/service/wallet.service.ts | 31 ++++------- 3 files changed, 33 insertions(+), 54 deletions(-) diff --git a/packages/core/src/wallet/AGENTS.md b/packages/core/src/wallet/AGENTS.md index 255eac37..b0440938 100644 --- a/packages/core/src/wallet/AGENTS.md +++ b/packages/core/src/wallet/AGENTS.md @@ -24,7 +24,7 @@ Queue `riskTags` are DB-backed heuristics, not a risk engine: `large_amount` (>= After the hold commits, `maybeAutoApprove` decides WHO approves - system or manual queue - never whether the request succeeds. Strictly fail-closed: it NEVER throws out of `withdraw()`; any error or ambiguous branch leaves the row `pending`. - Gates (ALL must hold): `autoWithdrawal.enabled`; a resolved positive threshold for the withdrawal's RAIL (per-player `auto_withdrawal_rule` wins over the global `wallet_auto_withdrawal_config` singleton row - DB-backed, Super-Admin-editable at runtime via `autoWithdrawalConfig.set`, no redeploy needed, BF-211) with `amount <= threshold`; KYC status in the pass-set INDEPENDENT of `kyc.gateWithdrawals` (missing directory/summary => pending); no active tag intersecting the effective exclusion set (via `PLAYER_TAGS`; port unbound while the set is non-empty => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded. The per-player rule overrides only the THRESHOLD gate - it never bypasses the tag-exclusion gate. -- Tag-exclusion set (BF-319): `wallet_auto_withdrawal_config.excludeRiskFlags` (DB column, Super-Admin-editable via `autoWithdrawalConfig.set`, admin submits the full replacement array every call) UNIONED with `COMPLIANCE_FLOOR_TAGS` (`wallet.service.ts` - `withdrawal_review`, `kyc_rejected`, `multi_account`, `high_risk`, `bonus_abuser`), a hardcoded, non-removable server-side floor. The union means the DB column can only ever widen exclusions, never narrow them below the floor - `.set` with an array omitting a floor tag still leaves that tag excluded. The floor makes the exclusion set always non-empty in practice, so `PLAYER_TAGS` is now an effectively-required dependency for auto-approval to ever succeed, not just when an operator opts into exclusions. +- Tag-exclusion set (BF-319): `wallet_auto_withdrawal_config.excludeRiskFlags` (DB column, Super-Admin-editable via `autoWithdrawalConfig.set`, admin submits the full replacement array every call) is the entire, sole source of truth for the exclusion set - no tag is hardcoded as permanently excluded, and a Super Admin can clear it to `[]` to disable all tag-based exclusion. `PLAYER_TAGS` is only required when the exclusion set is non-empty - `autoApprovalRiskTags` short-circuits to `[]` (skipping the port call) when `excludeRiskFlags` is empty, so an unbound port only fails closed while exclusions are actually configured. The migration-level column `DEFAULT` (`withdrawal_review`, `kyc_rejected`, `multi_account`, `high_risk`, `bonus_abuser`) is a starting value for upgraded installs, not an enforced floor - it is exactly as editable as any tag an admin adds later. - Only the daily-cap check runs under the per-user advisory lock (atomic with the `processing` flip); the other gates run before it. - System-actor marker: `reviewedBy = null`, `reviewReason = 'auto-approved'`. Reuses `flipToProcessing`/`settleApproved` - the same two-phase sequence as manual approve. - Every auto-approval writes an `AUDIT_WRITER` entry (`actorType: 'system'`) capturing the full rationale BEFORE the PSP call, so the AML/SAR trail survives a PSP failure. diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts index f1af254d..72650b56 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts @@ -22,10 +22,21 @@ import { autoWithdrawalRule, walletAutoWithdrawalConfig, } from '../schema/index.js'; -import { WalletService, COMPLIANCE_FLOOR_TAGS } from '../service/wallet.service.js'; +import { WalletService } from '../service/wallet.service.js'; let db: TestDb; +// The migration-level column DEFAULT (0006_lush_morg.sql) - a starting value for +// upgraded installs, not a server-enforced floor. A Super Admin can clear or +// change any of it via setAutoWithdrawalConfig. +const MIGRATION_DEFAULT_EXCLUDE_RISK_FLAGS: readonly TagKey[] = [ + 'high_risk', + 'bonus_abuser', + 'kyc_rejected', + 'withdrawal_review', + 'multi_account', +]; + type ServiceOptions = { autoWithdrawal?: Partial; // The global fiat/crypto thresholds are DB-backed (BF-211), not part of @@ -35,7 +46,7 @@ type ServiceOptions = { fiatThreshold?: string; cryptoThreshold?: string; // The DB row's tag-exclusion column (BF-319). Undefined = let the column's - // migration DEFAULT apply (the 5 compliance-floor tags); pass an explicit + // migration DEFAULT apply (the 5-tag starting value); pass an explicit // array (incl. []) to seed exactly that set instead. excludeRiskFlags?: readonly TagKey[]; // Leaves the singleton config row unseeded, to exercise the row-missing @@ -411,9 +422,8 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000', - // Explicit empty array proves an unrelated-tag player only trips the - // non-removable compliance floor (which 'vip' is not part of), not an - // empty exclusion set colliding with the column's non-empty DB default. + // Explicit empty array proves the exclusion set is genuinely empty here, + // not the column's non-empty DB default leaking through. excludeRiskFlags: [], riskTags: ['vip'], }); @@ -448,36 +458,16 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { expect(result.status).toBe('pending'); }); - it('a floor tag stays pending even when the DB row explicitly sets excludeRiskFlags to an empty array (the floor cannot be zeroed out)', async () => { + it('setAutoWithdrawalConfig clearing excludeRiskFlags to [] lets a high_risk-tagged player auto-approve', async () => { const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000', - excludeRiskFlags: [], riskTags: ['high_risk'], }); - const w = await seedWallet(); - - const result = await svc.withdraw({ - userId: w.userId, - amount: '40', - currency: 'USD', - ...NO_CLIENT_META, - }); - - expect(result.status).toBe('pending'); - }); - - it("setAutoWithdrawalConfig cannot weaken the compliance floor - a player carrying a floor tag OMITTED from the admin's submitted excludeRiskFlags still stays pending", async () => { - const { svc } = await makeService({ - autoWithdrawal: {}, - fiatThreshold: '1000', - riskTags: ['bonus_abuser'], - }); - const submitted = COMPLIANCE_FLOOR_TAGS.filter((t) => t !== 'bonus_abuser'); await svc.setAutoWithdrawalConfig(randomUUID(), { fiatThreshold: '1000', cryptoThreshold: '0', - excludeRiskFlags: [...submitted], + excludeRiskFlags: [], }); const w = await seedWallet(); @@ -488,10 +478,10 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { ...NO_CLIENT_META, }); - expect(result.status).toBe('pending'); + expect(result.status).toBe('completed'); }); - it('a per-player auto_withdrawal_rule override that clears the threshold gate does NOT bypass the tag-exclusion gate - a floor-tagged player still stays pending', async () => { + it('a per-player auto_withdrawal_rule override that clears the threshold gate does NOT bypass the tag-exclusion gate - a tag-excluded player still stays pending', async () => { const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '10', @@ -501,7 +491,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { await svc.setAutoWithdrawalRule({ userId: w.userId, threshold: '1000', - reason: 'trusted, but still carries a floor tag', + reason: 'trusted, but still carries an excluded tag', createdBy: randomUUID(), }); @@ -515,12 +505,12 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { expect(result.status).toBe('pending'); }); - it('an upgraded install with a pre-existing config row (no explicit excludeRiskFlags) gets the migration DEFAULT floor tags without any admin edit', async () => { + it('an upgraded install with a pre-existing config row (no explicit excludeRiskFlags) gets the migration DEFAULT tags without any admin edit', async () => { const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const config = await svc.getAutoWithdrawalConfig(); - expect(new Set(config.excludeRiskFlags)).toEqual(new Set(COMPLIANCE_FLOOR_TAGS)); + expect(new Set(config.excludeRiskFlags)).toEqual(new Set(MIGRATION_DEFAULT_EXCLUDE_RISK_FLAGS)); }); it('stays pending on the large_amount heuristic regardless of the threshold', async () => { diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 989d8c8f..b72f1927 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -132,17 +132,6 @@ function namespacedIdempotencyKey(namespace: string, rawKey: string): string { // moneyToNumber is the sanctioned JS conversion point for this heuristic comparison only. const LARGE_WITHDRAWAL_THRESHOLD = '5000'; -// Non-removable compliance floor (BF-319): always unioned into the DB-editable -// wallet_auto_withdrawal_config.excludeRiskFlags, so a Super Admin can widen the -// exclusion set at runtime but can never narrow it below this set via `.set`. -export const COMPLIANCE_FLOOR_TAGS: readonly TagKey[] = [ - 'withdrawal_review', - 'kyc_rejected', - 'multi_account', - 'high_risk', - 'bonus_abuser', -]; - // ponytail: >=3 withdrawals in a 24h window flags velocity; a flat count, not a per-tier rule. const HIGH_FREQUENCY_WINDOW_MS = 24 * 60 * 60 * 1000; const HIGH_FREQUENCY_MIN_COUNT = 3; @@ -1042,13 +1031,9 @@ export class WalletService { return null; } - // The DB-editable exclusion set can only ever be widened by a Super Admin - the - // compliance floor is unioned in so `.set` can never weaken it. - const effectiveExcludeTags = [ - ...new Set([...threshold.config.excludeRiskFlags, ...COMPLIANCE_FLOOR_TAGS]), - ]; + const effectiveExcludeTags = threshold.config.excludeRiskFlags; - const riskTags = await this.autoApprovalRiskTags(userId); + const riskTags = await this.autoApprovalRiskTags(userId, effectiveExcludeTags); // null = exclusions configured but the lookup port is unavailable => fail closed. if (riskTags === null) { return null; @@ -1189,10 +1174,14 @@ export class WalletService { return summary?.kycStatus ?? null; } - // Active tag keys; null when the port is unbound (fail closed) - the compliance floor - // (BF-319, see COMPLIANCE_FLOOR_TAGS) makes the caller's exclusion set permanently non-empty, - // so PLAYER_TAGS is effectively a hard dependency for auto-approval, not just an opt-in one. - private async autoApprovalRiskTags(userId: User['id']): Promise { + // Active tag keys; [] when no exclusions configured or none carried; null when configured but the port is unbound (fail closed). + private async autoApprovalRiskTags( + userId: User['id'], + excludeRiskFlags: readonly TagKey[], + ): Promise { + if (excludeRiskFlags.length === 0) { + return []; + } if (!this.riskTags) { return null; } From 6104ae150ab2bf9ed4484568a06b0940f4a040f8 Mon Sep 17 00:00:00 2001 From: Marek Chmielowski Date: Tue, 4 Aug 2026 10:24:15 +0200 Subject: [PATCH 3/5] test(wallet): update BF-319 QA e2e suite for the reverted compliance floor The QA e2e suite (qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts) still asserted the non-removable COMPLIANCE_FLOOR_TAGS union removed in the prior commit. Rewrite it to match the corrected design: an empty excludeRiskFlags disables all tag-based exclusion, an admin's submitted list is authoritative with no server-side widening, and the auto-approval audit record's effectiveExcludeTags equals the DB value verbatim. Also cleans up stray "compliance floor" wording left in adjacent comments (router test, seed script, sibling e2e fixture). --- ...-auto-withdrawal-config.router.int.test.ts | 9 +-- .../seed/seed-auto-withdrawal-config.ts | 4 +- ...st-wallet-auto-withdrawal-config-plugin.ts | 9 ++- ...-withdrawal-exclude-risk-flags.e2e.test.ts | 72 +++++++++---------- 4 files changed, 45 insertions(+), 49 deletions(-) diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts index 4dd038ba..0c356bce 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts @@ -74,9 +74,10 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial({ getActiveTagKeys: vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, []]))), }); @@ -236,7 +237,7 @@ describe('wallet auto-withdrawal-config routes', () => { fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000', // The beforeEach seed omits excludeRiskFlags, so the column's migration - // DEFAULT (the compliance floor) is what "before" captures here. + // DEFAULT (a starting value, not an enforced floor) is what "before" captures here. excludeRiskFlags: [ 'high_risk', 'bonus_abuser', diff --git a/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts index e134fd23..218a0c37 100644 --- a/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts +++ b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts @@ -6,8 +6,8 @@ import { walletAutoWithdrawalConfig } from '../schema/index.js'; * thresholds reproduce today's "off until a Super Admin configures it" * default (see wallet.service.ts's evaluateAutoApproval: a threshold <= 0 * never auto-approves). Does not set `excludeRiskFlags` - the column's - * migration-level DEFAULT (the five compliance-floor tags) applies on - * insert, same idempotent-no-op reasoning as the thresholds. + * migration-level DEFAULT (five starting tags, not an enforced floor) + * applies on insert, same idempotent-no-op reasoning as the thresholds. */ export async function seedAutoWithdrawalConfig(db: DrizzleDb): Promise { await db diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts index 2a110e2e..de59e615 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-config-plugin.ts @@ -4,11 +4,10 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (the fiat // threshold - '2' - is BF-211's DB-backed wallet_auto_withdrawal_config singleton, seeded by the // test file's own beforeAll, not this static config), caps set high. high_risk/bonus_abuser -// exclusion is BF-319's non-removable compliance floor (COMPLIANCE_FLOOR_TAGS, -// wallet.service.ts), always unioned in from the DB row regardless of what this static config -// sets - no excludeRiskFlags field here any more. kyc.gateWithdrawals stays false so the -// KYC-not-passing scenario hits the auto-approval KYC gate, not the withdraw-time one. Append -// last so this binding wins. +// exclusion (BF-319) comes from that same DB row's excludeRiskFlags column (the migration +// DEFAULT, left untouched by this suite) - no excludeRiskFlags field here any more. +// kyc.gateWithdrawals stays false so the KYC-not-passing scenario hits the auto-approval KYC +// gate, not the withdraw-time one. Append last so this binding wins. export default definePlugin({ id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], diff --git a/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts index b9cadccb..b89190e2 100644 --- a/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts +++ b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts @@ -19,13 +19,15 @@ import { /** * Independent QA verification of BF-319 (wallet auto-withdrawal tag-exclusion list moved * from static platform config to the DB-backed `wallet_auto_withdrawal_config` singleton's - * `excludeRiskFlags` column, runtime-editable via `autoWithdrawalConfig.set`, always UNIONED - * with a non-removable server-side `COMPLIANCE_FLOOR_TAGS` set at evaluation time), driven - * through the REAL app (bootTestApp: real Hono + oRPC + Postgres + Redis + real tag module) - * rather than the implementer's own unit/router-level tests (which mock PLAYER_TAGS / - * AdminGuard and never exercise a real tag assignment through the real tag module + real - * IAM RBAC resolver end to end) - same rationale as the sibling BF-211 QA suite this file - * extends. + * `excludeRiskFlags` column, runtime-editable via `autoWithdrawalConfig.set`). The column + * DEFAULT (5 tags, set at the migration level) is only a starting value for upgraded + * installs - the DB value is the entire, sole source of truth at evaluation time, verbatim, + * with no server-side floor unioned in; a Super Admin can clear it to `[]` to disable all + * tag-based exclusion. Driven through the REAL app (bootTestApp: real Hono + oRPC + Postgres + * + Redis + real tag module) rather than the implementer's own unit/router-level tests (which + * mock PLAYER_TAGS / AdminGuard and never exercise a real tag assignment through the real tag + * module + real IAM RBAC resolver end to end) - same rationale as the sibling BF-211 QA suite + * this file extends. */ let db: TestDb; @@ -139,13 +141,14 @@ afterAll(async () => { await db?.dispose(); }); -describe('BF-319 upgraded install: the migration DEFAULT floor tags gate before any admin ever edits excludeRiskFlags', () => { - it('a singleton row seeded with NO explicit excludeRiskFlags (column DEFAULT applies) gates a floor-tagged player and lets a clean player through', async () => { +describe('BF-319 upgraded install: the migration DEFAULT tags gate before any admin ever edits excludeRiskFlags', () => { + it('a singleton row seeded with NO explicit excludeRiskFlags (column DEFAULT applies) gates a tagged player and lets a clean player through', async () => { // Directly insert the singleton row the way `seedAutoWithdrawalConfig` does in // production - a positive threshold so the tag gate (not the threshold gate) is what's // under test, and deliberately OMITTING excludeRiskFlags so the column's migration-level - // DEFAULT (the 5 compliance-floor tags) applies, matching a genuine "just upgraded, - // never touched by an admin" install. Delete any pre-existing row first: this file's + // DEFAULT (5 tags, a starting value only - not an enforced floor) applies, matching a + // genuine "just upgraded, never touched by an admin" install. Delete any pre-existing + // row first: this file's // apps share one physical test database across the whole `test:integration` run (per // @openora/testing's AGENTS.md), so a sibling suite may have already seeded/edited the // singleton before this file runs - this test needs the row to be genuinely @@ -185,7 +188,7 @@ describe('BF-319 upgraded install: the migration DEFAULT floor tags gate before }); describe('BF-319 immediate effect: PUT excludeRiskFlags, GET reflects it right away, no restart needed', () => { - it('a super-admin widens the exclusion set with a non-floor tag and GET reflects it immediately', async () => { + it('a super-admin widens the exclusion set with a tag and GET reflects it immediately', async () => { const set = await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', @@ -212,8 +215,8 @@ describe('BF-319 immediate effect: PUT excludeRiskFlags, GET reflects it right a }); }); -describe('BF-319 compliance floor: submitting excludeRiskFlags without a floor tag does not weaken enforcement', () => { - it('an admin submits an empty excludeRiskFlags array, GET reflects the empty array, but a floor-tagged player still routes to review', async () => { +describe('BF-319 full admin control: excludeRiskFlags is the sole source of truth, no server-side floor', () => { + it('an admin submits an empty excludeRiskFlags array, GET reflects the empty array, and a previously-excluded tag no longer blocks auto-approval', async () => { const set = await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', @@ -223,7 +226,7 @@ describe('BF-319 compliance floor: submitting excludeRiskFlags without a floor t const got = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); expect(got.excludeRiskFlags).toEqual([]); - const email = `bf319-empty-array-floor-${randomUUID()}@e2e.test`; + const email = `bf319-empty-array-clears-exclusion-${randomUUID()}@e2e.test`; const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); await verifyKyc(superAdmin, userId); await assignTag(superAdmin, playerId, 'high_risk'); @@ -231,19 +234,21 @@ describe('BF-319 compliance floor: submitting excludeRiskFlags without a floor t const res = await readJson( await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), ); - expect(res.status).toBe('pending'); + expect(res.status).toBe('completed'); }); - it("an admin's excludeRiskFlags submission that omits a specific floor tag still leaves that tag excluded (union, never narrowed)", async () => { - // Submit every floor tag EXCEPT kyc_rejected, plus a widen tag - proves the floor - // tag missing from the admin's own list is still enforced via the server-side union. + it("an admin's excludeRiskFlags submission is authoritative - a tag omitted from the submitted list is no longer excluded, even one that used to be part of the migration DEFAULT", async () => { + // Submit every migration-DEFAULT tag EXCEPT kyc_rejected - proves the admin's own list, + // not any server-side union, decides what's excluded. kyc_rejected is a plain KYC-status + // gate elsewhere (autoApprovalKycStatus), so keep this player's KYC status passing and + // rely solely on the tag to isolate the tag-exclusion gate under test. await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', excludeRiskFlags: ['withdrawal_review', 'multi_account', 'high_risk', 'bonus_abuser'], }); - const email = `bf319-omitted-floor-tag-${randomUUID()}@e2e.test`; + const email = `bf319-omitted-tag-no-longer-excluded-${randomUUID()}@e2e.test`; const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); await verifyKyc(superAdmin, userId); await assignTag(superAdmin, playerId, 'kyc_rejected'); @@ -251,12 +256,12 @@ describe('BF-319 compliance floor: submitting excludeRiskFlags without a floor t const res = await readJson( await client.post('/wallet/withdraw', { amount: '50', currency: 'USD' }), ); - expect(res.status).toBe('pending'); + expect(res.status).toBe('completed'); }); }); -describe('BF-319 effective set = floor UNION configured: excluded regardless of amount when otherwise eligible', () => { - it('a player carrying a configured (non-floor) tag stays pending even for a tiny, well-under-threshold amount', async () => { +describe('BF-319 effective set = the DB value verbatim: excluded regardless of amount when otherwise eligible', () => { + it('a player carrying a configured tag stays pending even for a tiny, well-under-threshold amount', async () => { await setConfig({ fiatThreshold: '5000', cryptoThreshold: '0', @@ -295,20 +300,20 @@ describe('BF-319 regression (no weakening): a player with no excluded tag, under }); describe('BF-319 per-player override does not bypass the tag-exclusion gate', () => { - it('a per-player threshold override far above the amount still leaves a floor-tagged player pending', async () => { + it('a per-player threshold override far above the amount still leaves a tag-excluded player pending', async () => { await setConfig({ fiatThreshold: '10', cryptoThreshold: '0', - excludeRiskFlags: [], + excludeRiskFlags: ['multi_account'], }); - const email = `bf319-rule-override-floor-tag-${randomUUID()}@e2e.test`; + const email = `bf319-rule-override-excluded-tag-${randomUUID()}@e2e.test`; const { client, userId, playerId } = await registerAndMaterializePlayer(appMain.app, email); await verifyKyc(superAdmin, userId); await assignTag(superAdmin, playerId, 'multi_account'); await superAdmin.put(`/wallet/auto-withdrawal-rules/${userId}`, { threshold: '10000', - reason: 'BF-319 QA: trusted-looking player, still carries a floor tag', + reason: 'BF-319 QA: trusted-looking player, still carries an excluded tag', }); await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); @@ -339,7 +344,7 @@ describe('BF-319 audit trail', () => { expect(set.excludeRiskFlags).toEqual(['vip', 'large_depositor']); }); - it('the auto-approval audit record includes effectiveExcludeTags (floor UNION configured), not just riskTagsEvaluated', async () => { + it('the auto-approval audit record includes effectiveExcludeTags as the DB value verbatim, not just riskTagsEvaluated', async () => { await setConfig({ fiatThreshold: '1000', cryptoThreshold: '0', @@ -361,16 +366,7 @@ describe('BF-319 audit trail', () => { const audit = await readJson(auditRes); expect(audit.items.length).toBeGreaterThanOrEqual(1); const after = audit.items[0].after; - expect(new Set(after.effectiveExcludeTags)).toEqual( - new Set([ - 'large_depositor', - 'withdrawal_review', - 'kyc_rejected', - 'multi_account', - 'high_risk', - 'bonus_abuser', - ]), - ); + expect(after.effectiveExcludeTags).toEqual(['large_depositor']); expect(after.riskTagsEvaluated).toEqual([]); }); }); From c77688a53b0f6aae4e569addc24c590bf762b2bb Mon Sep 17 00:00:00 2001 From: Marek Chmielowski Date: Tue, 4 Aug 2026 11:26:25 +0200 Subject: [PATCH 4/5] refactor(wallet): drop redundant withdraw() race-order comment The evaluateWithdrawalRequested race-avoidance rationale is already documented on evaluateWithdrawalRequested itself (tag-evaluation.service.ts) and in wallet/AGENTS.md; the duplicate inline copy at the call site added no information not already carried by the call order and those docs. --- packages/core/src/wallet/service/wallet.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index b72f1927..f7d7ee56 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -562,11 +562,6 @@ export class WalletService { throw new InsufficientBalanceError(current.balance, amount); } - // Synchronous, transactional withdrawal_review evaluation - on this SAME txn, so - // the assignment (if any) commits atomically with this withdrawal request and is - // guaranteed visible before maybeAutoApprove reads risk tags below. Must run - // BEFORE that read; never move this after the transaction returns (see - // TagEvaluationService.evaluateWithdrawalRequested for the race this closes). if (this.tagEvaluationCommands) { await this.tagEvaluationCommands.evaluateWithdrawalRequested(txn, { userId, amount }); } From cfa44ad6d171e5c78e344328e2649d519fbeeac4 Mon Sep 17 00:00:00 2001 From: Marek Chmielowski Date: Tue, 4 Aug 2026 12:17:53 +0200 Subject: [PATCH 5/5] fix(testing): isolate wallet-ledger e2e's auto-withdrawal-config row from sibling suites The singleton wallet_auto_withdrawal_config row is shared across every e2e file in one test:integration run. qa-bf319's suite leaves excludeRiskFlags overwritten (eg to ['large_depositor']), and wallet-ledger-auto-withdrawal's onConflictDoNothing seed silently kept that leftover instead of the migration DEFAULT whenever it ran after qa-bf319 - Vitest's file order isn't guaranteed, so this was order-dependent and flaked CI post-merge. Delete the row before reseeding, same pattern qa-bf319's own first test already uses. --- .../wallet-ledger-auto-withdrawal.e2e.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts index 58b8b863..442033a7 100644 --- a/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts +++ b/packages/testing/src/__tests__/wallet-ledger-auto-withdrawal.e2e.test.ts @@ -116,10 +116,16 @@ beforeAll(async () => { // physical database with appDefault, so seeding it once here (fiatThreshold '2', matching // what the two fixtures used to set statically) covers both single-shot gate and daily-cap // scenarios below - the seed default ('0'/'0') would otherwise leave auto-approval off. - await seedAutoWithdrawalConfig(appDefault.container.get(DRIZZLE).db); - await appDefault.container - .get(DRIZZLE) - .db.update(walletAutoWithdrawalConfig) + // Delete any pre-existing row first: this suite's excluded-risk-tag scenario below relies + // on the column's migration DEFAULT for excludeRiskFlags, and this file's apps share one + // physical test database with every other e2e file in the run (per @openora/testing's + // AGENTS.md) - a sibling suite (eg BF-319's) may have already left the singleton with an + // admin-edited excludeRiskFlags value that no longer includes the tag this suite tests. + const configDb = appDefault.container.get(DRIZZLE).db; + await configDb.delete(walletAutoWithdrawalConfig); + await seedAutoWithdrawalConfig(configDb); + await configDb + .update(walletAutoWithdrawalConfig) .set({ fiatThreshold: '2' }) .where(eq(walletAutoWithdrawalConfig.singletonKey, 'global'));