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..b0440938 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) 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-config.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts index c8a1b742..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 @@ -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,13 @@ 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 +93,7 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial(platformConfig) : undefined, + riskTags, }); const router = createWalletRouter( service, @@ -111,6 +120,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 +147,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 +161,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 +175,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 +187,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 +199,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 +233,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 (a starting value, not an enforced 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..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 @@ -26,6 +26,17 @@ 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 @@ -34,6 +45,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-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 // fail-closed path. skipConfigSeed?: boolean; @@ -46,6 +61,7 @@ async function makeService({ autoWithdrawal, fiatThreshold, cryptoThreshold, + excludeRiskFlags, skipConfigSeed = false, kycStatus = 'verified', directoryThrows = false, @@ -56,6 +72,7 @@ async function makeService({ singletonKey: 'global', fiatThreshold: fiatThreshold ?? '0', cryptoThreshold: cryptoThreshold ?? '0', + ...(excludeRiskFlags !== undefined ? { excludeRiskFlags: [...excludeRiskFlags] } : {}), }); } const events = makeEventBus(); @@ -77,9 +94,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 +327,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 +369,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 +420,11 @@ 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 the exclusion set is genuinely empty here, + // not the column's non-empty DB default leaking through. + excludeRiskFlags: [], riskTags: ['vip'], }); const w = await seedWallet(); @@ -420,8 +441,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 +458,61 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { expect(result.status).toBe('pending'); }); + it('setAutoWithdrawalConfig clearing excludeRiskFlags to [] lets a high_risk-tagged player auto-approve', async () => { + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', + riskTags: ['high_risk'], + }); + await svc.setAutoWithdrawalConfig(randomUUID(), { + fiatThreshold: '1000', + cryptoThreshold: '0', + excludeRiskFlags: [], + }); + const w = await seedWallet(); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + + 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 tag-excluded 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 an excluded 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 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(MIGRATION_DEFAULT_EXCLUDE_RISK_FLAGS)); + }); + 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 +927,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..218a0c37 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 (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/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 461d9325..f7d7ee56 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -146,6 +146,7 @@ type AutoApprovalDecision = { thresholdSource: 'per-player' | 'global'; kycStatus: KycStatus; riskTagsEvaluated: TagKey[]; + effectiveExcludeTags: TagKey[]; dailyCapAmount: string | null; dailyCapCount: number | null; cumulativeAmountUsed: string; @@ -155,7 +156,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 +176,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(), @@ -560,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 }); } @@ -1029,12 +1026,14 @@ export class WalletService { return null; } - const riskTags = await this.autoApprovalRiskTags(userId, cfg.excludeRiskFlags); + const effectiveExcludeTags = threshold.config.excludeRiskFlags; + + const riskTags = await this.autoApprovalRiskTags(userId, effectiveExcludeTags); // 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 +1047,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 +1068,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 +1107,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 +1121,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 +1143,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; 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..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 @@ -3,8 +3,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), high_risk/bonus_abuser excluded, caps set -// high. kyc.gateWithdrawals stays false so the KYC-not-passing scenario hits the auto-approval KYC +// test file's own beforeAll, not this static config), caps set high. high_risk/bonus_abuser +// 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', @@ -15,7 +17,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..b89190e2 --- /dev/null +++ b/packages/testing/src/__tests__/qa-bf319-wallet-auto-withdrawal-exclude-risk-flags.e2e.test.ts @@ -0,0 +1,402 @@ +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`). 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; +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 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 (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 + // 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 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 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', + 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-clears-exclusion-${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('completed'); + }); + + 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-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'); + 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 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', + 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 tag-excluded player pending', async () => { + await setConfig({ + fiatThreshold: '10', + cryptoThreshold: '0', + excludeRiskFlags: ['multi_account'], + }); + + 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 an excluded 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 as the DB value verbatim, 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(after.effectiveExcludeTags).toEqual(['large_depositor']); + 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); + }); +}); 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'));