@openora/core 0.4.1-canary.39 → 0.4.1-canary.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/admin-console/contract/index.d.ts +2 -0
- package/dist/admin-console/contract/index.d.ts.map +1 -1
- package/dist/admin-console/router/index.d.ts +1 -0
- package/dist/admin-console/router/index.d.ts.map +1 -1
- package/dist/admin-console/service/backoffice.service.d.ts +1 -1
- package/dist/audit/plugin.d.ts.map +1 -1
- package/dist/audit/plugin.js +48 -1
- package/dist/audit/plugin.js.map +1 -1
- package/dist/common/errors/errors.classes.js +3 -3
- package/dist/common/errors/errors.classes.js.map +1 -1
- package/dist/common/errors/errors.mapper.js +1 -1
- package/dist/common/errors/errors.mapper.js.map +1 -1
- package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js +66 -12
- package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js.map +1 -1
- package/dist/compliance/__tests__/kyc-admin.router.test.d.ts +2 -0
- package/dist/compliance/__tests__/kyc-admin.router.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/kyc-admin.router.test.js +191 -0
- package/dist/compliance/__tests__/kyc-admin.router.test.js.map +1 -0
- package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts +2 -0
- package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/kyc-webhook.router.test.js +148 -0
- package/dist/compliance/__tests__/kyc-webhook.router.test.js.map +1 -0
- package/dist/compliance/__tests__/kyc.service.test.js +16 -10
- package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.router.test.js +5 -1
- package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
- package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts +16 -3
- package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts.map +1 -1
- package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js +47 -5
- package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js.map +1 -1
- package/dist/compliance/contract/index.d.ts +312 -0
- package/dist/compliance/contract/index.d.ts.map +1 -1
- package/dist/compliance/contract/index.js +50 -1
- package/dist/compliance/contract/index.js.map +1 -1
- package/dist/compliance/drizzle/migrations/0002_condemned_monster_badoon.sql +3 -0
- package/dist/compliance/drizzle/migrations/0003_public_spirit.sql +1 -0
- package/dist/compliance/drizzle/migrations/meta/0002_snapshot.json +673 -0
- package/dist/compliance/drizzle/migrations/meta/0003_snapshot.json +679 -0
- package/dist/compliance/drizzle/migrations/meta/_journal.json +14 -0
- package/dist/compliance/plugin.d.ts.map +1 -1
- package/dist/compliance/plugin.js +23 -3
- package/dist/compliance/plugin.js.map +1 -1
- package/dist/compliance/router/index.d.ts +186 -2
- package/dist/compliance/router/index.d.ts.map +1 -1
- package/dist/compliance/router/index.js +52 -3
- package/dist/compliance/router/index.js.map +1 -1
- package/dist/compliance/schema/index.d.ts +65 -3
- package/dist/compliance/schema/index.d.ts.map +1 -1
- package/dist/compliance/schema/index.js +8 -0
- package/dist/compliance/schema/index.js.map +1 -1
- package/dist/compliance/service/kyc.service.d.ts +153 -8
- package/dist/compliance/service/kyc.service.d.ts.map +1 -1
- package/dist/compliance/service/kyc.service.js +264 -32
- package/dist/compliance/service/kyc.service.js.map +1 -1
- package/dist/contracts/adapters/audit.d.ts +1 -1
- package/dist/contracts/adapters/audit.d.ts.map +1 -1
- package/dist/contracts/adapters/audit.js.map +1 -1
- package/dist/contracts/adapters/identity-reader.d.ts +9 -3
- package/dist/contracts/adapters/identity-reader.d.ts.map +1 -1
- package/dist/contracts/adapters/identity-reader.js.map +1 -1
- package/dist/contracts/adapters/index.d.ts +4 -2
- package/dist/contracts/adapters/index.d.ts.map +1 -1
- package/dist/contracts/adapters/index.js +2 -1
- package/dist/contracts/adapters/index.js.map +1 -1
- package/dist/contracts/adapters/kyc.d.ts +90 -5
- package/dist/contracts/adapters/kyc.d.ts.map +1 -1
- package/dist/contracts/adapters/kyc.js +23 -0
- package/dist/contracts/adapters/kyc.js.map +1 -1
- package/dist/contracts/adapters/tag-evaluation-commands.d.ts +10 -0
- package/dist/contracts/adapters/tag-evaluation-commands.d.ts.map +1 -0
- package/dist/contracts/adapters/tag-evaluation-commands.js +11 -0
- package/dist/contracts/adapters/tag-evaluation-commands.js.map +1 -0
- package/dist/contracts/adapters/wallet-reader.d.ts +8 -0
- package/dist/contracts/adapters/wallet-reader.d.ts.map +1 -1
- package/dist/contracts/adapters/wallet-reader.js.map +1 -1
- package/dist/contracts/schemas/__tests__/events.test.js +1 -1
- package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/player-schemas.test.js +30 -0
- package/dist/contracts/schemas/__tests__/player-schemas.test.js.map +1 -0
- package/dist/contracts/schemas/events.d.ts +84 -0
- package/dist/contracts/schemas/events.d.ts.map +1 -1
- package/dist/contracts/schemas/events.js +24 -2
- package/dist/contracts/schemas/events.js.map +1 -1
- package/dist/contracts/schemas/platform-config.d.ts +13 -1
- package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
- package/dist/contracts/schemas/player.d.ts +36 -3
- package/dist/contracts/schemas/player.d.ts.map +1 -1
- package/dist/contracts/schemas/player.js +14 -0
- package/dist/contracts/schemas/player.js.map +1 -1
- package/dist/contracts/schemas/tag.d.ts +85 -1
- package/dist/contracts/schemas/tag.d.ts.map +1 -1
- package/dist/contracts/schemas/tag.js +12 -0
- package/dist/contracts/schemas/tag.js.map +1 -1
- package/dist/engagement/notifications/contract/index.d.ts +5 -1
- package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
- package/dist/engagement/notifications/contract/index.js +5 -1
- package/dist/engagement/notifications/contract/index.js.map +1 -1
- package/dist/engagement/notifications/plugin.d.ts.map +1 -1
- package/dist/engagement/notifications/plugin.js +43 -3
- package/dist/engagement/notifications/plugin.js.map +1 -1
- package/dist/engagement/notifications/router/index.d.ts +1 -0
- package/dist/engagement/notifications/router/index.d.ts.map +1 -1
- package/dist/iam/service/iam.service.d.ts +1 -1
- package/dist/pam/identity/__tests__/admin-user-directory.test.js +1 -1
- package/dist/pam/identity/adapters/identity-reader.service.d.ts +6 -4
- package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
- package/dist/pam/identity/adapters/identity-reader.service.js +19 -1
- package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
- package/dist/pam/identity/admin-user-directory.d.ts +1 -1
- package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
- package/dist/pam/identity/admin-user-directory.js +6 -3
- package/dist/pam/identity/admin-user-directory.js.map +1 -1
- package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
- package/dist/pam/identity/service/identity.service.js +4 -0
- package/dist/pam/identity/service/identity.service.js.map +1 -1
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +5 -2
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.router.test.js +27 -20
- package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.service.test.js +30 -31
- package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
- package/dist/pam/player-management/contract/index.d.ts +37 -9
- package/dist/pam/player-management/contract/index.d.ts.map +1 -1
- package/dist/pam/player-management/contract/index.js +0 -1
- package/dist/pam/player-management/contract/index.js.map +1 -1
- package/dist/pam/player-management/plugin.d.ts.map +1 -1
- package/dist/pam/player-management/plugin.js +3 -2
- package/dist/pam/player-management/plugin.js.map +1 -1
- package/dist/pam/player-management/router/index.d.ts +32 -10
- package/dist/pam/player-management/router/index.d.ts.map +1 -1
- package/dist/pam/player-management/router/index.js +48 -16
- package/dist/pam/player-management/router/index.js.map +1 -1
- package/dist/pam/player-management/service/kyc-status-writer.d.ts +3 -2
- package/dist/pam/player-management/service/kyc-status-writer.d.ts.map +1 -1
- package/dist/pam/player-management/service/kyc-status-writer.js +24 -10
- package/dist/pam/player-management/service/kyc-status-writer.js.map +1 -1
- package/dist/pam/player-management/service/player.service.d.ts +10 -9
- package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
- package/dist/pam/player-management/service/player.service.js +24 -12
- package/dist/pam/player-management/service/player.service.js.map +1 -1
- package/dist/pam/profile/contract/index.d.ts +2 -0
- package/dist/pam/profile/contract/index.d.ts.map +1 -1
- package/dist/pam/profile/drizzle/migrations/0001_powerful_tiger_shark.sql +1 -0
- package/dist/pam/profile/drizzle/migrations/meta/0001_snapshot.json +191 -0
- package/dist/pam/profile/drizzle/migrations/meta/_journal.json +7 -0
- package/dist/pam/profile/react/profile.d.ts +2 -2
- package/dist/pam/profile/router/index.d.ts +2 -0
- package/dist/pam/profile/router/index.d.ts.map +1 -1
- package/dist/pam/profile/schema/index.d.ts +3 -3
- package/dist/pam/profile/schema/index.d.ts.map +1 -1
- package/dist/pam/profile/service/profile.service.d.ts +2 -2
- package/dist/pam/shared/player-mapper.d.ts +1 -1
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +619 -19
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-mappers.test.js +25 -0
- package/dist/pam/tag/__tests__/tag-mappers.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.router.test.js +71 -1
- package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.service.test.js +342 -15
- package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
- package/dist/pam/tag/contract/index.d.ts +123 -0
- package/dist/pam/tag/contract/index.d.ts.map +1 -1
- package/dist/pam/tag/contract/index.js +3 -0
- package/dist/pam/tag/contract/index.js.map +1 -1
- package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts +37 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts.map +1 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.js +20 -0
- package/dist/pam/tag/contract/player-tag-assign-metadata.js.map +1 -0
- package/dist/pam/tag/drizzle/migrations/0002_silent_skaar.sql +4 -0
- package/dist/pam/tag/drizzle/migrations/0003_overconfident_guardian.sql +3 -0
- package/dist/pam/tag/drizzle/migrations/0004_curvy_wither.sql +2 -0
- package/dist/pam/tag/drizzle/migrations/meta/0002_snapshot.json +335 -0
- package/dist/pam/tag/drizzle/migrations/meta/0003_snapshot.json +343 -0
- package/dist/pam/tag/drizzle/migrations/meta/0004_snapshot.json +345 -0
- package/dist/pam/tag/drizzle/migrations/meta/_journal.json +21 -0
- package/dist/pam/tag/migrate.d.ts.map +1 -1
- package/dist/pam/tag/migrate.js +34 -0
- package/dist/pam/tag/migrate.js.map +1 -1
- package/dist/pam/tag/plugin.d.ts.map +1 -1
- package/dist/pam/tag/plugin.js +43 -16
- package/dist/pam/tag/plugin.js.map +1 -1
- package/dist/pam/tag/router/index.d.ts +105 -0
- package/dist/pam/tag/router/index.d.ts.map +1 -1
- package/dist/pam/tag/router/index.js +34 -18
- package/dist/pam/tag/router/index.js.map +1 -1
- package/dist/pam/tag/schema/index.d.ts +42 -3
- package/dist/pam/tag/schema/index.d.ts.map +1 -1
- package/dist/pam/tag/schema/index.js +13 -2
- package/dist/pam/tag/schema/index.js.map +1 -1
- package/dist/pam/tag/seed/data/default-tag-rules.d.ts.map +1 -1
- package/dist/pam/tag/seed/data/default-tag-rules.js +42 -0
- package/dist/pam/tag/seed/data/default-tag-rules.js.map +1 -1
- package/dist/pam/tag/seed/data/default-tags.d.ts.map +1 -1
- package/dist/pam/tag/seed/data/default-tags.js +6 -0
- package/dist/pam/tag/seed/data/default-tags.js.map +1 -1
- package/dist/pam/tag/service/tag-evaluation.service.d.ts +141 -16
- package/dist/pam/tag/service/tag-evaluation.service.d.ts.map +1 -1
- package/dist/pam/tag/service/tag-evaluation.service.js +472 -47
- package/dist/pam/tag/service/tag-evaluation.service.js.map +1 -1
- package/dist/pam/tag/service/tag-mappers.d.ts +1 -1
- package/dist/pam/tag/service/tag-mappers.d.ts.map +1 -1
- package/dist/pam/tag/service/tag-mappers.js +1 -0
- package/dist/pam/tag/service/tag-mappers.js.map +1 -1
- package/dist/pam/tag/service/tag-rule.service.d.ts +3 -3
- package/dist/pam/tag/service/tag.service.d.ts +141 -11
- package/dist/pam/tag/service/tag.service.d.ts.map +1 -1
- package/dist/pam/tag/service/tag.service.js +242 -23
- package/dist/pam/tag/service/tag.service.js.map +1 -1
- package/dist/server/auth/permissions.d.ts +12 -0
- package/dist/server/auth/permissions.d.ts.map +1 -1
- package/dist/server/auth/permissions.js +2 -0
- package/dist/server/auth/permissions.js.map +1 -1
- package/dist/server/db/__tests__/migrate.test.js +79 -2
- package/dist/server/db/__tests__/migrate.test.js.map +1 -1
- package/dist/server/db/migrate.d.ts +11 -0
- package/dist/server/db/migrate.d.ts.map +1 -1
- package/dist/server/db/migrate.js +3 -0
- package/dist/server/db/migrate.js.map +1 -1
- package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
- package/dist/server/kernel/bullmq-job-queue.js +17 -0
- package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
- package/dist/server/plugin-host/__tests__/core-plugins.test.js +1 -1
- package/dist/server/plugin-host/__tests__/core-plugins.test.js.map +1 -1
- package/dist/server/plugin-host/define-plugin.d.ts +2 -2
- package/dist/server/plugin-host/define-plugin.d.ts.map +1 -1
- package/dist/server/plugin-host/define-plugin.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +60 -0
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.test.js +59 -0
- package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
- package/dist/wallet/adapters/wallet-reader.service.d.ts +1 -0
- package/dist/wallet/adapters/wallet-reader.service.d.ts.map +1 -1
- package/dist/wallet/adapters/wallet-reader.service.js +18 -1
- package/dist/wallet/adapters/wallet-reader.service.js.map +1 -1
- package/dist/wallet/contract/index.d.ts +4 -0
- package/dist/wallet/contract/index.d.ts.map +1 -1
- package/dist/wallet/plugin.d.ts.map +1 -1
- package/dist/wallet/plugin.js +7 -4
- package/dist/wallet/plugin.js.map +1 -1
- package/dist/wallet/router/index.d.ts +2 -0
- package/dist/wallet/router/index.d.ts.map +1 -1
- package/dist/wallet/service/wallet.service.d.ts +5 -4
- package/dist/wallet/service/wallet.service.d.ts.map +1 -1
- package/dist/wallet/service/wallet.service.js +28 -8
- package/dist/wallet/service/wallet.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { domainEventSchemas, } from '@openora/core/contracts';
|
|
1
|
+
import { domainEventSchemas, normalizeKycStatus, } from '@openora/core/contracts';
|
|
2
2
|
import { moneyToNumber, mapConcurrent } from '@openora/core/server';
|
|
3
3
|
import { TagAlreadyInUseError, TagAssignmentNotFoundError } from './tag.service.js';
|
|
4
4
|
import { TagRuleNotFoundError } from './tag-rule.service.js';
|
|
5
5
|
import { SYSTEM_ACTOR_ID } from './tag-mappers.js';
|
|
6
6
|
const EVAL_CHUNK_SIZE = 100;
|
|
7
|
+
const MULTI_ACCOUNT_REASON = 'identity signal matched another player account';
|
|
8
|
+
const BONUS_ABUSER_REASON = 'multi-account risk rule matched';
|
|
9
|
+
function isPendingKycStatus(status) {
|
|
10
|
+
return status === 'pending' || status === 'resubmission_requested';
|
|
11
|
+
}
|
|
7
12
|
export class TagEvaluationService {
|
|
8
13
|
tag;
|
|
9
14
|
rule;
|
|
10
15
|
walletReader;
|
|
11
16
|
identityReader;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
+
adminUserDirectory;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.tag = deps.tag;
|
|
20
|
+
this.rule = deps.rule;
|
|
21
|
+
this.walletReader = deps.walletReader;
|
|
22
|
+
this.identityReader = deps.identityReader;
|
|
23
|
+
this.adminUserDirectory = deps.adminUserDirectory;
|
|
17
24
|
}
|
|
18
25
|
/** Loads a rule; returns null when it does not exist or is disabled. Never throws for a missing row. */
|
|
19
26
|
async getEnabledRule(tagKey) {
|
|
@@ -32,7 +39,8 @@ export class TagEvaluationService {
|
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
/** Idempotently assigns a tag; swallows TagAlreadyInUseError. */
|
|
35
|
-
async tryAssignTag(
|
|
42
|
+
async tryAssignTag(args) {
|
|
43
|
+
const { userId, tagKey, reason, assignMetadata } = args;
|
|
36
44
|
const playerId = await this.identityReader.getPlayerIdByUserId(userId);
|
|
37
45
|
if (!playerId) {
|
|
38
46
|
return;
|
|
@@ -44,6 +52,7 @@ export class TagEvaluationService {
|
|
|
44
52
|
assignReason: reason,
|
|
45
53
|
assignActor: 'scheduled',
|
|
46
54
|
assignActorUserId: SYSTEM_ACTOR_ID,
|
|
55
|
+
assignMetadata,
|
|
47
56
|
});
|
|
48
57
|
}
|
|
49
58
|
catch (e) {
|
|
@@ -54,7 +63,8 @@ export class TagEvaluationService {
|
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
/** Idempotently removes a tag; swallows TagAssignmentNotFoundError. */
|
|
57
|
-
async tryRemoveTag(
|
|
66
|
+
async tryRemoveTag(args) {
|
|
67
|
+
const { userId, tagKey, reason } = args;
|
|
58
68
|
const playerId = await this.identityReader.getPlayerIdByUserId(userId);
|
|
59
69
|
if (!playerId) {
|
|
60
70
|
return;
|
|
@@ -80,7 +90,11 @@ export class TagEvaluationService {
|
|
|
80
90
|
* Called on wallet.deposit.completed.
|
|
81
91
|
*/
|
|
82
92
|
async onDepositCompleted(payload) {
|
|
83
|
-
const
|
|
93
|
+
const parsed = domainEventSchemas['wallet.deposit.completed'].safeParse(payload);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { userId, amount } = parsed.data;
|
|
84
98
|
const [highRoller, largeDepositor] = await Promise.all([
|
|
85
99
|
this.getEnabledRule('high_roller'),
|
|
86
100
|
this.getEnabledRule('large_depositor'),
|
|
@@ -90,95 +104,434 @@ export class TagEvaluationService {
|
|
|
90
104
|
if (largeDepositor &&
|
|
91
105
|
largeDepositor.threshold !== null &&
|
|
92
106
|
moneyToNumber(amount) >= moneyToNumber(largeDepositor.threshold)) {
|
|
93
|
-
await this.tryAssignTag(
|
|
107
|
+
await this.tryAssignTag({
|
|
108
|
+
userId,
|
|
109
|
+
tagKey: 'large_depositor',
|
|
110
|
+
reason: 'single deposit crossed threshold',
|
|
111
|
+
});
|
|
94
112
|
}
|
|
95
113
|
if (highRoller && highRoller.threshold !== null) {
|
|
96
114
|
const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId);
|
|
97
115
|
if (moneyToNumber(lifetimeDeposit) >= moneyToNumber(highRoller.threshold)) {
|
|
98
|
-
await this.tryAssignTag(
|
|
116
|
+
await this.tryAssignTag({
|
|
117
|
+
userId,
|
|
118
|
+
tagKey: 'high_roller',
|
|
119
|
+
reason: 'lifetime deposits crossed threshold',
|
|
120
|
+
});
|
|
99
121
|
}
|
|
100
122
|
else {
|
|
101
|
-
await this.tryRemoveTag(
|
|
123
|
+
await this.tryRemoveTag({
|
|
124
|
+
userId,
|
|
125
|
+
tagKey: 'high_roller',
|
|
126
|
+
reason: 'lifetime deposits below threshold',
|
|
127
|
+
});
|
|
102
128
|
}
|
|
103
129
|
}
|
|
104
130
|
}
|
|
131
|
+
/** True when the withdrawal_review rule is enabled and amount crosses its threshold. Shared by the event path and the synchronous command-port path below so both stay in lockstep. */
|
|
132
|
+
async _withdrawalReviewThresholdCrossed(amount) {
|
|
133
|
+
const rule = await this.getEnabledRule('withdrawal_review');
|
|
134
|
+
if (!rule || rule.threshold === null) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return moneyToNumber(amount) >= moneyToNumber(rule.threshold);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Assigns withdrawal_review when a single withdrawal attempt exceeds its threshold.
|
|
141
|
+
* Called on wallet.withdrawal.requested (the attempt, before funds move) so the
|
|
142
|
+
* review tag lands before payout. Sticky - never auto-removed here or anywhere else.
|
|
143
|
+
* This is the async, best-effort fan-out path (other consumers may want the event) -
|
|
144
|
+
* wallet's own auto-approval decision does NOT wait on this; it calls
|
|
145
|
+
* evaluateWithdrawalRequested below instead. Idempotent with that path via
|
|
146
|
+
* TagAlreadyInUseError (see tryAssignTag).
|
|
147
|
+
*/
|
|
148
|
+
async onWithdrawalRequested(payload) {
|
|
149
|
+
const { userId, amount } = domainEventSchemas['wallet.withdrawal.requested'].parse(payload);
|
|
150
|
+
if (await this._withdrawalReviewThresholdCrossed(amount)) {
|
|
151
|
+
await this.tryAssignTag({
|
|
152
|
+
userId,
|
|
153
|
+
tagKey: 'withdrawal_review',
|
|
154
|
+
reason: 'single withdrawal attempt exceeded review threshold',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Synchronous, transactional counterpart to onWithdrawalRequested, bound behind
|
|
160
|
+
* TAG_EVALUATION_COMMANDS. wallet.withdraw() awaits this on its own transaction handle
|
|
161
|
+
* BEFORE its maybeAutoApprove reads risk tags, so a withdrawal_review assignment is
|
|
162
|
+
* guaranteed committed before the auto-approval decision runs - closing the race the
|
|
163
|
+
* fire-and-forget EventBus emit above cannot (an EventBus emit is never awaited by the
|
|
164
|
+
* emitter). Money-adjacent: never events-only for this decision (see
|
|
165
|
+
* messaging-and-microservices "money never flows over events"). Any error here
|
|
166
|
+
* propagates and aborts the caller's withdrawal transaction - a review-gate evaluation
|
|
167
|
+
* failure must block the withdrawal rather than silently skip review.
|
|
168
|
+
*/
|
|
169
|
+
async evaluateWithdrawalRequested(tx, args) {
|
|
170
|
+
const { userId, amount } = args;
|
|
171
|
+
if (!(await this._withdrawalReviewThresholdCrossed(amount))) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const playerId = await this.identityReader.getPlayerIdByUserId(userId);
|
|
175
|
+
if (!playerId) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const trx = tx;
|
|
179
|
+
try {
|
|
180
|
+
await this.tag.assignPlayerTagInTx(trx, {
|
|
181
|
+
playerId,
|
|
182
|
+
tagKey: 'withdrawal_review',
|
|
183
|
+
assignReason: 'single withdrawal attempt exceeded review threshold',
|
|
184
|
+
assignActor: 'scheduled',
|
|
185
|
+
assignActorUserId: SYSTEM_ACTOR_ID,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
if (e instanceof TagAlreadyInUseError) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
await this.evaluateBasicKycNeededOnDeposit(userId);
|
|
195
|
+
}
|
|
196
|
+
async evaluateBasicKycNeededOnDeposit(userId) {
|
|
197
|
+
const rule = await this.getEnabledRule('basic_kyc_needed');
|
|
198
|
+
if (!rule) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const [summary] = await this.adminUserDirectory.lookupPlayers([userId]);
|
|
202
|
+
const status = summary?.kycStatus ? normalizeKycStatus(summary.kycStatus) : null;
|
|
203
|
+
if (status === 'not_started' || status === 'rejected') {
|
|
204
|
+
await this.tryAssignTag({
|
|
205
|
+
userId,
|
|
206
|
+
tagKey: 'basic_kyc_needed',
|
|
207
|
+
reason: 'no approved KYC on file despite a completed deposit',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
105
211
|
/**
|
|
106
212
|
* Evaluates the high_risk rule based on withdrawal amount and frequency.
|
|
107
|
-
* Called on wallet.withdrawal.completed.
|
|
108
|
-
*
|
|
109
|
-
* (
|
|
213
|
+
* Called on wallet.withdrawal.completed. Only assigns the tag when a threshold is
|
|
214
|
+
* crossed; removal is handled separately by the daily resweep in runDailyEvaluation
|
|
215
|
+
* (a rolling-window count/amount can drop back under threshold with no new
|
|
216
|
+
* withdrawal, so it can't be detected event-driven-only). Records which breach
|
|
217
|
+
* condition(s) fired in assignMetadata - the resweep uses this to know whether the
|
|
218
|
+
* amount dimension (never resweepable, see _runHighRiskResweep) was involved.
|
|
110
219
|
*/
|
|
111
220
|
async onWithdrawalCompleted(payload) {
|
|
112
|
-
const
|
|
221
|
+
const parsed = domainEventSchemas['wallet.withdrawal.completed'].safeParse(payload);
|
|
222
|
+
if (!parsed.success) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const { userId, amount } = parsed.data;
|
|
113
226
|
const rule = await this.getEnabledRule('high_risk');
|
|
114
227
|
if (!rule) {
|
|
115
228
|
return;
|
|
116
229
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
230
|
+
const amountBreach = rule.threshold !== null && moneyToNumber(amount) >= moneyToNumber(rule.threshold)
|
|
231
|
+
? { amount, threshold: rule.threshold }
|
|
232
|
+
: null;
|
|
233
|
+
let countBreach = null;
|
|
119
234
|
if (rule.thresholdDays !== null && rule.thresholdCount !== null) {
|
|
120
235
|
const count = await this.walletReader.getWithdrawalCountInWindow(userId, rule.thresholdDays);
|
|
121
|
-
|
|
236
|
+
if (count >= rule.thresholdCount) {
|
|
237
|
+
countBreach = {
|
|
238
|
+
count,
|
|
239
|
+
thresholdCount: rule.thresholdCount,
|
|
240
|
+
thresholdDays: rule.thresholdDays,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
122
243
|
}
|
|
123
|
-
if (
|
|
124
|
-
await this.tryAssignTag(
|
|
244
|
+
if (amountBreach || countBreach) {
|
|
245
|
+
await this.tryAssignTag({
|
|
246
|
+
userId,
|
|
247
|
+
tagKey: 'high_risk',
|
|
248
|
+
reason: 'withdrawal amount or frequency crossed threshold',
|
|
249
|
+
assignMetadata: { amountBreach, countBreach },
|
|
250
|
+
});
|
|
125
251
|
}
|
|
126
252
|
}
|
|
127
253
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
254
|
+
* Handles password login side effects:
|
|
255
|
+
* - removes inactive/dormant_high_roller on activity
|
|
256
|
+
* - assigns multi_account + bonus_abuser when another player has used this login IP
|
|
130
257
|
*/
|
|
131
258
|
async onUserLogin(payload) {
|
|
132
|
-
const
|
|
133
|
-
|
|
259
|
+
const parsed = domainEventSchemas['identity.user.login'].safeParse(payload);
|
|
260
|
+
if (!parsed.success) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const { userId, ip } = parsed.data;
|
|
264
|
+
await this.onAuthenticatedLogin({ userId, ip });
|
|
134
265
|
}
|
|
135
266
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
267
|
+
* Phone-login path mirrors password-login risk evaluation. The signal is the same
|
|
268
|
+
* authenticated session IP; the login method is intentionally not encoded in the tag.
|
|
269
|
+
*/
|
|
270
|
+
async onUserPhoneLogin(payload) {
|
|
271
|
+
const { userId, ip } = domainEventSchemas['identity.user.phone_login'].parse(payload);
|
|
272
|
+
await this.onAuthenticatedLogin({ userId, ip });
|
|
273
|
+
}
|
|
274
|
+
async onAuthenticatedLogin(args) {
|
|
275
|
+
const { userId, ip } = args;
|
|
276
|
+
await this.tryRemoveTag({ userId, tagKey: 'inactive', reason: 'player logged in' });
|
|
277
|
+
await this.tryRemoveTag({ userId, tagKey: 'dormant_high_roller', reason: 'player logged in' });
|
|
278
|
+
await this.evaluateSharedLoginIp(userId, ip);
|
|
279
|
+
}
|
|
280
|
+
async evaluateSharedLoginIp(userId, ip) {
|
|
281
|
+
const normalizedIp = ip?.trim();
|
|
282
|
+
if (!normalizedIp) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const [multiAccountRule, bonusAbuserRule] = await Promise.all([
|
|
286
|
+
this.getEnabledRule('multi_account'),
|
|
287
|
+
this.getEnabledRule('bonus_abuser'),
|
|
288
|
+
]);
|
|
289
|
+
if (!multiAccountRule && !bonusAbuserRule) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const linkedUserIds = await this.identityReader.getPlayerUserIdsSharingLoginIp(userId, normalizedIp);
|
|
293
|
+
if (linkedUserIds.length === 0) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const userIds = [...new Set([userId, ...linkedUserIds])];
|
|
297
|
+
await mapConcurrent(userIds, EVAL_CHUNK_SIZE, async (linkedUserId) => {
|
|
298
|
+
if (multiAccountRule) {
|
|
299
|
+
await this.tryAssignTag({
|
|
300
|
+
userId: linkedUserId,
|
|
301
|
+
tagKey: 'multi_account',
|
|
302
|
+
reason: MULTI_ACCOUNT_REASON,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (bonusAbuserRule) {
|
|
306
|
+
await this.tryAssignTag({
|
|
307
|
+
userId: linkedUserId,
|
|
308
|
+
tagKey: 'bonus_abuser',
|
|
309
|
+
reason: BONUS_ABUSER_REASON,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Applies kyc_pending on KYC submission only while the profile still has a pending-like
|
|
316
|
+
* status, and clears basic_kyc_needed / advanced_kyc_needed. Does not clear kyc_rejected:
|
|
317
|
+
* that sticky tag remains until a later approved state explicitly clears it.
|
|
138
318
|
*/
|
|
139
319
|
async onKycSubmitted(payload) {
|
|
140
|
-
const
|
|
320
|
+
const parsed = domainEventSchemas['compliance.kyc.submitted'].safeParse(payload);
|
|
321
|
+
if (!parsed.success) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const { userId } = parsed.data;
|
|
141
325
|
const rule = await this.getEnabledRule('kyc_pending');
|
|
142
326
|
if (!rule) {
|
|
143
327
|
return;
|
|
144
328
|
}
|
|
145
|
-
await this.
|
|
146
|
-
await this.
|
|
329
|
+
await this.removeKycNeededTags(userId, 'kyc resubmitted');
|
|
330
|
+
const status = await this.identityReader.getPlayerKycStatusByUserId(userId);
|
|
331
|
+
if (!status || !isPendingKycStatus(status)) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await this.tryAssignTag({
|
|
335
|
+
userId,
|
|
336
|
+
tagKey: 'kyc_pending',
|
|
337
|
+
reason: 'kyc verification initiated',
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
async removeKycNeededTags(userId, reason) {
|
|
341
|
+
for (const tagKey of ['basic_kyc_needed', 'advanced_kyc_needed']) {
|
|
342
|
+
const rule = await this.getEnabledRule(tagKey);
|
|
343
|
+
if (rule) {
|
|
344
|
+
await this.tryRemoveTag({ userId, tagKey, reason });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async evaluateBasicKycNeededOnRejection(userId) {
|
|
349
|
+
const rule = await this.getEnabledRule('basic_kyc_needed');
|
|
350
|
+
if (!rule) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId);
|
|
354
|
+
if (moneyToNumber(lifetimeDeposit) > 0) {
|
|
355
|
+
await this.tryAssignTag({
|
|
356
|
+
userId,
|
|
357
|
+
tagKey: 'basic_kyc_needed',
|
|
358
|
+
reason: 'kyc rejected with a completed deposit on file',
|
|
359
|
+
});
|
|
360
|
+
}
|
|
147
361
|
}
|
|
148
362
|
/**
|
|
149
|
-
* Manages kyc_pending / kyc_rejected
|
|
150
|
-
* Called on compliance.kyc.updated.
|
|
363
|
+
* Manages kyc_pending / kyc_rejected / basic_kyc_needed / advanced_kyc_needed lifecycle
|
|
364
|
+
* on KYC status changes. Called on compliance.kyc.updated. Assignment respects each tag
|
|
365
|
+
* rule's isEnabled flag; terminal cleanup still runs so disabled rules cannot strand old
|
|
366
|
+
* active rows.
|
|
151
367
|
*
|
|
152
368
|
* Transitions:
|
|
153
|
-
*
|
|
154
|
-
* rejected -> remove kyc_pending, assign kyc_rejected
|
|
155
|
-
* resubmission_requested -> assign kyc_pending
|
|
369
|
+
* approved / manually_overridden -> remove kyc_pending + kyc_rejected + basic_kyc_needed + advanced_kyc_needed
|
|
370
|
+
* rejected -> remove kyc_pending, assign kyc_rejected, evaluate basic_kyc_needed
|
|
371
|
+
* resubmission_requested -> assign kyc_pending, keep kyc_rejected sticky
|
|
156
372
|
*/
|
|
157
373
|
async onKycStatusUpdated(payload) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
if (!rule) {
|
|
374
|
+
const parsed = domainEventSchemas['compliance.kyc.updated'].safeParse(payload);
|
|
375
|
+
if (!parsed.success) {
|
|
161
376
|
return;
|
|
162
377
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
await this.tryRemoveTag(userId, '
|
|
378
|
+
const { userId, status } = parsed.data;
|
|
379
|
+
if (normalizeKycStatus(status) === 'approved' || status === 'manually_overridden') {
|
|
380
|
+
await this.tryRemoveTag({ userId, tagKey: 'kyc_pending', reason: 'kyc approved' });
|
|
381
|
+
await this.tryRemoveTag({ userId, tagKey: 'kyc_rejected', reason: 'kyc approved' });
|
|
382
|
+
await this.removeKycNeededTags(userId, 'kyc approved');
|
|
166
383
|
return;
|
|
167
384
|
}
|
|
168
385
|
if (status === 'rejected') {
|
|
169
|
-
await this.tryRemoveTag(userId, 'kyc_pending', 'kyc rejected');
|
|
170
|
-
await this.
|
|
386
|
+
await this.tryRemoveTag({ userId, tagKey: 'kyc_pending', reason: 'kyc rejected' });
|
|
387
|
+
const rule = await this.getEnabledRule('kyc_rejected');
|
|
388
|
+
if (rule) {
|
|
389
|
+
await this.tryAssignTag({
|
|
390
|
+
userId,
|
|
391
|
+
tagKey: 'kyc_rejected',
|
|
392
|
+
reason: 'kyc verification rejected',
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
await this.evaluateBasicKycNeededOnRejection(userId);
|
|
171
396
|
return;
|
|
172
397
|
}
|
|
173
398
|
if (status === 'resubmission_requested') {
|
|
174
|
-
await this.
|
|
399
|
+
const rule = await this.getEnabledRule('kyc_pending');
|
|
400
|
+
if (rule) {
|
|
401
|
+
await this.tryAssignTag({
|
|
402
|
+
userId,
|
|
403
|
+
tagKey: 'kyc_pending',
|
|
404
|
+
reason: 'kyc re-verification required',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Assigns advanced_kyc_needed as a pure label over compliance's own re-KYC decision.
|
|
411
|
+
* Called on compliance.kyc.reverify_required. Respects the advanced_kyc_needed rule's
|
|
412
|
+
* isEnabled flag.
|
|
413
|
+
*/
|
|
414
|
+
async onKycReverifyRequired(payload) {
|
|
415
|
+
const parsed = domainEventSchemas['compliance.kyc.reverify_required'].safeParse(payload);
|
|
416
|
+
if (!parsed.success) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const { userId } = parsed.data;
|
|
420
|
+
const rule = await this.getEnabledRule('advanced_kyc_needed');
|
|
421
|
+
if (!rule) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await this.tryAssignTag({
|
|
425
|
+
userId,
|
|
426
|
+
tagKey: 'advanced_kyc_needed',
|
|
427
|
+
reason: 'cumulative deposits crossed the re-KYC threshold',
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Assigns high_risk as a pure label over compliance's own device/IP screening decision.
|
|
432
|
+
* Called on compliance.kyc.high_risk_signal_detected. Respects the high_risk rule's
|
|
433
|
+
* isEnabled flag; never auto-removes (risk designation requires an explicit admin clear).
|
|
434
|
+
*/
|
|
435
|
+
async onKycHighRiskSignalDetected(payload) {
|
|
436
|
+
const parsed = domainEventSchemas['compliance.kyc.high_risk_signal_detected'].safeParse(payload);
|
|
437
|
+
if (!parsed.success) {
|
|
438
|
+
return;
|
|
175
439
|
}
|
|
440
|
+
const { userId } = parsed.data;
|
|
441
|
+
const rule = await this.getEnabledRule('high_risk');
|
|
442
|
+
if (!rule) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
await this.tryAssignTag({
|
|
446
|
+
userId,
|
|
447
|
+
tagKey: 'high_risk',
|
|
448
|
+
reason: 'kyc device/IP screening flagged high risk',
|
|
449
|
+
});
|
|
176
450
|
}
|
|
177
451
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
452
|
+
* Assigns self_excluded when a player activates self-exclusion.
|
|
453
|
+
* Called on rg.self_exclusion.activated.
|
|
454
|
+
*/
|
|
455
|
+
async onSelfExclusionActivated(payload) {
|
|
456
|
+
const { userId } = domainEventSchemas['rg.self_exclusion.activated'].parse(payload);
|
|
457
|
+
await this.tryAssignTag({
|
|
458
|
+
userId,
|
|
459
|
+
tagKey: 'self_excluded',
|
|
460
|
+
reason: 'self-exclusion activated',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Removes self_excluded when a player's self-exclusion is lifted.
|
|
465
|
+
* Called on rg.self_exclusion.lifted.
|
|
466
|
+
*/
|
|
467
|
+
async onSelfExclusionLifted(payload) {
|
|
468
|
+
const { userId } = domainEventSchemas['rg.self_exclusion.lifted'].parse(payload);
|
|
469
|
+
await this.tryRemoveTag({ userId, tagKey: 'self_excluded', reason: 'self-exclusion lifted' });
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Replaces the single mutable `level` tag with the player's new level.
|
|
473
|
+
* Called on player.level.changed (emitted by PlayerService.update() after commit).
|
|
474
|
+
* Not sticky - the level value lives only in assignReason text, never as a separate
|
|
475
|
+
* tag key per level. The swap goes through TagService.replacePlayerTag: removal of
|
|
476
|
+
* the prior active row (silent no-op when absent) + insert of the new one on ONE
|
|
477
|
+
* transaction, so a mid-swap failure can never strand the player with the old row
|
|
478
|
+
* removed and no new one committed. The loser of a concurrent replace throws
|
|
479
|
+
* TagAlreadyInUseError with nothing committed - swallowed here as the usual
|
|
480
|
+
* idempotent no-op (player.level itself stays the source of truth).
|
|
481
|
+
*/
|
|
482
|
+
async onPlayerLevelChanged(payload) {
|
|
483
|
+
const { userId, newLevel } = domainEventSchemas['player.level.changed'].parse(payload);
|
|
484
|
+
const playerId = await this.identityReader.getPlayerIdByUserId(userId);
|
|
485
|
+
if (!playerId) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await this.tag.replacePlayerTag({
|
|
490
|
+
playerId,
|
|
491
|
+
tagKey: 'level',
|
|
492
|
+
removalReason: 'player level changed',
|
|
493
|
+
assignReason: `player level set to ${newLevel}`,
|
|
494
|
+
assignActor: 'scheduled',
|
|
495
|
+
assignActorUserId: SYSTEM_ACTOR_ID,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (e) {
|
|
499
|
+
if (e instanceof TagAlreadyInUseError) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
throw e;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Daily scheduled evaluation, three independent sweeps:
|
|
507
|
+
* - inactive: assigns to players who have not logged in for rule.thresholdDays
|
|
508
|
+
* days. Removal is handled by onUserLogin on next login.
|
|
509
|
+
* - dormant_high_roller: a co-occurrence check, not a history lookup - high_roller
|
|
510
|
+
* is only re-evaluated on deposit, so a player who goes quiet never loses it via
|
|
511
|
+
* that path. Assigns to players who are both currently inactive (per this tag's
|
|
512
|
+
* OWN thresholdDays, decoupled from the inactive rule's) and currently hold an
|
|
513
|
+
* active high_roller tag. Removal is handled by onUserLogin on next login.
|
|
514
|
+
* - high_risk resweep: removal-only, frequency dimension only. A rolling
|
|
515
|
+
* withdrawal-count window can drop back under threshold with no new withdrawal
|
|
516
|
+
* to trigger onWithdrawalCompleted, so current holders are rechecked here and
|
|
517
|
+
* removed once the count falls below threshold. Deliberately does NOT recheck
|
|
518
|
+
* the amount dimension - assignment's amount check has no time bound, so a
|
|
519
|
+
* windowed recheck would silently de-designate a still-risky player once their
|
|
520
|
+
* triggering withdrawal ages out of the window (an AML false negative). Now gated
|
|
521
|
+
* on each holder's assignMetadata (recorded at assignment): a holder whose
|
|
522
|
+
* metadata shows an amount breach is skipped regardless of their current count,
|
|
523
|
+
* and a holder with null metadata (pre-migration/legacy row, or manually
|
|
524
|
+
* assigned) is also skipped - "unknown why this was assigned" is treated as
|
|
525
|
+
* "do not auto-remove". Skipped entirely when thresholdDays or thresholdCount is
|
|
526
|
+
* null - an amount-only high_risk rule isn't resweepable by the cron; it stays
|
|
527
|
+
* until an admin clears it or a future withdrawal re-triggers assignment.
|
|
180
528
|
*/
|
|
181
529
|
async runDailyEvaluation() {
|
|
530
|
+
await this._runInactiveSweep();
|
|
531
|
+
await this._runDormantHighRollerSweep();
|
|
532
|
+
await this._runHighRiskResweep();
|
|
533
|
+
}
|
|
534
|
+
async _runInactiveSweep() {
|
|
182
535
|
const rule = await this.getEnabledRule('inactive');
|
|
183
536
|
if (!rule || rule.thresholdDays === null) {
|
|
184
537
|
return;
|
|
@@ -187,7 +540,79 @@ export class TagEvaluationService {
|
|
|
187
540
|
const userIds = await this.identityReader.getPlayerIdsInactiveSince(sinceDate);
|
|
188
541
|
// Capped fan-out: the inactive-player set is unbounded, so hold at most EVAL_CHUNK_SIZE
|
|
189
542
|
// assignments in flight rather than one giant Promise.all that would exhaust the pool.
|
|
190
|
-
await mapConcurrent(userIds, EVAL_CHUNK_SIZE, (userId) => this.tryAssignTag(
|
|
543
|
+
await mapConcurrent(userIds, EVAL_CHUNK_SIZE, (userId) => this.tryAssignTag({
|
|
544
|
+
userId,
|
|
545
|
+
tagKey: 'inactive',
|
|
546
|
+
reason: 'no login for configured threshold',
|
|
547
|
+
}));
|
|
548
|
+
}
|
|
549
|
+
async _runDormantHighRollerSweep() {
|
|
550
|
+
const rule = await this.getEnabledRule('dormant_high_roller');
|
|
551
|
+
if (!rule || rule.thresholdDays === null) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const sinceDate = new Date(Date.now() - rule.thresholdDays * 24 * 60 * 60 * 1000);
|
|
555
|
+
const inactiveUserIds = await this.identityReader.getPlayerIdsInactiveSince(sinceDate);
|
|
556
|
+
const activeTagsByUser = await this.tag.getActiveTagKeys(inactiveUserIds);
|
|
557
|
+
const highRollerUserIds = inactiveUserIds.filter((userId) => (activeTagsByUser.get(userId) ?? []).includes('high_roller'));
|
|
558
|
+
await mapConcurrent(highRollerUserIds, EVAL_CHUNK_SIZE, (userId) => this.tryAssignTag({
|
|
559
|
+
userId,
|
|
560
|
+
tagKey: 'dormant_high_roller',
|
|
561
|
+
reason: 'holds high_roller and inactive for configured threshold',
|
|
562
|
+
}));
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Frequency-only removal. Assignment (onWithdrawalCompleted) checks a single
|
|
566
|
+
* withdrawal's amount with no time bound, so an amount-only breach from outside
|
|
567
|
+
* this rolling window is invisible to a windowed recheck - resweeping it would
|
|
568
|
+
* silently de-designate a still-risky player (an AML false negative). Only the
|
|
569
|
+
* count dimension, which IS windowed at assignment time, is safe to resweep. Each
|
|
570
|
+
* holder's assignMetadata (recorded at assignment) records which breach dimension(s)
|
|
571
|
+
* fired; a holder whose metadata shows an amount breach, or whose metadata is null
|
|
572
|
+
* (pre-migration/legacy row or manual assignment - unknown why it was assigned), is
|
|
573
|
+
* skipped regardless of their current windowed count - pinned until an admin
|
|
574
|
+
* manually clears the tag or a future onWithdrawalCompleted re-evaluation populates
|
|
575
|
+
* metadata. Skipped entirely when thresholdDays or thresholdCount is null - an
|
|
576
|
+
* amount-only rule has no frequency dimension to resweep against and is never
|
|
577
|
+
* auto-cleared by the cron, only by an admin or a future onWithdrawalCompleted
|
|
578
|
+
* re-evaluation.
|
|
579
|
+
*/
|
|
580
|
+
async _runHighRiskResweep() {
|
|
581
|
+
const rule = await this.getEnabledRule('high_risk');
|
|
582
|
+
if (!rule || rule.thresholdDays === null || rule.thresholdCount === null) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const { thresholdDays, thresholdCount } = rule;
|
|
586
|
+
const holders = await this.tag.listActiveHoldersByTagKey('high_risk');
|
|
587
|
+
if (holders.length === 0) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const holderUserIds = holders.map((h) => h.userId);
|
|
591
|
+
const metadataByUser = new Map(holders.map((h) => [h.userId, h.assignMetadata]));
|
|
592
|
+
// One set-based query for all holders' counts, then bound only the per-user
|
|
593
|
+
// removal writes - the reads are already batched, not per-row. The batched method
|
|
594
|
+
// is optional on the port (an external WalletReader may predate it), so fall back to
|
|
595
|
+
// bounded fan-out over the singular getWithdrawalCountInWindow when it's absent.
|
|
596
|
+
const countsByUser = this.walletReader.getWithdrawalCountsInWindow
|
|
597
|
+
? await this.walletReader.getWithdrawalCountsInWindow(holderUserIds, thresholdDays)
|
|
598
|
+
: new Map(await mapConcurrent(holderUserIds, EVAL_CHUNK_SIZE, async (userId) => {
|
|
599
|
+
const count = await this.walletReader.getWithdrawalCountInWindow(userId, thresholdDays);
|
|
600
|
+
return [userId, count];
|
|
601
|
+
}));
|
|
602
|
+
await mapConcurrent(holderUserIds, EVAL_CHUNK_SIZE, async (userId) => {
|
|
603
|
+
const metadata = metadataByUser.get(userId) ?? null;
|
|
604
|
+
if (!metadata || metadata.amountBreach) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const countInWindow = countsByUser.get(userId) ?? 0;
|
|
608
|
+
if (countInWindow < thresholdCount) {
|
|
609
|
+
await this.tryRemoveTag({
|
|
610
|
+
userId,
|
|
611
|
+
tagKey: 'high_risk',
|
|
612
|
+
reason: 'withdrawal frequency dropped below threshold within window',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
});
|
|
191
616
|
}
|
|
192
617
|
}
|
|
193
618
|
//# sourceMappingURL=tag-evaluation.service.js.map
|