diff --git a/docs/catalog.json b/docs/catalog.json index 3dabed31..5aabf89a 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -292,6 +292,7 @@ "tables": [ "auto_withdrawal_rule", "wallet", + "wallet_auto_withdrawal_config", "wallet_deposit_address", "wallet_transaction" ], @@ -300,6 +301,7 @@ "wallet.delete", "wallet.deposit", "wallet.get", + "wallet.get", "wallet.getAddress", "wallet.getBalance", "wallet.list", @@ -307,6 +309,7 @@ "wallet.listTransactions", "wallet.reject", "wallet.set", + "wallet.set", "wallet.webhook", "wallet.withdraw" ] @@ -1455,6 +1458,10 @@ "name": "SetPlayerLimitInputSchema", "file": "packages/core/src/compliance/contract/rg.ts" }, + { + "name": "SetWalletAutoWithdrawalConfigInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "SignedMoneyAmountSchema", "file": "packages/core/src/contracts/schemas/common.ts" @@ -1563,6 +1570,10 @@ "name": "VerifyPasswordResetOtpInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "WalletAutoWithdrawalConfigSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "WalletBalanceSchema", "file": "packages/core/src/wallet/contract/index.ts" @@ -1715,6 +1726,7 @@ "GET /players/{playerId}", "GET /profile", "GET /tag-rule", + "GET /wallet/auto-withdrawal-config", "GET /wallet/auto-withdrawal-rules/{userId}", "GET /wallet/balance", "GET /wallet/transactions", @@ -1790,6 +1802,7 @@ "PUT /compliance/players/{userId}/limits", "PUT /iam/roles/{roleId}/permissions", "PUT /tag-rule/{tagKey}", + "PUT /wallet/auto-withdrawal-config", "PUT /wallet/auto-withdrawal-rules/{userId}" ] } diff --git a/packages/core/package.json b/packages/core/package.json index 6e131bab..6130bfe8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -439,6 +439,11 @@ "import": "./dist/wallet/contract/index.js", "default": "./dist/wallet/contract/index.js" }, + "./wallet/seed": { + "types": "./dist/wallet/seed/index.d.ts", + "import": "./dist/wallet/seed/index.js", + "default": "./dist/wallet/seed/index.js" + }, "./audit/migrate": { "types": "./dist/audit/migrate.d.ts", "import": "./dist/audit/migrate.js", diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 7855ab04..613e652c 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -17,7 +17,8 @@ export type DirectAuditAction = | 'compliance.kyc.bulk_approve' | 'wallet.withdrawal.auto_approved' | 'wallet.auto_withdrawal_rule.set' - | 'wallet.auto_withdrawal_rule.deleted'; + | 'wallet.auto_withdrawal_rule.deleted' + | 'wallet.auto_withdrawal_config.set'; // Every value the audit `action` column legitimately holds: a cross-module domain // event topic (recorded by the audit plugin's subscriptions) or a direct admin diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index c63848c6..2ad1ee90 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -51,9 +51,6 @@ export const KycConfigSchema = z export const AutoWithdrawalConfigSchema = z .object({ enabled: z.boolean().default(false), - /** Fiat-rail auto-approval ceiling; a per-player rule overrides it. */ - fiatThreshold: MoneyAmountSchema.optional(), - cryptoThreshold: MoneyAmountSchema.optional(), /** 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. */ diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index 1be19069..833a376d 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -20,6 +20,7 @@ export const statement = { 'tag-rule': ['view', 'update'] as const, tag: ['view', 'create', 'delete'] as const, 'chat-room': ['view', 'create', 'update', 'delete'] as const, + 'auto-withdrawal-config': ['view', 'update'] as const, } as const; export const ac = createAccessControl(statement); diff --git a/packages/core/src/wallet/AGENTS.md b/packages/core/src/wallet/AGENTS.md index edcf4848..06a70bb0 100644 --- a/packages/core/src/wallet/AGENTS.md +++ b/packages/core/src/wallet/AGENTS.md @@ -23,11 +23,12 @@ 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 `fiatThreshold`/`cryptoThreshold`) 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 `excludeRiskFlags` (via `PLAYER_TAGS`; port unbound while flags are configured => pending); neither risk heuristic; trailing-24h `dailyCapAmount`/`dailyCapCount` not exceeded. - 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. -- Crypto uses the SAME mechanism and gates as fiat (BF-211), safe because `cryptoThreshold` is absent by default - unset/zero resolves to "never auto-approve", so an upgrade never silently activates it. The per-player rule is a single global column, not rail-aware. +- Crypto uses the SAME mechanism and gates as fiat (BF-211), safe because `wallet_auto_withdrawal_config.cryptoThreshold` defaults to `'0'` via the seed (same for `fiatThreshold`) - zero resolves to "never auto-approve", so an upgrade never silently activates it. The per-player rule is a single global column, not rail-aware. +- `wallet_auto_withdrawal_config` is a true singleton (`singletonKey` DB-unique, always `'global'`), normally created by `seedAutoWithdrawalConfig()`. The READ path (`getAutoWithdrawalConfig()`, used by `resolveAutoThreshold`/`maybeAutoApprove`) throws `AutoWithdrawalConfigNotFoundError` if the row is somehow absent rather than silently defaulting - `maybeAutoApprove`'s existing outer try/catch treats that the same as any other unexpected error (fail closed to pending); a withdrawal must never silently create config. The admin WRITE path (`setAutoWithdrawalConfig`, `PUT /wallet/auto-withdrawal-config`) is an upsert (`onConflictDoUpdate` on `singletonKey`), so a Super Admin can self-heal a missing row on an unseeded install through the existing route with zero new surface; `GET`/`.getAutoWithdrawalConfig()` never creates it, only `.set`/`PUT` does. ## Idempotency and races diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.test.ts new file mode 100644 index 00000000..19eb8671 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { call, ORPCError } from '@orpc/server'; +import type { AdminGuard } from '@openora/core/server'; +import type { + AdminPlayerSummary, + AdminUserDirectory, + PaymentAdapter, + PaymentWebhookVerifier, + PlatformConfig, +} from '@openora/core/contracts'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { + mock, + makeEventBus, + testContext, + makeAuditWriter, + makeAdminGuard, + NO_CLIENT_META, +} from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { wallet, walletTransaction, walletAutoWithdrawalConfig } from '../schema/index.js'; +import { createWalletRouter } from '../router/index.js'; +import { WalletService } from '../service/wallet.service.js'; + +const CTX = testContext(); +const CALLER_ID = '9a2f7c11-0000-4000-8000-0000000000bb'; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrate]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.delete(walletTransaction); + await db.drizzle.db.delete(wallet); + await db.drizzle.db.delete(walletAutoWithdrawalConfig); + await db.drizzle.db + .insert(walletAutoWithdrawalConfig) + .values({ singletonKey: 'global', fiatThreshold: '0', cryptoThreshold: '0' }); +}); + +const superAdminGuard = () => + makeAdminGuard({ caller: { userId: CALLER_ID, role: 'super-admin' } }); + +// The 'auto-withdrawal-config' resource is granted only to super-admin (see +// permissions.ts statement + default-admin-roles.ts) - both `admin` and +// `payments-manager` (which only has `withdrawal` RW, a different resource) +// must be denied here. +const adminDenyingGuard = () => + makeAdminGuard({ + deny: ['auto-withdrawal-config:view', 'auto-withdrawal-config:update'], + caller: { userId: CALLER_ID, role: 'admin' }, + }); + +const paymentsManagerDenyingGuard = () => + makeAdminGuard({ + deny: ['auto-withdrawal-config:view', 'auto-withdrawal-config:update'], + caller: { userId: CALLER_ID, role: 'payments-manager' }, + }); + +function routerWith(adminGuard: AdminGuard, platformConfig?: Partial) { + const audit = makeAuditWriter(); + const directory = mock({ + lookupPlayers: vi.fn(async (ids: string[]) => + ids.map((userId) => + mock({ userId, username: 'player', kycStatus: 'verified' }), + ), + ), + }); + const service = new WalletService({ + drizzle: db.drizzle, + events: makeEventBus(), + payment: mock({ + processWithdrawal: vi.fn(async () => ({ + externalId: randomUUID(), + status: 'completed' as const, + })), + }), + audit, + directory, + platformConfig: platformConfig ? mock(platformConfig) : undefined, + }); + const router = createWalletRouter( + service, + adminGuard, + audit, + mock({}), + mock({ verify: vi.fn().mockReturnValue(false) }), + ); + return { router, audit, service }; +} + +async function seedPlayerWallet(overrides: Partial = {}) { + const [row] = await db.drizzle.db + .insert(wallet) + .values({ userId: randomUUID(), balance: '100000', currency: 'USD', ...overrides }) + .returning(); + return row!; +} + +describe('wallet auto-withdrawal-config routes', () => { + it('get: returns the seeded singleton row for an authorized (super-admin) caller', async () => { + const { router } = routerWith(superAdminGuard()); + + const result = await call(router.autoWithdrawalConfig.get, {}, { context: CTX }); + + expect(result).toMatchObject({ fiatThreshold: '0.00000000', cryptoThreshold: '0.00000000' }); + }); + + it('get: rejects payments-manager', async () => { + const { router } = routerWith(paymentsManagerDenyingGuard()); + + await expect( + call(router.autoWithdrawalConfig.get, {}, { context: CTX }), + ).rejects.toBeInstanceOf(ORPCError); + }); + + it('get: rejects plain admin', async () => { + const { router } = routerWith(adminDenyingGuard()); + + await expect( + call(router.autoWithdrawalConfig.get, {}, { context: CTX }), + ).rejects.toBeInstanceOf(ORPCError); + }); + + it('set: rejects payments-manager and writes nothing', async () => { + const { router, audit } = routerWith(paymentsManagerDenyingGuard()); + + await expect( + call( + router.autoWithdrawalConfig.set, + { fiatThreshold: '500', cryptoThreshold: '1' }, + { context: CTX }, + ), + ).rejects.toBeInstanceOf(ORPCError); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('set: rejects plain admin and writes nothing', async () => { + const { router, audit } = routerWith(adminDenyingGuard()); + + await expect( + call( + router.autoWithdrawalConfig.set, + { fiatThreshold: '500', cryptoThreshold: '1' }, + { context: CTX }, + ), + ).rejects.toBeInstanceOf(ORPCError); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('set: rejects a negative fiat threshold', async () => { + const { router } = routerWith(superAdminGuard()); + + await expect( + call( + router.autoWithdrawalConfig.set, + { fiatThreshold: '-1', cryptoThreshold: '1' }, + { context: CTX }, + ), + ).rejects.toThrow(); + }); + + it('set: rejects a negative crypto threshold', async () => { + const { router } = routerWith(superAdminGuard()); + + await expect( + call( + router.autoWithdrawalConfig.set, + { fiatThreshold: '1', cryptoThreshold: '-1' }, + { context: CTX }, + ), + ).rejects.toThrow(); + }); + + it('set: super-admin updates both thresholds, 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' }, + { context: CTX }, + ); + + expect(result).toMatchObject({ + fiatThreshold: '500.00000000', + cryptoThreshold: '1.00000000', + updatedBy: CALLER_ID, + }); + const fetched = await call(router.autoWithdrawalConfig.get, {}, { context: CTX }); + expect(fetched).toMatchObject({ + fiatThreshold: '500.00000000', + cryptoThreshold: '1.00000000', + }); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: CALLER_ID, + 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' }, + }), + ); + }); + + 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: [] }, + }); + + await call( + router.autoWithdrawalConfig.set, + { fiatThreshold: '100', cryptoThreshold: '0' }, + { context: CTX }, + ); + + const below = await seedPlayerWallet(); + const belowResult = await service.withdraw({ + userId: below.userId, + amount: '40', + currency: 'USD', + ...NO_CLIENT_META, + }); + expect(belowResult.status).toBe('completed'); + + const above = await seedPlayerWallet(); + const aboveResult = await service.withdraw({ + userId: above.userId, + amount: '400', + currency: 'USD', + ...NO_CLIENT_META, + }); + expect(aboveResult.status).toBe('pending'); + + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'wallet.auto_withdrawal_config.set' }), + ); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'wallet.withdrawal.auto_approved', + resourceId: belowResult.transactionId, + }), + ); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.test.ts index 1223c66d..d07844dd 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.test.ts @@ -16,24 +16,48 @@ import type { import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock, makeEventBus, NO_CLIENT_META, makeAuditWriter } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; -import { wallet, walletTransaction, autoWithdrawalRule } from '../schema/index.js'; +import { + wallet, + walletTransaction, + autoWithdrawalRule, + walletAutoWithdrawalConfig, +} from '../schema/index.js'; import { WalletService } from '../service/wallet.service.js'; let db: TestDb; type ServiceOptions = { autoWithdrawal?: Partial; + // The global fiat/crypto thresholds are DB-backed (BF-211), not part of + // PlatformConfig any more - seeded into wallet_auto_withdrawal_config here + // whenever `autoWithdrawal` is passed, mirroring the production seed + // default ('0'/'0' = auto-approval off until configured). + fiatThreshold?: string; + cryptoThreshold?: string; + // Leaves the singleton config row unseeded, to exercise the row-missing + // fail-closed path. + skipConfigSeed?: boolean; kycStatus?: KycStatus | null; directoryThrows?: boolean; riskTags?: TagKey[] | 'unbound'; }; -function makeService({ +async function makeService({ autoWithdrawal, + fiatThreshold, + cryptoThreshold, + skipConfigSeed = false, kycStatus = 'verified', directoryThrows = false, riskTags = [], }: ServiceOptions = {}) { + if (autoWithdrawal && !skipConfigSeed) { + await db.drizzle.db.insert(walletAutoWithdrawalConfig).values({ + singletonKey: 'global', + fiatThreshold: fiatThreshold ?? '0', + cryptoThreshold: cryptoThreshold ?? '0', + }); + } const events = makeEventBus(); const psp = { processWithdrawal: vi.fn(async () => ({ @@ -103,13 +127,16 @@ afterAll(async () => { beforeEach(async () => { await db.drizzle.db.execute( - sql`TRUNCATE ${walletTransaction}, ${autoWithdrawalRule}, ${wallet} RESTART IDENTITY CASCADE`, + sql`TRUNCATE ${walletTransaction}, ${autoWithdrawalRule}, ${walletAutoWithdrawalConfig}, ${wallet} RESTART IDENTITY CASCADE`, ); }); describe('WalletService.withdraw auto-approval (real PG)', () => { it('auto-approves a fiat withdrawal that clears every gate, marking the system as reviewer', async () => { - const { svc, psp, audit, events } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc, psp, audit, events } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', + }); const w = await seedWallet(); const result = await svc.withdraw({ @@ -138,7 +165,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { resourceType: 'wallet_transaction', resourceId: result.transactionId, after: expect.objectContaining({ - threshold: '1000', + threshold: '1000.00000000', thresholdSource: 'global', kycStatus: 'verified', cumulativeCountUsed: 0, @@ -148,7 +175,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('reverts the flip to pending when the audit write fails instead of stranding it processing', async () => { - const { svc, audit, psp } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc, audit, psp } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); audit.record.mockRejectedValueOnce(new Error('audit sink down')); const w = await seedWallet(); @@ -170,7 +197,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('holds the funds even when auto-approval declines', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '10' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '10' }); const w = await seedWallet({ balance: '100' }); const result = await svc.withdraw({ @@ -186,7 +213,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when the amount exceeds the configured threshold', async () => { - const { svc, psp } = makeService({ autoWithdrawal: { fiatThreshold: '10' } }); + const { svc, psp } = await makeService({ autoWithdrawal: {}, fiatThreshold: '10' }); const w = await seedWallet(); const result = await svc.withdraw({ @@ -201,7 +228,25 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when no threshold is configured at all', async () => { - const { svc } = makeService({ autoWithdrawal: {} }); + const { svc } = await makeService({ autoWithdrawal: {} }); + 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('stays pending, without throwing, when the auto-withdrawal config row is missing entirely', async () => { + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', + skipConfigSeed: true, + }); const w = await seedWallet(); const result = await svc.withdraw({ @@ -215,7 +260,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when auto-withdrawal is not enabled', async () => { - const { svc, audit } = makeService(); + const { svc, audit } = await makeService(); const w = await seedWallet(); const result = await svc.withdraw({ @@ -230,8 +275,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when KYC is not passing even though gateWithdrawals is off', async () => { - const { svc } = makeService({ - autoWithdrawal: { fiatThreshold: '1000' }, + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', kycStatus: 'pending', }); const w = await seedWallet(); @@ -247,8 +293,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('fails closed to pending when the directory throws during the KYC check', async () => { - const { svc } = makeService({ - autoWithdrawal: { fiatThreshold: '1000' }, + const { svc } = await makeService({ + autoWithdrawal: {}, + fiatThreshold: '1000', directoryThrows: true, }); const w = await seedWallet(); @@ -264,8 +311,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when the player carries an excluded risk flag', async () => { - const { svc } = makeService({ - autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] }, + const { svc } = await makeService({ + autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + fiatThreshold: '1000', riskTags: ['bonus_abuser'], }); const w = await seedWallet(); @@ -305,10 +353,12 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { ), ), }); + await db.drizzle.db + .insert(walletAutoWithdrawalConfig) + .values({ singletonKey: 'global', fiatThreshold: '1000', cryptoThreshold: '0' }); const platformConfig = mock({ autoWithdrawal: { enabled: true, - fiatThreshold: '1000', excludeRiskFlags: ['withdrawal_review'], }, }); @@ -351,8 +401,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('auto-approves when the player carries only unrelated tags', async () => { - const { svc } = makeService({ - autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] }, + const { svc } = await makeService({ + autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + fiatThreshold: '1000', riskTags: ['vip'], }); const w = await seedWallet(); @@ -368,8 +419,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 } = makeService({ - autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] }, + const { svc } = await makeService({ + autoWithdrawal: { excludeRiskFlags: ['bonus_abuser'] }, + fiatThreshold: '1000', riskTags: 'unbound', }); const w = await seedWallet(); @@ -385,7 +437,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending on the large_amount heuristic regardless of the threshold', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '100000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '100000' }); const w = await seedWallet(); const result = await svc.withdraw({ @@ -399,7 +451,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending once the wallet hits the high_frequency velocity flag', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const w = await seedWallet(); await db.drizzle.db.insert(walletTransaction).values([ { walletId: w.id, type: 'withdrawal', amount: '1', currency: 'USD', status: 'completed' }, @@ -417,7 +469,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('ignores withdrawals outside the velocity window', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const w = await seedWallet(); const outsideWindow = new Date(Date.now() - 48 * 60 * 60 * 1000); await db.drizzle.db.insert(walletTransaction).values([ @@ -450,7 +502,10 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when a configured daily count cap would be exceeded', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } }); + const { svc } = await makeService({ + autoWithdrawal: { dailyCapCount: 1 }, + fiatThreshold: '1000', + }); const w = await seedWallet(); await db.drizzle.db.insert(walletTransaction).values({ walletId: w.id, @@ -472,8 +527,9 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('stays pending when a configured daily amount cap would be exceeded', async () => { - const { svc } = makeService({ - autoWithdrawal: { fiatThreshold: '1000', dailyCapAmount: '50' }, + const { svc } = await makeService({ + autoWithdrawal: { dailyCapAmount: '50' }, + fiatThreshold: '1000', }); const w = await seedWallet(); await db.drizzle.db.insert(walletTransaction).values({ @@ -496,7 +552,10 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('counts only auto-approved payouts towards the daily cap', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } }); + const { svc } = await makeService({ + autoWithdrawal: { dailyCapCount: 1 }, + fiatThreshold: '1000', + }); const w = await seedWallet(); await db.drizzle.db.insert(walletTransaction).values({ walletId: w.id, @@ -518,7 +577,10 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { }); it('serializes concurrent withdrawals so a count cap of one cannot be bypassed', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } }); + const { svc } = await makeService({ + autoWithdrawal: { dailyCapCount: 1 }, + fiatThreshold: '1000', + }); const w = await seedWallet(); const results = await Promise.all([ @@ -533,7 +595,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { it('stays pending when no cryptoThreshold is configured, even with a fiat one', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const w = await seedWallet({ currency: 'BTC', balance: '10' }); const result = await svc.withdraw({ @@ -548,7 +610,7 @@ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { }); it('auto-approves once an operator configures a positive cryptoThreshold', async () => { - const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '2' } }); + const { svc } = await makeService({ autoWithdrawal: {}, cryptoThreshold: '2' }); const w = await seedWallet({ currency: 'BTC', balance: '10' }); const result = await svc.withdraw({ @@ -568,7 +630,7 @@ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { }); it('stays pending when the crypto amount exceeds cryptoThreshold', async () => { - const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '0.5' } }); + const { svc } = await makeService({ autoWithdrawal: {}, cryptoThreshold: '0.5' }); const w = await seedWallet({ currency: 'BTC', balance: '10' }); const result = await svc.withdraw({ @@ -583,8 +645,9 @@ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { }); it('applies the same KYC guard as the fiat rail', async () => { - const { svc } = makeService({ - autoWithdrawal: { cryptoThreshold: '2' }, + const { svc } = await makeService({ + autoWithdrawal: {}, + cryptoThreshold: '2', kycStatus: 'rejected', }); const w = await seedWallet({ currency: 'BTC', balance: '10' }); @@ -603,7 +666,7 @@ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { describe('WalletService auto-approval threshold resolution (real PG)', () => { it('a per-player rule below the global threshold blocks what global would allow', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const w = await seedWallet(); await svc.setAutoWithdrawalRule({ userId: w.userId, @@ -623,7 +686,7 @@ describe('WalletService auto-approval threshold resolution (real PG)', () => { }); it('a per-player rule above the global threshold allows what global would block', async () => { - const { svc, audit } = makeService({ autoWithdrawal: { fiatThreshold: '10' } }); + const { svc, audit } = await makeService({ autoWithdrawal: {}, fiatThreshold: '10' }); const w = await seedWallet(); await svc.setAutoWithdrawalRule({ userId: w.userId, @@ -648,7 +711,7 @@ describe('WalletService auto-approval threshold resolution (real PG)', () => { }); it('a per-player rule overrides the crypto threshold too', async () => { - const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '0.5' } }); + const { svc } = await makeService({ autoWithdrawal: {}, cryptoThreshold: '0.5' }); const w = await seedWallet({ currency: 'BTC', balance: '10' }); await svc.setAutoWithdrawalRule({ userId: w.userId, @@ -669,7 +732,7 @@ describe('WalletService auto-approval threshold resolution (real PG)', () => { }); it('a zero per-player threshold turns auto-approval off for that player', async () => { - const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } }); + const { svc } = await makeService({ autoWithdrawal: {}, fiatThreshold: '1000' }); const w = await seedWallet(); await svc.setAutoWithdrawalRule({ userId: w.userId, @@ -691,7 +754,7 @@ describe('WalletService auto-approval threshold resolution (real PG)', () => { describe('WalletService auto-withdrawal rule methods (real PG)', () => { it('setAutoWithdrawalRule inserts, then upserts on a repeat for the same player', async () => { - const { svc } = makeService(); + const { svc } = await makeService(); const userId = randomUUID(); const createdBy = randomUUID(); @@ -719,13 +782,13 @@ describe('WalletService auto-withdrawal rule methods (real PG)', () => { }); it('getAutoWithdrawalRule returns null when no rule exists', async () => { - const { svc } = makeService(); + const { svc } = await makeService(); expect(await svc.getAutoWithdrawalRule(randomUUID())).toBeNull(); }); it('getAutoWithdrawalRule serializes the timestamps as ISO strings', async () => { - const { svc } = makeService(); + const { svc } = await makeService(); const userId = randomUUID(); await svc.setAutoWithdrawalRule({ userId, @@ -740,7 +803,7 @@ describe('WalletService auto-withdrawal rule methods (real PG)', () => { }); it('deleteAutoWithdrawalRule reports whether a row was removed', async () => { - const { svc } = makeService(); + const { svc } = await makeService(); const userId = randomUUID(); await svc.setAutoWithdrawalRule({ userId, @@ -754,3 +817,30 @@ describe('WalletService auto-withdrawal rule methods (real PG)', () => { expect(await svc.getAutoWithdrawalRule(userId)).toBeNull(); }); }); + +describe('WalletService auto-withdrawal config methods (real PG)', () => { + it('setAutoWithdrawalConfig updates the singleton row and stamps the admin', async () => { + const { svc } = await makeService({ autoWithdrawal: {} }); + const adminId = randomUUID(); + + const updated = await svc.setAutoWithdrawalConfig(adminId, { + fiatThreshold: '2500', + cryptoThreshold: '3', + }); + + expect(updated.fiatThreshold).toBe('2500.00000000'); + expect(updated.cryptoThreshold).toBe('3.00000000'); + expect(updated.updatedBy).toBe(adminId); + expect(await svc.getAutoWithdrawalConfig()).toMatchObject({ + fiatThreshold: '2500.00000000', + cryptoThreshold: '3.00000000', + updatedBy: adminId, + }); + }); + + it('getAutoWithdrawalConfig throws a typed not-found error when the singleton row is missing', async () => { + const { svc } = await makeService(); + + await expect(svc.getAutoWithdrawalConfig()).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/wallet/contract/index.ts b/packages/core/src/wallet/contract/index.ts index 68312c62..7ed6b153 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -133,6 +133,21 @@ export const SetAutoWithdrawalRuleInputSchema = z.object({ export const AutoWithdrawalRuleKeySchema = z.object({ userId: UuidSchema }); +export const WalletAutoWithdrawalConfigSchema = z.object({ + id: UuidSchema, + fiatThreshold: MoneyAmountSchema, + cryptoThreshold: MoneyAmountSchema, + updatedBy: UuidSchema.nullable(), + updatedAt: TimestampSchema, + createdAt: TimestampSchema, +}); +export type WalletAutoWithdrawalConfig = z.infer; + +export const SetWalletAutoWithdrawalConfigInputSchema = z.object({ + fiatThreshold: MoneyAmountSchema, + cryptoThreshold: MoneyAmountSchema, +}); + export const ApproveWithdrawalInputSchema = z.object({ withdrawalId: UuidSchema }); export const RejectWithdrawalInputSchema = z.object({ @@ -211,6 +226,17 @@ export const walletContract = { .output(z.boolean()), }, + autoWithdrawalConfig: { + get: oc + .route({ method: 'GET', path: '/wallet/auto-withdrawal-config' }) + .output(WalletAutoWithdrawalConfigSchema), + + set: oc + .route({ method: 'PUT', path: '/wallet/auto-withdrawal-config' }) + .input(SetWalletAutoWithdrawalConfigInputSchema) + .output(WalletAutoWithdrawalConfigSchema), + }, + deposits: { getAddress: oc .route({ method: 'POST', path: '/wallet/deposits/address' }) diff --git a/packages/core/src/wallet/drizzle/migrations/0004_gigantic_dust.sql b/packages/core/src/wallet/drizzle/migrations/0004_gigantic_dust.sql new file mode 100644 index 00000000..7703ffa0 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0004_gigantic_dust.sql @@ -0,0 +1,10 @@ +CREATE TABLE "wallet_auto_withdrawal_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "singleton_key" text DEFAULT 'global' NOT NULL, + "fiat_threshold" numeric(18, 8) NOT NULL, + "crypto_threshold" numeric(18, 8) NOT NULL, + "updated_by" uuid, + "updated_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "wallet_auto_withdrawal_config_singletonKey_unique" UNIQUE("singleton_key") +); diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 00000000..e010938a --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,631 @@ +{ + "id": "a94db42c-0cad-414c-8151-14cf49a4c150", + "prevId": "dc212a38-53e8-4ed3-9b0b-4e6eb50d9477", + "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 + }, + "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"] + } + }, + "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 e114fc02..d1ced697 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1785015620531, "tag": "0003_certain_ogun", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785409961214, + "tag": "0004_gigantic_dust", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/router/index.ts b/packages/core/src/wallet/router/index.ts index a8741338..5eaf87f2 100644 --- a/packages/core/src/wallet/router/index.ts +++ b/packages/core/src/wallet/router/index.ts @@ -17,6 +17,7 @@ import { IdempotencyKeyReuseError, DepositAddressUnsupportedError, DestinationAddressRequiredError, + AutoWithdrawalConfigNotFoundError, } from '../service/wallet.service.js'; export function createWalletRouter( @@ -173,6 +174,42 @@ export function createWalletRouter( }), }, + autoWithdrawalConfig: { + set: os.autoWithdrawalConfig.set.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'auto-withdrawal-config', 'update'); + // Non-throwing read: the row may legitimately be missing on an unseeded + // install, and `set` below self-heals it via upsert - a missing `before` + // must not block that self-heal. + const before = await wallet.getAutoWithdrawalConfigOrNull(); + const config = await wallet.setAutoWithdrawalConfig(adminId, input); + await audit.record({ + actorId: adminId, + actorType: 'admin', + action: 'wallet.auto_withdrawal_config.set', + resourceType: 'auto_withdrawal_config', + resourceId: config.id, + before: before + ? { fiatThreshold: before.fiatThreshold, cryptoThreshold: before.cryptoThreshold } + : null, + after: { fiatThreshold: config.fiatThreshold, cryptoThreshold: config.cryptoThreshold }, + ip, + userAgent, + }); + return config; + }), + + get: os.autoWithdrawalConfig.get.handler(async ({ context }) => { + await adminGuard.assert(context, 'auto-withdrawal-config', 'view'); + return mapErrors({ NOT_FOUND: AutoWithdrawalConfigNotFoundError }, () => + wallet.getAutoWithdrawalConfig(), + ); + }), + }, + deposits: { getAddress: os.deposits.getAddress.handler(({ input, context }) => mapErrors({ CONFLICT: DepositAddressUnsupportedError }, () => diff --git a/packages/core/src/wallet/schema/index.ts b/packages/core/src/wallet/schema/index.ts index 189a3b94..756f0d9f 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -124,7 +124,22 @@ export const walletDepositAddress = pgTable( ], ); +// Global fiat/crypto auto-withdrawal thresholds (BF-211), Super-Admin-editable at runtime. +// singletonKey's unique constraint DB-enforces exactly one row ever ('global'). +export const walletAutoWithdrawalConfig = pgTable('wallet_auto_withdrawal_config', { + id: uuid().primaryKey().defaultRandom(), + singletonKey: text().notNull().unique().default('global'), + fiatThreshold: decimal({ precision: 18, scale: 8 }).notNull(), + cryptoThreshold: decimal({ precision: 18, scale: 8 }).notNull(), + updatedBy: uuid(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .$onUpdateFn(() => new Date()), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), +}); + export type Wallet = typeof wallet.$inferSelect; export type WalletTransaction = typeof walletTransaction.$inferSelect; export type AutoWithdrawalRule = typeof autoWithdrawalRule.$inferSelect; export type WalletDepositAddress = typeof walletDepositAddress.$inferSelect; +export type WalletAutoWithdrawalConfig = typeof walletAutoWithdrawalConfig.$inferSelect; diff --git a/packages/core/src/wallet/seed/index.ts b/packages/core/src/wallet/seed/index.ts new file mode 100644 index 00000000..bc5f6404 --- /dev/null +++ b/packages/core/src/wallet/seed/index.ts @@ -0,0 +1 @@ +export { seedAutoWithdrawalConfig } from './seed-auto-withdrawal-config.js'; diff --git a/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts new file mode 100644 index 00000000..b86570f7 --- /dev/null +++ b/packages/core/src/wallet/seed/seed-auto-withdrawal-config.ts @@ -0,0 +1,15 @@ +import type { DrizzleDb } from '@openora/core/server'; +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). + */ +export async function seedAutoWithdrawalConfig(db: DrizzleDb): Promise { + await db + .insert(walletAutoWithdrawalConfig) + .values({ singletonKey: 'global', fiatThreshold: '0', cryptoThreshold: '0' }) + .onConflictDoNothing({ target: walletAutoWithdrawalConfig.singletonKey }); +} diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 3c81838c..803935c1 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -37,16 +37,19 @@ import { wallet, walletTransaction, autoWithdrawalRule, + walletAutoWithdrawalConfig, walletDepositAddress, type Wallet, type WalletTransaction, type AutoWithdrawalRule as AutoWithdrawalRuleRow, + type WalletAutoWithdrawalConfig as WalletAutoWithdrawalConfigRow, } from '../schema/index.js'; import type { TransactionResult, WithdrawalQueueItem, WithdrawalQueueFilter, AutoWithdrawalRule, + WalletAutoWithdrawalConfig, WalletTransactionSortBy, } from '../contract/index.js'; @@ -54,6 +57,7 @@ const logger = createLogger('wallet'); export const WalletNotFoundError = makeNotFoundError('Wallet'); export const WithdrawalNotFoundError = makeNotFoundError('Withdrawal'); +export const AutoWithdrawalConfigNotFoundError = makeNotFoundError('AutoWithdrawalConfig'); export const InsufficientBalanceError = createDomainError<[available: string, requested: string]>( 'InsufficientBalanceError', @@ -166,6 +170,17 @@ function toAutoWithdrawalRuleDto(row: AutoWithdrawalRuleRow): AutoWithdrawalRule }; } +function toAutoWithdrawalConfigDto(row: WalletAutoWithdrawalConfigRow): WalletAutoWithdrawalConfig { + return { + id: row.id, + fiatThreshold: row.fiatThreshold, + cryptoThreshold: row.cryptoThreshold, + updatedBy: row.updatedBy, + updatedAt: row.updatedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + }; +} + // The concrete settlement provider recorded per transaction: the crypto rail settles // through Fireblocks, the fiat rail through a PSP. function providerNameFor(rail: WalletRail | null): string { @@ -1047,14 +1062,54 @@ export class WalletService { if (rule) { return { value: rule.threshold, source: 'per-player' }; } - const global = - rail === 'crypto' - ? this.platformConfig?.autoWithdrawal?.cryptoThreshold - : this.platformConfig?.autoWithdrawal?.fiatThreshold; - if (global !== undefined) { - return { value: global, source: 'global' }; + const config = await this.getAutoWithdrawalConfig(); + const global = rail === 'crypto' ? config.cryptoThreshold : config.fiatThreshold; + return { value: global, source: 'global' }; + } + + // The row always exists in a properly-seeded install (seeded once, BF-211) - a + // missing row is an unexpected failure mode on the READ path, not a normal + // "unconfigured" state, so this throws rather than silently defaulting. + // maybeAutoApprove's outer try/catch handles that thrown error the same as any + // other unexpected error: fail closed to pending. + async getAutoWithdrawalConfig(): Promise { + const config = await this.getAutoWithdrawalConfigOrNull(); + if (!config) { + throw new AutoWithdrawalConfigNotFoundError('global'); } - return null; + return config; + } + + // Non-throwing variant for callers that need to tell "missing" from "found" + // without a try/catch (eg the router's audit `before` read). + async getAutoWithdrawalConfigOrNull(): Promise { + const [row] = await this.drizzle.db + .select() + .from(walletAutoWithdrawalConfig) + .where(eq(walletAutoWithdrawalConfig.singletonKey, 'global')); + return row ? toAutoWithdrawalConfigDto(row) : null; + } + + // Upsert, not a plain UPDATE: the WRITE path lets a Super Admin self-heal a + // missing singleton row (eg an install that skipped the seed step) through the + // existing route with zero new surface. The READ path (getAutoWithdrawalConfig, + // resolveAutoThreshold) still throws on a missing row and stays fail-closed - + // a withdrawal must never silently create config. + async setAutoWithdrawalConfig( + adminId: User['id'], + { fiatThreshold, cryptoThreshold }: { fiatThreshold: string; cryptoThreshold: string }, + ): Promise { + const rows = await this.drizzle.db + .insert(walletAutoWithdrawalConfig) + .values({ singletonKey: 'global', fiatThreshold, cryptoThreshold, updatedBy: adminId }) + .onConflictDoUpdate({ + target: walletAutoWithdrawalConfig.singletonKey, + set: { fiatThreshold, cryptoThreshold, updatedBy: adminId }, + }) + .returning(); + return toAutoWithdrawalConfigDto( + findOneOrThrow(rows, new AutoWithdrawalConfigNotFoundError('global')), + ); } private async autoApprovalKycStatus(userId: User['id']): Promise { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5831bbec..c725ac88 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -105,6 +105,7 @@ "@openora/core/wallet/plugin": ["./src/wallet/plugin.ts"], "@openora/core/wallet/plugins/wallet": ["./src/wallet/plugin.ts"], "@openora/core/wallet/schema": ["./src/wallet/schema/index.ts"], + "@openora/core/wallet/seed": ["./src/wallet/seed/index.ts"], "@openora/core/wallet/server": ["./src/wallet/server.ts"] } }, 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 new file mode 100644 index 00000000..871d0ee0 --- /dev/null +++ b/packages/testing/src/__tests__/fixtures/qa-bf211-auto-withdrawal-config-plugin.ts @@ -0,0 +1,25 @@ +import { definePlugin } from '@openora/core/server'; +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 +// scenarios exercise the auto-approval KYC gate, not the withdraw-time one. +export default definePlugin({ + id: 'qa-bf211-auto-withdrawal-config', + dependsOn: ['identity'], + register(ctx) { + ctx.provide(PLATFORM_CONFIG, () => + definePlatformConfig({ + kyc: { gateWithdrawals: false }, + autoWithdrawal: { + enabled: true, + excludeRiskFlags: [], + dailyCapAmount: '1000000', + dailyCapCount: 1000, + }, + }), + ); + }, +}); diff --git a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts index 3b733e88..bcfafdc1 100644 --- a/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts +++ b/packages/testing/src/__tests__/fixtures/test-wallet-auto-withdrawal-cap-config-plugin.ts @@ -3,13 +3,15 @@ import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; // PLATFORM_CONFIG overlay for the daily-cap scenario: dailyCapCount 1 trips on the 2nd withdrawal, still below // the high_frequency heuristic (>= 3) so the cap gate is tested in isolation. Separate app since config is boot-once. +// The fiat threshold ('2') is BF-211's DB-backed wallet_auto_withdrawal_config singleton, seeded by the +// test file's own beforeAll - this static config no longer carries it. export default definePlugin({ id: 'test-wallet-auto-withdrawal-cap-config', dependsOn: ['identity'], register(ctx) { ctx.provide(PLATFORM_CONFIG, () => definePlatformConfig({ - autoWithdrawal: { enabled: true, fiatThreshold: '2', dailyCapCount: 1 }, + autoWithdrawal: { enabled: true, dailyCapCount: 1 }, }), ); }, 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 a9c4f85a..f6c6d8e2 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 @@ -1,9 +1,11 @@ import { definePlugin } from '@openora/core/server'; import { PLATFORM_CONFIG, definePlatformConfig } from '@openora/core/contracts'; -// PLATFORM_CONFIG overlay for the auto-withdrawal e2e suite: autoWithdrawal enabled (threshold 2, -// 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. +// 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. export default definePlugin({ id: 'test-wallet-auto-withdrawal-config', dependsOn: ['identity'], @@ -13,7 +15,6 @@ export default definePlugin({ kyc: { gateWithdrawals: false }, autoWithdrawal: { enabled: true, - fiatThreshold: '2', 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 new file mode 100644 index 00000000..f74ca0bb --- /dev/null +++ b/packages/testing/src/__tests__/qa-bf211-wallet-auto-withdrawal-config.e2e.test.ts @@ -0,0 +1,532 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { Client } from 'pg'; +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 { seedAutoWithdrawalConfig } from '@openora/core/wallet/seed'; +import { definePlatformConfig, AutoWithdrawalConfigSchema } from '@openora/core/contracts'; +import { + setupTestDb, + bootTestApp, + applyMigrations, + asPlayer, + asAdmin, + seedMinimal, + type TestDb, + type TestApp, + type TestClient, +} from '../index.js'; + +/** + * Independent QA verification of BF-211 (auto-withdrawal rules: a Super-Admin-editable, + * DB-backed global auto-withdrawal threshold config), driven through the REAL app + * (bootTestApp: real Hono + oRPC + Postgres + Redis) rather than the implementer's own + * router-level tests, which mock AdminGuard/AdminUserDirectory and never exercise the + * real DB-backed IAM RBAC resolver or the real seed/boot wiring. Two apps share one db: + * - `appMain`: autoWithdrawal enabled, wallet_auto_withdrawal_config singleton SEEDED. + * - `appUnseeded`: autoWithdrawal enabled, singleton row deliberately NOT seeded (BF-211 + * checklist item 6: fail-closed when the row is missing). + */ + +let db: TestDb; +let appMain: TestApp; +let appUnseeded: TestApp; + +let superAdmin: TestClient; +let paymentsManager: TestClient; +let plainAdmin: 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-211 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-211 QA fixture verification', + }); + if (res.status !== 200) { + throw new Error(`verifyKyc failed (${res.status}): ${await res.text()}`); + } +} + +/** + * Assigns a REAL DB-backed IAM role (bypassing the API, since assignRole itself + * requires an existing super-admin - a legitimate bootstrap gap, not a BF-211 bug). + * This is what actually exercises the dynamic ADMIN_PERMISSION_RESOLVER path the + * implementer's mocked-AdminGuard tests never touch. + * + * AdminGuard.assert first gates on the coarse static `user.role` column (must be + * one of the statically-declared roles - admin/support/content-manager) BEFORE it + * ever consults the DB-backed permission resolver; only once past that gate does a + * bound resolver's per-user grants (from the DB assignment) become authoritative. + * So a DB-backed role (super-admin, payments-manager, ...) needs BOTH the static + * `user.role = 'admin'` coarse flag AND the specific admin_role_assignment row. + */ +async function assignIamRoleByKey(container: Container, userId: string, roleKey: string) { + 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, roleKey)); + if (!role) { + throw new Error(`assignIamRoleByKey: no seeded admin_role with key='${roleKey}'`); + } + await drizzle + .insert(adminRoleAssignment) + .values({ userId, roleId: role.id }) + .onConflictDoNothing(); +} + +async function setStaticRole(container: Container, userId: string, role: string) { + await container.get(DRIZZLE).db.update(user).set({ role }).where(eq(user.id, userId)); +} + +let unseededDbName: string; +let unseededAdminClient: Client; + +/** + * `bootTestApp` apps in one file normally share ONE physical Postgres database + * (only Redis is per-app-isolated) - see the implementer's own + * wallet-ledger-auto-withdrawal.e2e.test.ts, which boots three apps against the + * same `db.url`. wallet_auto_withdrawal_config is a true DB-wide singleton + * (unique `singletonKey`), so a genuine "row is missing" scenario needs its own + * physical database, not just its own app/container. + */ +async function createIsolatedTestDatabase(): Promise { + const baseUrl = + process.env['TEST_DATABASE_URL'] ?? + 'postgres://postgres:postgres@localhost:5432/oss_igaming_test'; + const url = new URL(baseUrl); + const dbName = `bf211_unseeded_${randomUUID().replaceAll('-', '')}`; + const admin = new Client({ + connectionString: `${url.protocol}//${url.username}:${url.password}@${url.host}/postgres`, + }); + await admin.connect(); + await admin.query(`CREATE DATABASE "${dbName}"`); + unseededAdminClient = admin; + unseededDbName = dbName; + const isolatedUrl = new URL(baseUrl); + isolatedUrl.pathname = `/${dbName}`; + return isolatedUrl.toString(); +} + +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-bf211-auto-withdrawal-config-plugin.ts', import.meta.url), + ); + + appMain = await bootTestApp({ + plugins: [...basePlugins, { id: 'qa-bf211-auto-withdrawal-config', path: fixture }], + databaseUrl: db.url, + }); + + // seedMinimal reads process.env.DATABASE_URL (a global, set as a side effect of + // the most-recently-booted app) rather than taking a url param, so appMain must + // be fully seeded here BEFORE appUnseeded is booted below - otherwise this call + // would silently reseed whichever app booted last (observed: a duplicate + // admin@oss.dev registration collision when appUnseeded was booted first). + await seedMinimal(appMain.container, { playerCount: 0 }); + // The singleton row is ONLY seeded by tools/db/seed.ts (pnpm db:seed) / the consumer + // template in production - nothing in @openora/testing's bootTestApp/seedMinimal path + // seeds it. Seed it explicitly for appMain; appUnseeded's separate database + // deliberately never gets this call. + await seedAutoWithdrawalConfig(appMain.container.get(DRIZZLE).db); + + const unseededUrl = await createIsolatedTestDatabase(); + await applyMigrations(unseededUrl); + appUnseeded = await bootTestApp({ + plugins: [...basePlugins, { id: 'qa-bf211-auto-withdrawal-config', path: fixture }], + databaseUrl: unseededUrl, + }); + await seedMinimal(appUnseeded.container, { playerCount: 0 }); + + const superAdminEmail = `bf211-super-admin-${randomUUID()}@e2e.test`; + const { client: superAdminClient, userId: superAdminUserId } = await registerAndMaterializePlayer( + appMain.app, + superAdminEmail, + ); + await assignIamRoleByKey(appMain.container, superAdminUserId, 'super-admin'); + superAdmin = superAdminClient; + + const paymentsManagerEmail = `bf211-payments-manager-${randomUUID()}@e2e.test`; + const { client: paymentsManagerClient, userId: paymentsManagerUserId } = + await registerAndMaterializePlayer(appMain.app, paymentsManagerEmail); + await assignIamRoleByKey(appMain.container, paymentsManagerUserId, 'payments-manager'); + paymentsManager = paymentsManagerClient; + + const plainAdminEmail = `bf211-plain-admin-${randomUUID()}@e2e.test`; + const { client: plainAdminClient, userId: plainAdminUserId } = await registerAndMaterializePlayer( + appMain.app, + plainAdminEmail, + ); + await setStaticRole(appMain.container, plainAdminUserId, 'admin'); + plainAdmin = plainAdminClient; +}, 60_000); + +afterAll(async () => { + await appMain?.close(); + await appUnseeded?.close(); + await db?.dispose(); + if (unseededAdminClient) { + await unseededAdminClient.query(`DROP DATABASE IF EXISTS "${unseededDbName}" WITH (FORCE)`); + await unseededAdminClient.end(); + } +}); + +describe('BF-211 authz: auto-withdrawal-config is super-admin only (real DB-backed IAM RBAC)', () => { + it('super-admin succeeds on GET and PUT', async () => { + const getRes = await superAdmin.get('/wallet/auto-withdrawal-config'); + expect(getRes.status).toBe(200); + + const setRes = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '1', + cryptoThreshold: '1', + }); + expect(setRes.status).toBe(200); + }); + + it('payments-manager gets 403 on GET and PUT despite holding withdrawal:read_write', async () => { + const getRes = await paymentsManager.get('/wallet/auto-withdrawal-config'); + expect(getRes.status).toBe(403); + + const setRes = await paymentsManager.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '1', + cryptoThreshold: '1', + }); + expect(setRes.status).toBe(403); + }); + + it('plain admin gets 403 on GET and PUT', async () => { + const getRes = await plainAdmin.get('/wallet/auto-withdrawal-config'); + expect(getRes.status).toBe(403); + + const setRes = await plainAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '1', + cryptoThreshold: '1', + }); + expect(setRes.status).toBe(403); + }); + + it('anonymous (no session) gets 401, not 403', async () => { + const getRes = await appMain.app.request('/wallet/auto-withdrawal-config'); + expect(getRes.status).toBe(401); + }); +}); + +describe('BF-211 validation: threshold input', () => { + it('rejects a negative fiat threshold', async () => { + const res = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '-1', + cryptoThreshold: '0', + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('rejects a non-numeric crypto threshold', async () => { + const res = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '0', + cryptoThreshold: 'not-a-number', + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('a rejected update does not change the persisted config', async () => { + const before = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '-999', + cryptoThreshold: '0', + }); + const after = await readJson(await superAdmin.get('/wallet/auto-withdrawal-config')); + expect(after).toMatchObject({ + fiatThreshold: before.fiatThreshold, + cryptoThreshold: before.cryptoThreshold, + }); + }); +}); + +describe('BF-211 happy path: set -> immediate GET -> below/above threshold -> audit trail', () => { + it('super-admin sets fiat/crypto thresholds, GET reflects immediately with no staleness', async () => { + const setRes = await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '100', + cryptoThreshold: '0.01', + }); + expect(setRes.status).toBe(200); + const set = await readJson(setRes); + expect(set).toMatchObject({ fiatThreshold: '100.00000000', cryptoThreshold: '0.01000000' }); + + const getRes = await superAdmin.get('/wallet/auto-withdrawal-config'); + const got = await readJson(getRes); + expect(got).toMatchObject({ fiatThreshold: '100.00000000', cryptoThreshold: '0.01000000' }); + }); + + it('a withdrawal below the fiat threshold auto-approves; one above stays pending; both leave audit trails', async () => { + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '100', + cryptoThreshold: '0.01', + }); + + const configAuditRes = await superAdmin.get( + '/audit/logs?action=wallet.auto_withdrawal_config.set&limit=1', + ); + const configAudit = await readJson(configAuditRes); + expect(configAudit.items.length).toBeGreaterThanOrEqual(1); + expect(configAudit.items[0].after).toMatchObject({ + fiatThreshold: '100.00000000', + cryptoThreshold: '0.01000000', + }); + expect(configAudit.items[0].before).toBeTruthy(); + + const belowEmail = `bf211-below-${randomUUID()}@e2e.test`; + const below = await registerAndMaterializePlayer(appMain.app, belowEmail); + await verifyKyc(superAdmin, below.userId); + await below.client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const belowRes = await below.client.post('/wallet/withdraw', { + amount: '50', + currency: 'USD', + }); + expect(belowRes.status).toBe(200); + const belowBody = await readJson(belowRes); + expect(belowBody.status).toBe('completed'); + + const aboveEmail = `bf211-above-${randomUUID()}@e2e.test`; + const above = await registerAndMaterializePlayer(appMain.app, aboveEmail); + await verifyKyc(superAdmin, above.userId); + await above.client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + const aboveRes = await above.client.post('/wallet/withdraw', { + amount: '150', + currency: 'USD', + }); + expect(aboveRes.status).toBe(200); + const aboveBody = await readJson(aboveRes); + expect(aboveBody.status).toBe('pending'); + + const pendingListRes = await superAdmin.get('/wallet/withdrawals?status=pending&limit=100'); + const pendingList = await readJson(pendingListRes); + const pendingIds = new Set( + (pendingList.items as Array<{ transactionId: string }>).map((i) => i.transactionId), + ); + expect(pendingIds.has(aboveBody.transactionId)).toBe(true); + expect(pendingIds.has(belowBody.transactionId)).toBe(false); + + const autoApprovedAuditRes = await superAdmin.get( + `/audit/logs?resourceId=${belowBody.transactionId}&action=wallet.withdrawal.auto_approved`, + ); + const autoApprovedAudit = await readJson(autoApprovedAuditRes); + expect(autoApprovedAudit.items.length).toBeGreaterThanOrEqual(1); + expect(autoApprovedAudit.items[0].actorType).toBe('system'); + expect(autoApprovedAudit.items[0].after).toMatchObject({ + userId: below.userId, + threshold: '100.00000000', + thresholdSource: 'global', + }); + }); +}); + +describe('BF-211 immediate effect: two consecutive config changes in one run', () => { + it('each withdrawal picks up the latest threshold, no restart/cache needed', async () => { + const email = `bf211-immediate-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '10', + cryptoThreshold: '0', + }); + const first = await readJson( + await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), + ); + expect(first.status).toBe('pending'); + + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '30', + cryptoThreshold: '0', + }); + const second = await readJson( + await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), + ); + expect(second.status).toBe('completed'); + + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '5', + cryptoThreshold: '0', + }); + const third = await readJson( + await client.post('/wallet/withdraw', { amount: '20', currency: 'USD' }), + ); + expect(third.status).toBe('pending'); + }); +}); + +describe('BF-211 precedence: per-player auto_withdrawal_rule vs the global config', () => { + it('a per-player rule ABOVE the global threshold allows what global would block', async () => { + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '10', + cryptoThreshold: '0', + }); + const email = `bf211-rule-above-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await superAdmin.put(`/wallet/auto-withdrawal-rules/${userId}`, { + threshold: '1000', + reason: 'BF-211 QA: trusted player, rule above global', + }); + + 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[0].after).toMatchObject({ thresholdSource: 'per-player' }); + }); + + it('a per-player rule BELOW the global threshold blocks what global would allow', async () => { + await superAdmin.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '1000', + cryptoThreshold: '0', + }); + const email = `bf211-rule-below-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appMain.app, email); + await verifyKyc(superAdmin, userId); + await superAdmin.put(`/wallet/auto-withdrawal-rules/${userId}`, { + threshold: '5', + reason: 'BF-211 QA: watchlist player, rule below global', + }); + + 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-211 fail-closed: the singleton config row is missing', () => { + it('a withdrawal does not 500 and stays pending when wallet_auto_withdrawal_config has no row', async () => { + const [row] = await appUnseeded.container + .get(DRIZZLE) + .db.select() + .from(walletAutoWithdrawalConfig); + expect(row).toBeUndefined(); + + const email = `bf211-unseeded-${randomUUID()}@e2e.test`; + const { client, userId } = await registerAndMaterializePlayer(appUnseeded.app, email); + const admin = await asAdmin(appUnseeded.app); + await verifyKyc(admin, userId); + await client.post('/wallet/deposit', { amount: '500', currency: 'USD' }); + + const res = await client.post('/wallet/withdraw', { amount: '10', currency: 'USD' }); + expect(res.status).toBe(200); + const body = await readJson(res); + expect(body.status).toBe('pending'); + }); + + it('BUG: GET /wallet/auto-withdrawal-config 500s instead of a mapped 404 when the row is missing (router/index.ts autoWithdrawalConfig.get skips mapErrors, unlike every sibling handler)', async () => { + const email = `bf211-unseeded-superadmin-${randomUUID()}@e2e.test`; + const { userId } = await registerAndMaterializePlayer(appUnseeded.app, email); + await assignIamRoleByKey(appUnseeded.container, userId, 'super-admin'); + const client = await asPlayer(appUnseeded.app, { email }); + + const res = await client.get('/wallet/auto-withdrawal-config'); + // EXPECTED (once fixed): a mapped 404. ACTUAL today: an unhandled 500 - see + // the QA report. This assertion intentionally documents the correct + // contract and will start passing once router/index.ts wraps the handler + // in mapErrors({ NOT_FOUND: AutoWithdrawalConfigNotFoundError }, ...). + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('FIXED: PUT /wallet/auto-withdrawal-config self-heals the missing row via upsert instead of 500ing (agreed fix: setAutoWithdrawalConfig is an upsert on the admin WRITE path only - the READ path still fails closed)', async () => { + const email = `bf211-unseeded-set-${randomUUID()}@e2e.test`; + const { userId } = await registerAndMaterializePlayer(appUnseeded.app, email); + await assignIamRoleByKey(appUnseeded.container, userId, 'super-admin'); + const client = await asPlayer(appUnseeded.app, { email }); + + // A Super Admin trying to configure the feature for the first time on an + // install where the seed script was never run previously got an unhandled + // 500 with no way to recover via the API. The agreed fix upserts on the + // admin write path, so PUT both creates the row and returns it - a clean + // self-heal through the existing route, zero new surface. + const res = await client.put('/wallet/auto-withdrawal-config', { + fiatThreshold: '100', + cryptoThreshold: '1', + }); + expect(res.status).toBe(200); + const body = await readJson(res); + expect(body).toMatchObject({ fiatThreshold: '100.00000000', cryptoThreshold: '1.00000000' }); + + const [row] = await appUnseeded.container + .get(DRIZZLE) + .db.select() + .from(walletAutoWithdrawalConfig); + expect(row).toMatchObject({ fiatThreshold: '100.00000000', cryptoThreshold: '1.00000000' }); + }); +}); + +describe('BF-211 regression spot-check: static platform-config.yaml AutoWithdrawalConfigSchema', () => { + it('still accepts the fields that stay static: enabled, excludeRiskFlags, 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('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({ + autoWithdrawal: { + enabled: true, + // @ts-expect-error -- BF-211 removed this field; kept here to prove the + // regression the two existing fixtures below still trigger. + fiatThreshold: '2', + }, + }), + ).toThrow(/Unrecognized key: "fiatThreshold"/); + }); +}); 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 932b54fa..58b8b863 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 @@ -4,6 +4,8 @@ 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 { walletAutoWithdrawalConfig } from '@openora/core/wallet/schema'; +import { seedAutoWithdrawalConfig } from '@openora/core/wallet/seed'; import { setupTestDb, bootTestApp, @@ -109,6 +111,18 @@ beforeAll(async () => { databaseUrl: db.url, }); + // BF-211 moved the global fiat/crypto threshold from static PLATFORM_CONFIG to the + // DB-backed wallet_auto_withdrawal_config singleton. appGated/appCapGated share this + // 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) + .set({ fiatThreshold: '2' }) + .where(eq(walletAutoWithdrawalConfig.singletonKey, 'global')); + await seedMinimal(appDefault.container, { playerCount: 0 }); }, 60_000); @@ -147,7 +161,7 @@ describe('Auto-withdrawal: single-shot gates (appGated - fiatThreshold 2)', () = expect(auditBody.items[0].actorType).toBe('system'); expect(auditBody.items[0].after).toMatchObject({ userId, - threshold: '2', + threshold: '2.00000000', thresholdSource: 'global', kycStatus: 'manually_overridden', riskTagsEvaluated: [], diff --git a/tools/db/seed.ts b/tools/db/seed.ts index cad7c5dc..d087cac0 100644 --- a/tools/db/seed.ts +++ b/tools/db/seed.ts @@ -21,6 +21,7 @@ import { createAuth, createDrizzleDb } from '@openora/core/server'; import { seedIam } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; +import { seedAutoWithdrawalConfig } from '@openora/core/wallet/seed'; import { seedDemoData } from '@openora/testing'; import { user, session, account, verification, twoFactor } from '@openora/core/pam/schema/identity'; @@ -43,6 +44,8 @@ async function main() { console.log(' Reference roles ready.'); await seedTag(db); console.log(' Default tags ready.'); + await seedAutoWithdrawalConfig(db); + console.log(' Auto-withdrawal config ready.'); const result = await seedDemoData({ db, diff --git a/tools/templates/consumer/apps/api/src/seed.ts.tpl b/tools/templates/consumer/apps/api/src/seed.ts.tpl index 01d2ce9a..9719f746 100644 --- a/tools/templates/consumer/apps/api/src/seed.ts.tpl +++ b/tools/templates/consumer/apps/api/src/seed.ts.tpl @@ -11,6 +11,7 @@ import { createDrizzleDb } from '@openora/core/server'; import { seedRoles } from '@openora/core/iam/seed'; import { seedTag } from '@openora/core/pam/tag/seed'; +import { seedAutoWithdrawalConfig } from '@openora/core/wallet/seed'; // import additional module seeders here as you enable them async function main() { @@ -29,6 +30,7 @@ async function main() { await seedRoles(db); await seedTag(db); + await seedAutoWithdrawalConfig(db); console.log('Reference data seeded.'); }