@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.
Files changed (248) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/contract/index.d.ts +2 -0
  3. package/dist/admin-console/contract/index.d.ts.map +1 -1
  4. package/dist/admin-console/router/index.d.ts +1 -0
  5. package/dist/admin-console/router/index.d.ts.map +1 -1
  6. package/dist/admin-console/service/backoffice.service.d.ts +1 -1
  7. package/dist/audit/plugin.d.ts.map +1 -1
  8. package/dist/audit/plugin.js +48 -1
  9. package/dist/audit/plugin.js.map +1 -1
  10. package/dist/common/errors/errors.classes.js +3 -3
  11. package/dist/common/errors/errors.classes.js.map +1 -1
  12. package/dist/common/errors/errors.mapper.js +1 -1
  13. package/dist/common/errors/errors.mapper.js.map +1 -1
  14. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js +66 -12
  15. package/dist/compliance/__tests__/hmac-kyc-webhook-verifier.test.js.map +1 -1
  16. package/dist/compliance/__tests__/kyc-admin.router.test.d.ts +2 -0
  17. package/dist/compliance/__tests__/kyc-admin.router.test.d.ts.map +1 -0
  18. package/dist/compliance/__tests__/kyc-admin.router.test.js +191 -0
  19. package/dist/compliance/__tests__/kyc-admin.router.test.js.map +1 -0
  20. package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts +2 -0
  21. package/dist/compliance/__tests__/kyc-webhook.router.test.d.ts.map +1 -0
  22. package/dist/compliance/__tests__/kyc-webhook.router.test.js +148 -0
  23. package/dist/compliance/__tests__/kyc-webhook.router.test.js.map +1 -0
  24. package/dist/compliance/__tests__/kyc.service.test.js +16 -10
  25. package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
  26. package/dist/compliance/__tests__/rg.router.test.js +5 -1
  27. package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
  28. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts +16 -3
  29. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.d.ts.map +1 -1
  30. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js +47 -5
  31. package/dist/compliance/adapters/hmac-kyc-webhook-verifier.js.map +1 -1
  32. package/dist/compliance/contract/index.d.ts +312 -0
  33. package/dist/compliance/contract/index.d.ts.map +1 -1
  34. package/dist/compliance/contract/index.js +50 -1
  35. package/dist/compliance/contract/index.js.map +1 -1
  36. package/dist/compliance/drizzle/migrations/0002_condemned_monster_badoon.sql +3 -0
  37. package/dist/compliance/drizzle/migrations/0003_public_spirit.sql +1 -0
  38. package/dist/compliance/drizzle/migrations/meta/0002_snapshot.json +673 -0
  39. package/dist/compliance/drizzle/migrations/meta/0003_snapshot.json +679 -0
  40. package/dist/compliance/drizzle/migrations/meta/_journal.json +14 -0
  41. package/dist/compliance/plugin.d.ts.map +1 -1
  42. package/dist/compliance/plugin.js +23 -3
  43. package/dist/compliance/plugin.js.map +1 -1
  44. package/dist/compliance/router/index.d.ts +186 -2
  45. package/dist/compliance/router/index.d.ts.map +1 -1
  46. package/dist/compliance/router/index.js +52 -3
  47. package/dist/compliance/router/index.js.map +1 -1
  48. package/dist/compliance/schema/index.d.ts +65 -3
  49. package/dist/compliance/schema/index.d.ts.map +1 -1
  50. package/dist/compliance/schema/index.js +8 -0
  51. package/dist/compliance/schema/index.js.map +1 -1
  52. package/dist/compliance/service/kyc.service.d.ts +153 -8
  53. package/dist/compliance/service/kyc.service.d.ts.map +1 -1
  54. package/dist/compliance/service/kyc.service.js +264 -32
  55. package/dist/compliance/service/kyc.service.js.map +1 -1
  56. package/dist/contracts/adapters/audit.d.ts +1 -1
  57. package/dist/contracts/adapters/audit.d.ts.map +1 -1
  58. package/dist/contracts/adapters/audit.js.map +1 -1
  59. package/dist/contracts/adapters/identity-reader.d.ts +9 -3
  60. package/dist/contracts/adapters/identity-reader.d.ts.map +1 -1
  61. package/dist/contracts/adapters/identity-reader.js.map +1 -1
  62. package/dist/contracts/adapters/index.d.ts +4 -2
  63. package/dist/contracts/adapters/index.d.ts.map +1 -1
  64. package/dist/contracts/adapters/index.js +2 -1
  65. package/dist/contracts/adapters/index.js.map +1 -1
  66. package/dist/contracts/adapters/kyc.d.ts +90 -5
  67. package/dist/contracts/adapters/kyc.d.ts.map +1 -1
  68. package/dist/contracts/adapters/kyc.js +23 -0
  69. package/dist/contracts/adapters/kyc.js.map +1 -1
  70. package/dist/contracts/adapters/tag-evaluation-commands.d.ts +10 -0
  71. package/dist/contracts/adapters/tag-evaluation-commands.d.ts.map +1 -0
  72. package/dist/contracts/adapters/tag-evaluation-commands.js +11 -0
  73. package/dist/contracts/adapters/tag-evaluation-commands.js.map +1 -0
  74. package/dist/contracts/adapters/wallet-reader.d.ts +8 -0
  75. package/dist/contracts/adapters/wallet-reader.d.ts.map +1 -1
  76. package/dist/contracts/adapters/wallet-reader.js.map +1 -1
  77. package/dist/contracts/schemas/__tests__/events.test.js +1 -1
  78. package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts +2 -0
  79. package/dist/contracts/schemas/__tests__/player-schemas.test.d.ts.map +1 -0
  80. package/dist/contracts/schemas/__tests__/player-schemas.test.js +30 -0
  81. package/dist/contracts/schemas/__tests__/player-schemas.test.js.map +1 -0
  82. package/dist/contracts/schemas/events.d.ts +84 -0
  83. package/dist/contracts/schemas/events.d.ts.map +1 -1
  84. package/dist/contracts/schemas/events.js +24 -2
  85. package/dist/contracts/schemas/events.js.map +1 -1
  86. package/dist/contracts/schemas/platform-config.d.ts +13 -1
  87. package/dist/contracts/schemas/platform-config.d.ts.map +1 -1
  88. package/dist/contracts/schemas/player.d.ts +36 -3
  89. package/dist/contracts/schemas/player.d.ts.map +1 -1
  90. package/dist/contracts/schemas/player.js +14 -0
  91. package/dist/contracts/schemas/player.js.map +1 -1
  92. package/dist/contracts/schemas/tag.d.ts +85 -1
  93. package/dist/contracts/schemas/tag.d.ts.map +1 -1
  94. package/dist/contracts/schemas/tag.js +12 -0
  95. package/dist/contracts/schemas/tag.js.map +1 -1
  96. package/dist/engagement/notifications/contract/index.d.ts +5 -1
  97. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  98. package/dist/engagement/notifications/contract/index.js +5 -1
  99. package/dist/engagement/notifications/contract/index.js.map +1 -1
  100. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  101. package/dist/engagement/notifications/plugin.js +43 -3
  102. package/dist/engagement/notifications/plugin.js.map +1 -1
  103. package/dist/engagement/notifications/router/index.d.ts +1 -0
  104. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  105. package/dist/iam/service/iam.service.d.ts +1 -1
  106. package/dist/pam/identity/__tests__/admin-user-directory.test.js +1 -1
  107. package/dist/pam/identity/adapters/identity-reader.service.d.ts +6 -4
  108. package/dist/pam/identity/adapters/identity-reader.service.d.ts.map +1 -1
  109. package/dist/pam/identity/adapters/identity-reader.service.js +19 -1
  110. package/dist/pam/identity/adapters/identity-reader.service.js.map +1 -1
  111. package/dist/pam/identity/admin-user-directory.d.ts +1 -1
  112. package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
  113. package/dist/pam/identity/admin-user-directory.js +6 -3
  114. package/dist/pam/identity/admin-user-directory.js.map +1 -1
  115. package/dist/pam/identity/service/identity.service.d.ts.map +1 -1
  116. package/dist/pam/identity/service/identity.service.js +4 -0
  117. package/dist/pam/identity/service/identity.service.js.map +1 -1
  118. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +5 -2
  119. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
  120. package/dist/pam/player-management/__tests__/player.router.test.js +27 -20
  121. package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
  122. package/dist/pam/player-management/__tests__/player.service.test.js +30 -31
  123. package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
  124. package/dist/pam/player-management/contract/index.d.ts +37 -9
  125. package/dist/pam/player-management/contract/index.d.ts.map +1 -1
  126. package/dist/pam/player-management/contract/index.js +0 -1
  127. package/dist/pam/player-management/contract/index.js.map +1 -1
  128. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  129. package/dist/pam/player-management/plugin.js +3 -2
  130. package/dist/pam/player-management/plugin.js.map +1 -1
  131. package/dist/pam/player-management/router/index.d.ts +32 -10
  132. package/dist/pam/player-management/router/index.d.ts.map +1 -1
  133. package/dist/pam/player-management/router/index.js +48 -16
  134. package/dist/pam/player-management/router/index.js.map +1 -1
  135. package/dist/pam/player-management/service/kyc-status-writer.d.ts +3 -2
  136. package/dist/pam/player-management/service/kyc-status-writer.d.ts.map +1 -1
  137. package/dist/pam/player-management/service/kyc-status-writer.js +24 -10
  138. package/dist/pam/player-management/service/kyc-status-writer.js.map +1 -1
  139. package/dist/pam/player-management/service/player.service.d.ts +10 -9
  140. package/dist/pam/player-management/service/player.service.d.ts.map +1 -1
  141. package/dist/pam/player-management/service/player.service.js +24 -12
  142. package/dist/pam/player-management/service/player.service.js.map +1 -1
  143. package/dist/pam/profile/contract/index.d.ts +2 -0
  144. package/dist/pam/profile/contract/index.d.ts.map +1 -1
  145. package/dist/pam/profile/drizzle/migrations/0001_powerful_tiger_shark.sql +1 -0
  146. package/dist/pam/profile/drizzle/migrations/meta/0001_snapshot.json +191 -0
  147. package/dist/pam/profile/drizzle/migrations/meta/_journal.json +7 -0
  148. package/dist/pam/profile/react/profile.d.ts +2 -2
  149. package/dist/pam/profile/router/index.d.ts +2 -0
  150. package/dist/pam/profile/router/index.d.ts.map +1 -1
  151. package/dist/pam/profile/schema/index.d.ts +3 -3
  152. package/dist/pam/profile/schema/index.d.ts.map +1 -1
  153. package/dist/pam/profile/service/profile.service.d.ts +2 -2
  154. package/dist/pam/shared/player-mapper.d.ts +1 -1
  155. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +619 -19
  156. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
  157. package/dist/pam/tag/__tests__/tag-mappers.test.js +25 -0
  158. package/dist/pam/tag/__tests__/tag-mappers.test.js.map +1 -1
  159. package/dist/pam/tag/__tests__/tag.router.test.js +71 -1
  160. package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
  161. package/dist/pam/tag/__tests__/tag.service.test.js +342 -15
  162. package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
  163. package/dist/pam/tag/contract/index.d.ts +123 -0
  164. package/dist/pam/tag/contract/index.d.ts.map +1 -1
  165. package/dist/pam/tag/contract/index.js +3 -0
  166. package/dist/pam/tag/contract/index.js.map +1 -1
  167. package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts +37 -0
  168. package/dist/pam/tag/contract/player-tag-assign-metadata.d.ts.map +1 -0
  169. package/dist/pam/tag/contract/player-tag-assign-metadata.js +20 -0
  170. package/dist/pam/tag/contract/player-tag-assign-metadata.js.map +1 -0
  171. package/dist/pam/tag/drizzle/migrations/0002_silent_skaar.sql +4 -0
  172. package/dist/pam/tag/drizzle/migrations/0003_overconfident_guardian.sql +3 -0
  173. package/dist/pam/tag/drizzle/migrations/0004_curvy_wither.sql +2 -0
  174. package/dist/pam/tag/drizzle/migrations/meta/0002_snapshot.json +335 -0
  175. package/dist/pam/tag/drizzle/migrations/meta/0003_snapshot.json +343 -0
  176. package/dist/pam/tag/drizzle/migrations/meta/0004_snapshot.json +345 -0
  177. package/dist/pam/tag/drizzle/migrations/meta/_journal.json +21 -0
  178. package/dist/pam/tag/migrate.d.ts.map +1 -1
  179. package/dist/pam/tag/migrate.js +34 -0
  180. package/dist/pam/tag/migrate.js.map +1 -1
  181. package/dist/pam/tag/plugin.d.ts.map +1 -1
  182. package/dist/pam/tag/plugin.js +43 -16
  183. package/dist/pam/tag/plugin.js.map +1 -1
  184. package/dist/pam/tag/router/index.d.ts +105 -0
  185. package/dist/pam/tag/router/index.d.ts.map +1 -1
  186. package/dist/pam/tag/router/index.js +34 -18
  187. package/dist/pam/tag/router/index.js.map +1 -1
  188. package/dist/pam/tag/schema/index.d.ts +42 -3
  189. package/dist/pam/tag/schema/index.d.ts.map +1 -1
  190. package/dist/pam/tag/schema/index.js +13 -2
  191. package/dist/pam/tag/schema/index.js.map +1 -1
  192. package/dist/pam/tag/seed/data/default-tag-rules.d.ts.map +1 -1
  193. package/dist/pam/tag/seed/data/default-tag-rules.js +42 -0
  194. package/dist/pam/tag/seed/data/default-tag-rules.js.map +1 -1
  195. package/dist/pam/tag/seed/data/default-tags.d.ts.map +1 -1
  196. package/dist/pam/tag/seed/data/default-tags.js +6 -0
  197. package/dist/pam/tag/seed/data/default-tags.js.map +1 -1
  198. package/dist/pam/tag/service/tag-evaluation.service.d.ts +141 -16
  199. package/dist/pam/tag/service/tag-evaluation.service.d.ts.map +1 -1
  200. package/dist/pam/tag/service/tag-evaluation.service.js +472 -47
  201. package/dist/pam/tag/service/tag-evaluation.service.js.map +1 -1
  202. package/dist/pam/tag/service/tag-mappers.d.ts +1 -1
  203. package/dist/pam/tag/service/tag-mappers.d.ts.map +1 -1
  204. package/dist/pam/tag/service/tag-mappers.js +1 -0
  205. package/dist/pam/tag/service/tag-mappers.js.map +1 -1
  206. package/dist/pam/tag/service/tag-rule.service.d.ts +3 -3
  207. package/dist/pam/tag/service/tag.service.d.ts +141 -11
  208. package/dist/pam/tag/service/tag.service.d.ts.map +1 -1
  209. package/dist/pam/tag/service/tag.service.js +242 -23
  210. package/dist/pam/tag/service/tag.service.js.map +1 -1
  211. package/dist/server/auth/permissions.d.ts +12 -0
  212. package/dist/server/auth/permissions.d.ts.map +1 -1
  213. package/dist/server/auth/permissions.js +2 -0
  214. package/dist/server/auth/permissions.js.map +1 -1
  215. package/dist/server/db/__tests__/migrate.test.js +79 -2
  216. package/dist/server/db/__tests__/migrate.test.js.map +1 -1
  217. package/dist/server/db/migrate.d.ts +11 -0
  218. package/dist/server/db/migrate.d.ts.map +1 -1
  219. package/dist/server/db/migrate.js +3 -0
  220. package/dist/server/db/migrate.js.map +1 -1
  221. package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
  222. package/dist/server/kernel/bullmq-job-queue.js +17 -0
  223. package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
  224. package/dist/server/plugin-host/__tests__/core-plugins.test.js +1 -1
  225. package/dist/server/plugin-host/__tests__/core-plugins.test.js.map +1 -1
  226. package/dist/server/plugin-host/define-plugin.d.ts +2 -2
  227. package/dist/server/plugin-host/define-plugin.d.ts.map +1 -1
  228. package/dist/server/plugin-host/define-plugin.js.map +1 -1
  229. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +60 -0
  230. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
  231. package/dist/wallet/__tests__/wallet.service.test.js +59 -0
  232. package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
  233. package/dist/wallet/adapters/wallet-reader.service.d.ts +1 -0
  234. package/dist/wallet/adapters/wallet-reader.service.d.ts.map +1 -1
  235. package/dist/wallet/adapters/wallet-reader.service.js +18 -1
  236. package/dist/wallet/adapters/wallet-reader.service.js.map +1 -1
  237. package/dist/wallet/contract/index.d.ts +4 -0
  238. package/dist/wallet/contract/index.d.ts.map +1 -1
  239. package/dist/wallet/plugin.d.ts.map +1 -1
  240. package/dist/wallet/plugin.js +7 -4
  241. package/dist/wallet/plugin.js.map +1 -1
  242. package/dist/wallet/router/index.d.ts +2 -0
  243. package/dist/wallet/router/index.d.ts.map +1 -1
  244. package/dist/wallet/service/wallet.service.d.ts +5 -4
  245. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  246. package/dist/wallet/service/wallet.service.js +28 -8
  247. package/dist/wallet/service/wallet.service.js.map +1 -1
  248. 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
- constructor(tag, rule, walletReader, identityReader) {
13
- this.tag = tag;
14
- this.rule = rule;
15
- this.walletReader = walletReader;
16
- this.identityReader = identityReader;
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(userId, tagKey, reason) {
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(userId, tagKey, reason) {
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 { userId, amount } = domainEventSchemas['wallet.deposit.completed'].parse(payload);
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(userId, 'large_depositor', 'single deposit crossed threshold');
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(userId, 'high_roller', 'lifetime deposits crossed threshold');
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(userId, 'high_roller', 'lifetime deposits below threshold');
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
- * Only assigns the tag when a threshold is crossed; never auto-removes
109
- * (risk designation requires an explicit admin clear).
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 { userId, amount } = domainEventSchemas['wallet.withdrawal.completed'].parse(payload);
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 amountBreached = rule.threshold !== null && moneyToNumber(amount) >= moneyToNumber(rule.threshold);
118
- let countBreached = false;
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
- countBreached = count >= rule.thresholdCount;
236
+ if (count >= rule.thresholdCount) {
237
+ countBreach = {
238
+ count,
239
+ thresholdCount: rule.thresholdCount,
240
+ thresholdDays: rule.thresholdDays,
241
+ };
242
+ }
122
243
  }
123
- if (amountBreached || countBreached) {
124
- await this.tryAssignTag(userId, 'high_risk', 'withdrawal amount or frequency crossed threshold');
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
- * Removes the inactive tag when a player logs in.
129
- * Called on identity.user.login.
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 { userId } = domainEventSchemas['identity.user.login'].parse(payload);
133
- await this.tryRemoveTag(userId, 'inactive', 'player logged in');
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
- * Applies the kyc_pending tag on KYC submission and clears any prior kyc_rejected.
137
- * Called on compliance.kyc.submitted. Respects the kyc_pending rule's isEnabled flag.
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 { userId } = domainEventSchemas['compliance.kyc.submitted'].parse(payload);
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.tryRemoveTag(userId, 'kyc_rejected', 'kyc resubmitted');
146
- await this.tryAssignTag(userId, 'kyc_pending', 'kyc verification initiated');
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 lifecycle on KYC status changes.
150
- * Called on compliance.kyc.updated. Respects the kyc_pending rule's isEnabled flag.
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
- * verified / manually_overridden -> remove kyc_pending + kyc_rejected
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 { userId, status } = domainEventSchemas['compliance.kyc.updated'].parse(payload);
159
- const rule = await this.getEnabledRule('kyc_pending');
160
- if (!rule) {
374
+ const parsed = domainEventSchemas['compliance.kyc.updated'].safeParse(payload);
375
+ if (!parsed.success) {
161
376
  return;
162
377
  }
163
- if (status === 'verified' || status === 'manually_overridden') {
164
- await this.tryRemoveTag(userId, 'kyc_pending', 'kyc approved');
165
- await this.tryRemoveTag(userId, 'kyc_rejected', 'kyc approved');
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.tryAssignTag(userId, 'kyc_rejected', 'kyc verification rejected');
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.tryAssignTag(userId, 'kyc_pending', 'kyc re-verification required');
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
- * Daily scheduled evaluation: assigns inactive to players who have not logged in
179
- * for rule.thresholdDays days. Removal is handled by onUserLogin on next login.
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(userId, 'inactive', 'no login for configured threshold'));
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