Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions packages/core/src/contracts/schemas/platform-config.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/wallet/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PaymentAdapter,
PaymentWebhookVerifier,
PlatformConfig,
PlayerTags,
} from '@openora/core/contracts';
import { createTestDb, type TestDb } from '@openora/core/testing';
import {
Expand Down Expand Up @@ -73,6 +74,13 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial<PlatformCon
),
),
});
// excludeRiskFlags defaults to the migration's 5-tag DEFAULT (non-empty), so
// evaluateAutoApproval needs PLAYER_TAGS bound to check it - bind an empty-tags double by
// default so tests that aren't specifically exercising risk-tag exclusion still reach
// auto-approval.
const riskTags = mock<PlayerTags>({
getActiveTagKeys: vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, []]))),
});
const service = new WalletService({
drizzle: db.drizzle,
events: makeEventBus(),
Expand All @@ -85,6 +93,7 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial<PlatformCon
audit,
directory,
platformConfig: platformConfig ? mock<PlatformConfig>(platformConfig) : undefined,
riskTags,
});
const router = createWalletRouter(
service,
Expand All @@ -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 () => {
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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(),
Expand All @@ -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 },
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AutoWithdrawalConfig>;
// The global fiat/crypto thresholds are DB-backed (BF-211), not part of
Expand All @@ -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;
Expand All @@ -46,6 +61,7 @@ async function makeService({
autoWithdrawal,
fiatThreshold,
cryptoThreshold,
excludeRiskFlags,
skipConfigSeed = false,
kycStatus = 'verified',
directoryThrows = false,
Expand All @@ -56,6 +72,7 @@ async function makeService({
singletonKey: 'global',
fiatThreshold: fiatThreshold ?? '0',
cryptoThreshold: cryptoThreshold ?? '0',
...(excludeRiskFlags !== undefined ? { excludeRiskFlags: [...excludeRiskFlags] } : {}),
});
}
const events = makeEventBus();
Expand All @@ -77,9 +94,7 @@ async function makeService({
}),
});
const platformConfig = mock<PlatformConfig>(
autoWithdrawal
? { autoWithdrawal: { enabled: true, excludeRiskFlags: [], ...autoWithdrawal } }
: {},
autoWithdrawal ? { autoWithdrawal: { enabled: true, ...autoWithdrawal } } : {},
);
const svc = new WalletService({
drizzle: db.drizzle,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<PlatformConfig>({
autoWithdrawal: {
enabled: true,
excludeRiskFlags: ['withdrawal_review'],
},
});
const payment = mock<PaymentAdapter>({
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/wallet/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as z from 'zod';
import {
KycStatusSchema,
MoneyAmountSchema,
TagKeySchema,
TimestampSchema,
UuidSchema,
WalletRailSchema,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading