@openora/core 0.4.1-canary.37 → 0.4.1-canary.39

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 (259) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/__tests__/backoffice.router.test.js +67 -51
  3. package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
  4. package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
  5. package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
  6. package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
  7. package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
  8. package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
  9. package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
  10. package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
  11. package/dist/analytics/service/financial-analytics.service.js +14 -17
  12. package/dist/analytics/service/financial-analytics.service.js.map +1 -1
  13. package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
  14. package/dist/analytics/service/funnel-analytics.service.js +8 -11
  15. package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
  16. package/dist/audit/__tests__/audit.service.test.js +128 -251
  17. package/dist/audit/__tests__/audit.service.test.js.map +1 -1
  18. package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
  19. package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
  20. package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
  21. package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
  22. package/dist/cms/__tests__/cms.reads.test.js +59 -80
  23. package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
  24. package/dist/cms/__tests__/cms.router.test.js +62 -20
  25. package/dist/cms/__tests__/cms.router.test.js.map +1 -1
  26. package/dist/cms/__tests__/cms.service.test.js +116 -14
  27. package/dist/cms/__tests__/cms.service.test.js.map +1 -1
  28. package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
  29. package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
  30. package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
  31. package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
  32. package/dist/compliance/__tests__/assert.test.d.ts +2 -0
  33. package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
  34. package/dist/compliance/__tests__/assert.test.js +34 -0
  35. package/dist/compliance/__tests__/assert.test.js.map +1 -0
  36. package/dist/compliance/__tests__/compliance.service.test.js +149 -84
  37. package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
  38. package/dist/compliance/__tests__/kyc.service.test.js +247 -155
  39. package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
  40. package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
  41. package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
  42. package/dist/compliance/__tests__/limits.contract.test.js +43 -0
  43. package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
  44. package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
  45. package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
  46. package/dist/compliance/__tests__/rg.contract.test.js +1 -20
  47. package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
  48. package/dist/compliance/__tests__/rg.router.test.js +113 -65
  49. package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
  50. package/dist/compliance/__tests__/rg.service.test.js +277 -235
  51. package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
  52. package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
  53. package/dist/compliance/service/rg-monitoring.service.js +11 -8
  54. package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
  55. package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
  56. package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
  57. package/dist/contracts/__tests__/compose-contract.test.js +27 -0
  58. package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
  59. package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
  60. package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
  61. package/dist/contracts/adapters/__tests__/token.test.js +14 -0
  62. package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
  63. package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
  64. package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
  65. package/dist/contracts/schemas/__tests__/events.test.js +51 -0
  66. package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
  67. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
  68. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
  69. package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
  70. package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
  71. package/dist/contracts/schemas/reporting.d.ts +7 -2
  72. package/dist/contracts/schemas/reporting.d.ts.map +1 -1
  73. package/dist/contracts/schemas/reporting.js +2 -1
  74. package/dist/contracts/schemas/reporting.js.map +1 -1
  75. package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
  76. package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
  77. package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
  78. package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
  79. package/dist/iam/__tests__/iam.service.test.js +367 -596
  80. package/dist/iam/__tests__/iam.service.test.js.map +1 -1
  81. package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
  82. package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
  83. package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
  84. package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
  85. package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
  86. package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
  87. package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
  88. package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
  89. package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
  90. package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
  91. package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
  92. package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
  93. package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
  94. package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
  95. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
  96. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
  97. package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
  98. package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
  99. package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
  100. package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
  101. package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
  102. package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
  103. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
  104. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
  105. package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
  106. package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
  107. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
  108. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
  109. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
  110. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
  111. package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
  112. package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
  113. package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
  114. package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
  115. package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
  116. package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
  117. package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
  118. package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
  119. package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
  120. package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
  121. package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
  122. package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
  123. package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
  124. package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
  125. package/dist/server/db/__tests__/query-helpers.test.js +72 -1
  126. package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
  127. package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
  128. package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
  129. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
  130. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
  131. package/dist/server/kernel/__tests__/cache.test.js +26 -13
  132. package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
  133. package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
  134. package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
  135. package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
  136. package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
  137. package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
  138. package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
  139. package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
  140. package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
  141. package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
  142. package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
  143. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
  144. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
  145. package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
  146. package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
  147. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
  148. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
  149. package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
  150. package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
  151. package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
  152. package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
  153. package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
  154. package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
  155. package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
  156. package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
  157. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
  158. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
  159. package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
  160. package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
  161. package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
  162. package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
  163. package/dist/server/kernel/__tests__/request-context.test.js +64 -0
  164. package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
  165. package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
  166. package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
  167. package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
  168. package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
  169. package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
  170. package/dist/server/kernel/bullmq-job-queue.js +8 -2
  171. package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
  172. package/dist/server/runtime/__tests__/create-app.test.js +26 -18
  173. package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
  174. package/dist/testing/index.d.ts +2 -6
  175. package/dist/testing/index.d.ts.map +1 -1
  176. package/dist/testing/index.js +2 -10
  177. package/dist/testing/index.js.map +1 -1
  178. package/dist/testing/mock.d.ts +28 -16
  179. package/dist/testing/mock.d.ts.map +1 -1
  180. package/dist/testing/mock.js +58 -37
  181. package/dist/testing/mock.js.map +1 -1
  182. package/dist/testing/real-infra.d.ts +47 -0
  183. package/dist/testing/real-infra.d.ts.map +1 -0
  184. package/dist/testing/real-infra.js +122 -0
  185. package/dist/testing/real-infra.js.map +1 -0
  186. package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
  187. package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
  188. package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
  189. package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
  190. package/dist/wallet/__tests__/rail-for.test.js +22 -0
  191. package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
  192. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
  193. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
  194. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
  195. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
  196. package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
  197. package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
  198. package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
  199. package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
  200. package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
  201. package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
  202. package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
  203. package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
  204. package/dist/wallet/__tests__/wallet.router.test.js +56 -36
  205. package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
  206. package/dist/wallet/__tests__/wallet.service.test.js +623 -887
  207. package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
  208. package/package.json +1 -2
  209. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
  210. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
  211. package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
  212. package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
  213. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
  214. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
  215. package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
  216. package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
  217. package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
  218. package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
  219. package/dist/testing/fakes/__tests__/cache.test.js +0 -89
  220. package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
  221. package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
  222. package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
  223. package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
  224. package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
  225. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
  226. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
  227. package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
  228. package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
  229. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
  230. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
  231. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
  232. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
  233. package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
  234. package/dist/testing/fakes/cache.d.ts +0 -14
  235. package/dist/testing/fakes/cache.d.ts.map +0 -1
  236. package/dist/testing/fakes/cache.js +0 -56
  237. package/dist/testing/fakes/cache.js.map +0 -1
  238. package/dist/testing/fakes/event-bus.d.ts +0 -17
  239. package/dist/testing/fakes/event-bus.d.ts.map +0 -1
  240. package/dist/testing/fakes/event-bus.js +0 -30
  241. package/dist/testing/fakes/event-bus.js.map +0 -1
  242. package/dist/testing/fakes/job-queue.d.ts +0 -40
  243. package/dist/testing/fakes/job-queue.d.ts.map +0 -1
  244. package/dist/testing/fakes/job-queue.js +0 -200
  245. package/dist/testing/fakes/job-queue.js.map +0 -1
  246. package/dist/testing/fakes/rate-limiter.d.ts +0 -11
  247. package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
  248. package/dist/testing/fakes/rate-limiter.js +0 -39
  249. package/dist/testing/fakes/rate-limiter.js.map +0 -1
  250. package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
  251. package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
  252. package/dist/testing/fakes/realtime-authorizer.js +0 -10
  253. package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
  254. package/dist/testing/fakes/realtime-transport.d.ts +0 -2
  255. package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
  256. package/dist/testing/fakes/realtime-transport.js +0 -2
  257. package/dist/testing/fakes/realtime-transport.js.map +0 -1
  258. /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
  259. /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js +0 -0
@@ -1,581 +1,558 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { mock, readPrivate, makeDrizzle, makeEvents, makePayment, NO_CLIENT_META, } from '../../testing/mock.js';
1
+ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { eq, sql } from 'drizzle-orm';
4
+ import { createTestDb } from '@openora/core/testing';
5
+ import { mock, makeEventBus, NO_CLIENT_META, makeAuditWriter } from '../../testing/mock.js';
6
+ import { migrate } from '../migrate.js';
7
+ import { wallet, walletTransaction, autoWithdrawalRule } from '../schema/index.js';
3
8
  import { WalletService } from '../service/wallet.service.js';
4
- const makeAudit = () => ({ record: vi.fn().mockResolvedValue(undefined) });
5
- const WALLET_ROW = { id: 'w-1', userId: 'u-1', balance: '100000', currency: 'USD' };
6
- const insertRow = (over = {}) => ({
7
- id: 'tx-1',
8
- walletId: 'w-1',
9
- amount: '40',
10
- currency: 'USD',
11
- status: 'pending',
12
- rail: 'fiat',
13
- ...over,
14
- });
15
- // select/returning rows the initial hold transaction always consumes (no idempotency key).
16
- const holdSelect = (wallet = WALLET_ROW) => [wallet];
17
- const holdReturning = (tx = insertRow()) => [[tx], [{ id: 'w-1' }]];
18
- // select/returning rows the flipToProcessing + settleApproved path consumes on a successful settle.
19
- const finalizeSelect = () => [
20
- [
21
- {
22
- id: 'tx-1',
23
- walletId: 'w-1',
24
- type: 'withdrawal',
25
- status: 'pending',
26
- amount: '40',
27
- currency: 'USD',
28
- },
29
- ],
30
- [{ userId: 'u-1' }],
31
- [],
32
- ];
33
- const finalizeReturning = () => [
34
- [
35
- {
36
- id: 'tx-1',
37
- walletId: 'w-1',
38
- type: 'withdrawal',
39
- status: 'processing',
40
- amount: '40',
41
- currency: 'USD',
42
- rail: 'fiat',
43
- },
44
- ],
45
- ];
46
- function makeSvc(opts) {
47
- const events = makeEvents();
48
- const payment = makePayment();
49
- const audit = makeAudit();
50
- const platformConfig = {
51
- ...(opts.kyc ? { kyc: opts.kyc } : {}),
52
- ...(opts.autoWithdrawal
53
- ? { autoWithdrawal: { enabled: true, excludeRiskFlags: [], ...opts.autoWithdrawal } }
54
- : {}),
9
+ let db;
10
+ function makeService({ autoWithdrawal, kycStatus = 'verified', directoryThrows = false, riskTags = [], } = {}) {
11
+ const events = makeEventBus();
12
+ const psp = {
13
+ processWithdrawal: vi.fn(async () => ({
14
+ externalId: randomUUID(),
15
+ status: 'completed',
16
+ })),
55
17
  };
56
- const svc = new WalletService(mock({
57
- drizzle: opts.drizzle,
58
- events,
59
- payment,
60
- directory: opts.directory,
61
- riskTags: opts.riskTags,
62
- audit,
18
+ const audit = makeAuditWriter();
19
+ const directory = mock({
20
+ lookupPlayers: vi.fn(async (ids) => {
21
+ if (directoryThrows) {
22
+ throw new Error('directory down');
23
+ }
24
+ return kycStatus
25
+ ? ids.map((userId) => mock({ userId, username: 'player', kycStatus }))
26
+ : [];
27
+ }),
28
+ });
29
+ const platformConfig = mock(autoWithdrawal
30
+ ? { autoWithdrawal: { enabled: true, excludeRiskFlags: [], ...autoWithdrawal } }
31
+ : {});
32
+ const svc = new WalletService({
33
+ drizzle: db.drizzle,
34
+ events: events,
35
+ payment: mock(psp),
36
+ audit: mock(audit),
37
+ directory,
63
38
  platformConfig,
64
- }));
65
- return { svc, events, payment, audit };
39
+ ...(riskTags === 'unbound'
40
+ ? {}
41
+ : {
42
+ riskTags: mock({
43
+ getActiveTagKeys: vi.fn(async (ids) => new Map(ids.map((id) => [id, riskTags]))),
44
+ }),
45
+ }),
46
+ });
47
+ return { svc, events, psp, audit };
48
+ }
49
+ async function seedWallet(overrides = {}) {
50
+ const [row] = await db.drizzle.db
51
+ .insert(wallet)
52
+ .values({ userId: randomUUID(), balance: '100000', currency: 'USD', ...overrides })
53
+ .returning();
54
+ return row;
55
+ }
56
+ async function txById(id) {
57
+ const [row] = await db.drizzle.db
58
+ .select()
59
+ .from(walletTransaction)
60
+ .where(eq(walletTransaction.id, id));
61
+ return row;
66
62
  }
67
- const verifiedDirectory = () => ({
68
- lookupPlayers: vi.fn().mockResolvedValue([{ userId: 'u-1', kycStatus: 'verified' }]),
63
+ beforeAll(async () => {
64
+ db = await createTestDb([migrate]);
69
65
  });
70
- describe('WalletService.withdraw auto-approval', () => {
71
- beforeEach(() => vi.clearAllMocks());
72
- it('auto-approves a fiat withdrawal that passes every gate, reusing the approve/settle path', async () => {
73
- const dz = makeDrizzle({
74
- select: [
75
- ...holdSelect().map((r) => [r]),
76
- [], // resolveAutoThreshold: no per-player rule -> falls back to global (pre-lock)
77
- [{ walletId: 'w-1', n: 1 }], // velocity grouped count (< 3), pre-lock
78
- [{ total: '0', n: 0 }], // caps, inside the advisory lock
79
- ...finalizeSelect(),
80
- ],
81
- returning: [...holdReturning(), ...finalizeReturning()],
82
- });
83
- const setSpy = readPrivate(dz.db, 'set');
84
- const { svc, payment, audit } = makeSvc({
85
- drizzle: dz,
86
- directory: verifiedDirectory(),
87
- autoWithdrawal: { fiatThreshold: '1000' },
88
- });
66
+ afterAll(async () => {
67
+ await db.drop();
68
+ });
69
+ beforeEach(async () => {
70
+ await db.drizzle.db.execute(sql `TRUNCATE ${walletTransaction}, ${autoWithdrawalRule}, ${wallet} RESTART IDENTITY CASCADE`);
71
+ });
72
+ describe('WalletService.withdraw auto-approval (real PG)', () => {
73
+ it('auto-approves a fiat withdrawal that clears every gate, marking the system as reviewer', async () => {
74
+ const { svc, psp, audit, events } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
75
+ const w = await seedWallet();
89
76
  const result = await svc.withdraw({
90
- userId: 'u-1',
77
+ userId: w.userId,
91
78
  amount: '40',
92
79
  currency: 'USD',
93
80
  ...NO_CLIENT_META,
94
81
  });
95
- expect(result).toEqual({ transactionId: 'tx-1', status: 'completed' });
96
- expect(payment.processWithdrawal).toHaveBeenCalledTimes(1);
97
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ reviewReason: 'auto-approved', reviewedBy: null }));
98
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'completed', providerRefId: 'ext-2' }));
82
+ expect(result.status).toBe('completed');
83
+ expect(psp.processWithdrawal).toHaveBeenCalledTimes(1);
84
+ expect(await txById(result.transactionId)).toMatchObject({
85
+ status: 'completed',
86
+ reviewedBy: null,
87
+ reviewReason: 'auto-approved',
88
+ providerName: 'psp',
89
+ });
90
+ expect(events.emit.mock.calls.map(([topic]) => topic)).toEqual([
91
+ 'wallet.withdrawal.requested',
92
+ 'wallet.withdrawal.completed',
93
+ ]);
99
94
  expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({
100
95
  actorType: 'system',
101
96
  action: 'wallet.withdrawal.auto_approved',
102
97
  resourceType: 'wallet_transaction',
103
- resourceId: 'tx-1',
98
+ resourceId: result.transactionId,
104
99
  after: expect.objectContaining({
105
100
  threshold: '1000',
106
101
  thresholdSource: 'global',
107
102
  kycStatus: 'verified',
108
- riskTagsEvaluated: [],
103
+ cumulativeCountUsed: 0,
109
104
  }),
110
105
  }));
111
106
  });
112
- it('takes a per-user advisory lock before the cap check + auto-approval flip', async () => {
113
- // The mock can't reproduce advisory-lock serialization, so instead prove the invariant it relies on:
114
- // pg_advisory_xact_lock is acquired before the flip that writes the marker the next caller's cap query reads.
115
- const dz = makeDrizzle({
116
- select: [
117
- ...holdSelect().map((r) => [r]),
118
- [], // resolveAutoThreshold -> global fallback
119
- [{ n: 1 }], // velocity
120
- [{ total: '0', n: 0 }], // caps
121
- ...finalizeSelect(),
122
- ],
123
- returning: [...holdReturning(), ...finalizeReturning()],
124
- });
125
- const setSpy = readPrivate(dz.db, 'set');
126
- const executeSpy = readPrivate(dz.db, 'execute');
127
- const { svc } = makeSvc({
128
- drizzle: dz,
129
- directory: verifiedDirectory(),
130
- autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 },
131
- });
132
- await svc.withdraw({ userId: 'u-1', amount: '40', currency: 'USD', ...NO_CLIENT_META });
133
- expect(executeSpy).toHaveBeenCalledTimes(1);
134
- const lockOrder = executeSpy.mock.invocationCallOrder.at(0);
135
- if (lockOrder === undefined) {
136
- throw new Error('expected executeSpy to have been called');
137
- }
138
- // The lock must precede the flip `.set()` that writes the auto-approved marker (and the cap read between them).
139
- const flipCallIndex = setSpy.mock.calls.findIndex(([arg]) => arg?.reviewReason === 'auto-approved');
140
- expect(flipCallIndex).toBeGreaterThanOrEqual(0);
141
- const flipOrder = setSpy.mock.invocationCallOrder.at(flipCallIndex);
142
- if (flipOrder === undefined) {
143
- throw new Error('expected the flip call to have been recorded');
144
- }
145
- expect(lockOrder).toBeLessThan(flipOrder);
146
- });
147
- it('reverts the flip to pending when the audit write fails, instead of leaving it stuck processing', async () => {
148
- const dz = makeDrizzle({
149
- select: [
150
- ...holdSelect().map((r) => [r]),
151
- [], // resolveAutoThreshold -> global fallback
152
- [{ n: 1 }], // velocity
153
- [{ total: '0', n: 0 }], // caps
154
- [
155
- {
156
- id: 'tx-1',
157
- walletId: 'w-1',
158
- type: 'withdrawal',
159
- status: 'pending',
160
- amount: '40',
161
- currency: 'USD',
162
- },
163
- ], // flipToProcessing's FOR UPDATE select
164
- ],
165
- returning: [
166
- ...holdReturning(),
167
- [
168
- {
169
- id: 'tx-1',
170
- walletId: 'w-1',
171
- status: 'processing',
172
- amount: '40',
173
- currency: 'USD',
174
- rail: 'fiat',
175
- },
176
- ], // flipToProcessing's update...returning
177
- ],
178
- });
179
- const setSpy = readPrivate(dz.db, 'set');
180
- const { svc, audit, payment } = makeSvc({
181
- drizzle: dz,
182
- directory: verifiedDirectory(),
183
- autoWithdrawal: { fiatThreshold: '1000' },
184
- });
185
- audit.record.mockRejectedValueOnce(new Error('audit down'));
107
+ it('reverts the flip to pending when the audit write fails instead of stranding it processing', async () => {
108
+ const { svc, audit, psp } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
109
+ audit.record.mockRejectedValueOnce(new Error('audit sink down'));
110
+ const w = await seedWallet();
186
111
  const result = await svc.withdraw({
187
- userId: 'u-1',
112
+ userId: w.userId,
188
113
  amount: '40',
189
114
  currency: 'USD',
190
115
  ...NO_CLIENT_META,
191
116
  });
192
- expect(result).toEqual({ transactionId: 'tx-1', status: 'pending' });
193
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
194
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'processing', reviewReason: 'auto-approved' }));
195
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
117
+ expect(result.status).toBe('pending');
118
+ expect(psp.processWithdrawal).not.toHaveBeenCalled();
119
+ expect(await txById(result.transactionId)).toMatchObject({
196
120
  status: 'pending',
197
121
  reviewedBy: null,
198
122
  reviewedAt: null,
199
123
  reviewReason: null,
200
- }));
201
- });
202
- it('stays pending when KYC is not passing even though kyc.gateWithdrawals is false', async () => {
203
- const dz = makeDrizzle({
204
- select: [...holdSelect().map((r) => [r]), []],
205
- returning: [...holdReturning()],
206
- });
207
- const { svc, payment } = makeSvc({
208
- drizzle: dz,
209
- directory: {
210
- lookupPlayers: vi.fn().mockResolvedValue([{ userId: 'u-1', kycStatus: 'pending' }]),
211
- },
212
- kyc: { gateWithdrawals: false },
213
- autoWithdrawal: { fiatThreshold: '1000' },
214
124
  });
125
+ });
126
+ it('holds the funds even when auto-approval declines', async () => {
127
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '10' } });
128
+ const w = await seedWallet({ balance: '100' });
215
129
  const result = await svc.withdraw({
216
- userId: 'u-1',
130
+ userId: w.userId,
217
131
  amount: '40',
218
132
  currency: 'USD',
219
133
  ...NO_CLIENT_META,
220
134
  });
221
135
  expect(result.status).toBe('pending');
222
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
136
+ const [row] = await db.drizzle.db.select().from(wallet).where(eq(wallet.id, w.id));
137
+ expect(Number(row?.balance)).toBe(60);
223
138
  });
224
- it('stays pending when the player carries an excluded risk flag', async () => {
225
- const dz = makeDrizzle({
226
- select: [...holdSelect().map((r) => [r]), []],
227
- returning: [...holdReturning()],
228
- });
229
- const { svc, payment } = makeSvc({
230
- drizzle: dz,
231
- directory: verifiedDirectory(),
232
- riskTags: {
233
- getActiveTagKeys: vi.fn().mockResolvedValue(new Map([['u-1', ['high_risk']]])),
234
- },
235
- autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['high_risk'] },
139
+ it('stays pending when the amount exceeds the configured threshold', async () => {
140
+ const { svc, psp } = makeService({ autoWithdrawal: { fiatThreshold: '10' } });
141
+ const w = await seedWallet();
142
+ const result = await svc.withdraw({
143
+ userId: w.userId,
144
+ amount: '40',
145
+ currency: 'USD',
146
+ ...NO_CLIENT_META,
236
147
  });
148
+ expect(result.status).toBe('pending');
149
+ expect(psp.processWithdrawal).not.toHaveBeenCalled();
150
+ });
151
+ it('stays pending when no threshold is configured at all', async () => {
152
+ const { svc } = makeService({ autoWithdrawal: {} });
153
+ const w = await seedWallet();
237
154
  const result = await svc.withdraw({
238
- userId: 'u-1',
155
+ userId: w.userId,
239
156
  amount: '40',
240
157
  currency: 'USD',
241
158
  ...NO_CLIENT_META,
242
159
  });
243
160
  expect(result.status).toBe('pending');
244
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
245
161
  });
246
- it('stays pending on a high_frequency velocity flag', async () => {
247
- const dz = makeDrizzle({
248
- // grouped velocity count returns the wallet with >= 3 withdrawals in the window
249
- select: [...holdSelect().map((r) => [r]), [], [{ walletId: 'w-1', n: 3 }]],
250
- returning: [...holdReturning()],
162
+ it('stays pending when auto-withdrawal is not enabled', async () => {
163
+ const { svc, audit } = makeService();
164
+ const w = await seedWallet();
165
+ const result = await svc.withdraw({
166
+ userId: w.userId,
167
+ amount: '40',
168
+ currency: 'USD',
169
+ ...NO_CLIENT_META,
251
170
  });
252
- const { svc, payment } = makeSvc({
253
- drizzle: dz,
254
- directory: verifiedDirectory(),
171
+ expect(result.status).toBe('pending');
172
+ expect(audit.record).not.toHaveBeenCalled();
173
+ });
174
+ it('stays pending when KYC is not passing even though gateWithdrawals is off', async () => {
175
+ const { svc } = makeService({
255
176
  autoWithdrawal: { fiatThreshold: '1000' },
177
+ kycStatus: 'pending',
256
178
  });
179
+ const w = await seedWallet();
257
180
  const result = await svc.withdraw({
258
- userId: 'u-1',
181
+ userId: w.userId,
259
182
  amount: '40',
260
183
  currency: 'USD',
261
184
  ...NO_CLIENT_META,
262
185
  });
263
186
  expect(result.status).toBe('pending');
264
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
265
187
  });
266
- it('stays pending when a configured daily count cap would be exceeded', async () => {
267
- const dz = makeDrizzle({
268
- select: [...holdSelect().map((r) => [r]), [], [{ n: 1 }], [{ total: '0', n: 1 }]],
269
- returning: [...holdReturning()],
270
- });
271
- const { svc, payment } = makeSvc({
272
- drizzle: dz,
273
- directory: verifiedDirectory(),
274
- autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 },
188
+ it('fails closed to pending when the directory throws during the KYC check', async () => {
189
+ const { svc } = makeService({
190
+ autoWithdrawal: { fiatThreshold: '1000' },
191
+ directoryThrows: true,
275
192
  });
193
+ const w = await seedWallet();
276
194
  const result = await svc.withdraw({
277
- userId: 'u-1',
195
+ userId: w.userId,
278
196
  amount: '40',
279
197
  currency: 'USD',
280
198
  ...NO_CLIENT_META,
281
199
  });
282
200
  expect(result.status).toBe('pending');
283
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
284
201
  });
285
- it('stays pending when a configured daily amount cap would be exceeded', async () => {
286
- const dz = makeDrizzle({
287
- select: [...holdSelect().map((r) => [r]), [], [{ n: 1 }], [{ total: '80', n: 1 }]],
288
- returning: [...holdReturning()],
289
- });
290
- const { svc, payment } = makeSvc({
291
- drizzle: dz,
292
- directory: verifiedDirectory(),
293
- autoWithdrawal: { fiatThreshold: '1000', dailyCapAmount: '100' },
202
+ it('stays pending when the player carries an excluded risk flag', async () => {
203
+ const { svc } = makeService({
204
+ autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] },
205
+ riskTags: ['bonus_abuser'],
294
206
  });
207
+ const w = await seedWallet();
295
208
  const result = await svc.withdraw({
296
- userId: 'u-1',
209
+ userId: w.userId,
297
210
  amount: '40',
298
211
  currency: 'USD',
299
212
  ...NO_CLIENT_META,
300
213
  });
301
214
  expect(result.status).toBe('pending');
302
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
303
215
  });
304
- it('fails closed to pending when risk flags are configured but PLAYER_TAGS is unbound', async () => {
305
- const dz = makeDrizzle({
306
- select: [...holdSelect().map((r) => [r]), []],
307
- returning: [...holdReturning()],
216
+ it('auto-approves when the player carries only unrelated tags', async () => {
217
+ const { svc } = makeService({
218
+ autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] },
219
+ riskTags: ['vip'],
308
220
  });
309
- const { svc, payment } = makeSvc({
310
- drizzle: dz,
311
- directory: verifiedDirectory(),
312
- autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['high_risk'] },
221
+ const w = await seedWallet();
222
+ const result = await svc.withdraw({
223
+ userId: w.userId,
224
+ amount: '40',
225
+ currency: 'USD',
226
+ ...NO_CLIENT_META,
227
+ });
228
+ expect(result.status).toBe('completed');
229
+ });
230
+ it('fails closed to pending when risk flags are configured but the tags port is unbound', async () => {
231
+ const { svc } = makeService({
232
+ autoWithdrawal: { fiatThreshold: '1000', excludeRiskFlags: ['bonus_abuser'] },
233
+ riskTags: 'unbound',
313
234
  });
235
+ const w = await seedWallet();
314
236
  const result = await svc.withdraw({
315
- userId: 'u-1',
237
+ userId: w.userId,
316
238
  amount: '40',
317
239
  currency: 'USD',
318
240
  ...NO_CLIENT_META,
319
241
  });
320
242
  expect(result.status).toBe('pending');
321
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
322
243
  });
323
- it('fails closed to pending when the directory throws during the KYC check', async () => {
324
- const dz = makeDrizzle({
325
- select: [...holdSelect().map((r) => [r]), []],
326
- returning: [...holdReturning()],
327
- });
328
- const { svc, payment } = makeSvc({
329
- drizzle: dz,
330
- directory: { lookupPlayers: vi.fn().mockRejectedValue(new Error('directory down')) },
331
- autoWithdrawal: { fiatThreshold: '1000' },
244
+ it('stays pending on the large_amount heuristic regardless of the threshold', async () => {
245
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '100000' } });
246
+ const w = await seedWallet();
247
+ const result = await svc.withdraw({
248
+ userId: w.userId,
249
+ amount: '5000',
250
+ currency: 'USD',
251
+ ...NO_CLIENT_META,
332
252
  });
253
+ expect(result.status).toBe('pending');
254
+ });
255
+ it('stays pending once the wallet hits the high_frequency velocity flag', async () => {
256
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
257
+ const w = await seedWallet();
258
+ await db.drizzle.db.insert(walletTransaction).values([
259
+ { walletId: w.id, type: 'withdrawal', amount: '1', currency: 'USD', status: 'completed' },
260
+ { walletId: w.id, type: 'withdrawal', amount: '1', currency: 'USD', status: 'completed' },
261
+ ]);
333
262
  const result = await svc.withdraw({
334
- userId: 'u-1',
263
+ userId: w.userId,
335
264
  amount: '40',
336
265
  currency: 'USD',
337
266
  ...NO_CLIENT_META,
338
267
  });
339
268
  expect(result.status).toBe('pending');
340
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
341
- });
342
- it('stays pending for crypto when no cryptoThreshold is configured (opt-in required, never silently active)', async () => {
343
- const dz = makeDrizzle({
344
- select: [[{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'BTC' }], []],
345
- returning: [
346
- [{ id: 'tx-1', walletId: 'w-1', amount: '1', currency: 'BTC', status: 'pending' }],
347
- [{ id: 'w-1' }],
348
- ],
349
- });
350
- const directory = verifiedDirectory();
351
- const { svc, payment } = makeSvc({
352
- drizzle: dz,
353
- directory,
354
- autoWithdrawal: { fiatThreshold: '1000000' },
269
+ });
270
+ it('ignores withdrawals outside the velocity window', async () => {
271
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
272
+ const w = await seedWallet();
273
+ const outsideWindow = new Date(Date.now() - 48 * 60 * 60 * 1000);
274
+ await db.drizzle.db.insert(walletTransaction).values([
275
+ {
276
+ walletId: w.id,
277
+ type: 'withdrawal',
278
+ amount: '1',
279
+ currency: 'USD',
280
+ status: 'completed',
281
+ createdAt: outsideWindow,
282
+ },
283
+ {
284
+ walletId: w.id,
285
+ type: 'withdrawal',
286
+ amount: '1',
287
+ currency: 'USD',
288
+ status: 'completed',
289
+ createdAt: outsideWindow,
290
+ },
291
+ ]);
292
+ const result = await svc.withdraw({
293
+ userId: w.userId,
294
+ amount: '40',
295
+ currency: 'USD',
296
+ ...NO_CLIENT_META,
297
+ });
298
+ expect(result.status).toBe('completed');
299
+ });
300
+ it('stays pending when a configured daily count cap would be exceeded', async () => {
301
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } });
302
+ const w = await seedWallet();
303
+ await db.drizzle.db.insert(walletTransaction).values({
304
+ walletId: w.id,
305
+ type: 'withdrawal',
306
+ amount: '10',
307
+ currency: 'USD',
308
+ status: 'completed',
309
+ reviewReason: 'auto-approved',
355
310
  });
356
311
  const result = await svc.withdraw({
357
- userId: 'u-1',
358
- amount: '1',
359
- currency: 'BTC',
360
- destinationAddress: 'bc1qtest',
312
+ userId: w.userId,
313
+ amount: '40',
314
+ currency: 'USD',
361
315
  ...NO_CLIENT_META,
362
316
  });
363
317
  expect(result.status).toBe('pending');
364
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
365
- expect(directory.lookupPlayers).not.toHaveBeenCalled();
366
- });
367
- it('auto-approves a crypto withdrawal once an operator configures a positive cryptoThreshold', async () => {
368
- const btcWallet = { id: 'w-1', userId: 'u-1', balance: '10', currency: 'BTC' };
369
- const dz = makeDrizzle({
370
- select: [
371
- ...holdSelect(btcWallet).map((r) => [r]),
372
- [],
373
- [{ walletId: 'w-1', n: 1 }],
374
- [{ total: '0', n: 0 }],
375
- [
376
- {
377
- id: 'tx-1',
378
- walletId: 'w-1',
379
- type: 'withdrawal',
380
- status: 'pending',
381
- amount: '0.5',
382
- currency: 'BTC',
383
- },
384
- ],
385
- [{ userId: 'u-1' }],
386
- [],
387
- ],
388
- returning: [
389
- ...holdReturning(insertRow({ currency: 'BTC', rail: 'crypto', amount: '0.5' })),
390
- [
391
- {
392
- id: 'tx-1',
393
- walletId: 'w-1',
394
- status: 'processing',
395
- amount: '0.5',
396
- currency: 'BTC',
397
- rail: 'crypto',
398
- },
399
- ],
400
- ],
401
- });
402
- const { svc, payment, audit } = makeSvc({
403
- drizzle: dz,
404
- directory: verifiedDirectory(),
405
- autoWithdrawal: { cryptoThreshold: '1' },
318
+ });
319
+ it('stays pending when a configured daily amount cap would be exceeded', async () => {
320
+ const { svc } = makeService({
321
+ autoWithdrawal: { fiatThreshold: '1000', dailyCapAmount: '50' },
322
+ });
323
+ const w = await seedWallet();
324
+ await db.drizzle.db.insert(walletTransaction).values({
325
+ walletId: w.id,
326
+ type: 'withdrawal',
327
+ amount: '20',
328
+ currency: 'USD',
329
+ status: 'completed',
330
+ reviewReason: 'auto-approved',
406
331
  });
407
332
  const result = await svc.withdraw({
408
- userId: 'u-1',
409
- amount: '0.5',
410
- currency: 'BTC',
411
- destinationAddress: 'bc1qtest',
333
+ userId: w.userId,
334
+ amount: '40',
335
+ currency: 'USD',
412
336
  ...NO_CLIENT_META,
413
337
  });
414
- expect(result).toEqual({ transactionId: 'tx-1', status: 'completed' });
415
- expect(payment.processWithdrawal).toHaveBeenCalledTimes(1);
416
- expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({
417
- after: expect.objectContaining({ threshold: '1', thresholdSource: 'global' }),
418
- }));
338
+ expect(result.status).toBe('pending');
419
339
  });
420
- it('stays pending when the crypto amount exceeds the configured cryptoThreshold', async () => {
421
- const btcWallet = { id: 'w-1', userId: 'u-1', balance: '10', currency: 'BTC' };
422
- const dz = makeDrizzle({
423
- select: [...holdSelect(btcWallet).map((r) => [r]), []],
424
- returning: [...holdReturning(insertRow({ currency: 'BTC', rail: 'crypto', amount: '5' }))],
340
+ it('counts only auto-approved payouts towards the daily cap', async () => {
341
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } });
342
+ const w = await seedWallet();
343
+ await db.drizzle.db.insert(walletTransaction).values({
344
+ walletId: w.id,
345
+ type: 'withdrawal',
346
+ amount: '10',
347
+ currency: 'USD',
348
+ status: 'completed',
349
+ reviewReason: 'manual sign-off',
425
350
  });
426
- const { svc, payment } = makeSvc({
427
- drizzle: dz,
428
- directory: verifiedDirectory(),
429
- autoWithdrawal: { cryptoThreshold: '1' },
351
+ const result = await svc.withdraw({
352
+ userId: w.userId,
353
+ amount: '40',
354
+ currency: 'USD',
355
+ ...NO_CLIENT_META,
430
356
  });
357
+ expect(result.status).toBe('completed');
358
+ });
359
+ it('serializes concurrent withdrawals so a count cap of one cannot be bypassed', async () => {
360
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000', dailyCapCount: 1 } });
361
+ const w = await seedWallet();
362
+ const results = await Promise.all([
363
+ svc.withdraw({ userId: w.userId, amount: '40', currency: 'USD', ...NO_CLIENT_META }),
364
+ svc.withdraw({ userId: w.userId, amount: '41', currency: 'USD', ...NO_CLIENT_META }),
365
+ ]);
366
+ expect(results.filter((r) => r.status === 'completed')).toHaveLength(1);
367
+ expect(results.filter((r) => r.status === 'pending')).toHaveLength(1);
368
+ });
369
+ });
370
+ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => {
371
+ it('stays pending when no cryptoThreshold is configured, even with a fiat one', async () => {
372
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
373
+ const w = await seedWallet({ currency: 'BTC', balance: '10' });
431
374
  const result = await svc.withdraw({
432
- userId: 'u-1',
433
- amount: '5',
375
+ userId: w.userId,
376
+ amount: '1',
434
377
  currency: 'BTC',
435
- destinationAddress: 'bc1qtest',
378
+ destinationAddress: 'bc1qexample',
436
379
  ...NO_CLIENT_META,
437
380
  });
438
381
  expect(result.status).toBe('pending');
439
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
440
- });
441
- it('stays pending for a crypto withdrawal below cryptoThreshold when KYC is not passing (same guard as fiat)', async () => {
442
- const btcWallet = { id: 'w-1', userId: 'u-1', balance: '10', currency: 'BTC' };
443
- const dz = makeDrizzle({
444
- select: [...holdSelect(btcWallet).map((r) => [r]), []],
445
- returning: [...holdReturning(insertRow({ currency: 'BTC', rail: 'crypto', amount: '0.5' }))],
446
- });
447
- const { svc, payment } = makeSvc({
448
- drizzle: dz,
449
- directory: {
450
- lookupPlayers: vi.fn().mockResolvedValue([{ userId: 'u-1', kycStatus: 'pending' }]),
451
- },
452
- autoWithdrawal: { cryptoThreshold: '1' },
382
+ });
383
+ it('auto-approves once an operator configures a positive cryptoThreshold', async () => {
384
+ const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '2' } });
385
+ const w = await seedWallet({ currency: 'BTC', balance: '10' });
386
+ const result = await svc.withdraw({
387
+ userId: w.userId,
388
+ amount: '1',
389
+ currency: 'BTC',
390
+ destinationAddress: 'bc1qexample',
391
+ ...NO_CLIENT_META,
392
+ });
393
+ expect(result.status).toBe('completed');
394
+ expect(await txById(result.transactionId)).toMatchObject({
395
+ rail: 'crypto',
396
+ providerName: 'fireblocks',
397
+ reviewReason: 'auto-approved',
453
398
  });
399
+ });
400
+ it('stays pending when the crypto amount exceeds cryptoThreshold', async () => {
401
+ const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '0.5' } });
402
+ const w = await seedWallet({ currency: 'BTC', balance: '10' });
454
403
  const result = await svc.withdraw({
455
- userId: 'u-1',
456
- amount: '0.5',
404
+ userId: w.userId,
405
+ amount: '1',
457
406
  currency: 'BTC',
458
- destinationAddress: 'bc1qtest',
407
+ destinationAddress: 'bc1qexample',
459
408
  ...NO_CLIENT_META,
460
409
  });
461
410
  expect(result.status).toBe('pending');
462
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
463
411
  });
464
- it('stays pending when no threshold is configured (no per-player rule, no global)', async () => {
465
- const dz = makeDrizzle({
466
- select: [...holdSelect().map((r) => [r]), []],
467
- returning: [...holdReturning()],
468
- });
469
- const { svc, payment } = makeSvc({
470
- drizzle: dz,
471
- directory: verifiedDirectory(),
472
- autoWithdrawal: {}, // enabled, but no fiatThreshold
412
+ it('applies the same KYC guard as the fiat rail', async () => {
413
+ const { svc } = makeService({
414
+ autoWithdrawal: { cryptoThreshold: '2' },
415
+ kycStatus: 'rejected',
473
416
  });
417
+ const w = await seedWallet({ currency: 'BTC', balance: '10' });
474
418
  const result = await svc.withdraw({
475
- userId: 'u-1',
476
- amount: '40',
477
- currency: 'USD',
419
+ userId: w.userId,
420
+ amount: '1',
421
+ currency: 'BTC',
422
+ destinationAddress: 'bc1qexample',
478
423
  ...NO_CLIENT_META,
479
424
  });
480
425
  expect(result.status).toBe('pending');
481
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
482
- });
483
- it('per-player rule below the global threshold blocks what global would allow', async () => {
484
- const dz = makeDrizzle({
485
- select: [
486
- ...holdSelect().map((r) => [r]),
487
- [{ id: 'r-1', userId: 'u-1', threshold: '10', reason: 'watch', createdBy: 'a-1' }],
488
- ],
489
- returning: [...holdReturning()],
490
- });
491
- const { svc, payment } = makeSvc({
492
- drizzle: dz,
493
- directory: verifiedDirectory(),
494
- autoWithdrawal: { fiatThreshold: '1000' },
426
+ });
427
+ });
428
+ describe('WalletService auto-approval threshold resolution (real PG)', () => {
429
+ it('a per-player rule below the global threshold blocks what global would allow', async () => {
430
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
431
+ const w = await seedWallet();
432
+ await svc.setAutoWithdrawalRule({
433
+ userId: w.userId,
434
+ threshold: '10',
435
+ reason: 'watchlist',
436
+ createdBy: randomUUID(),
495
437
  });
496
438
  const result = await svc.withdraw({
497
- userId: 'u-1',
439
+ userId: w.userId,
498
440
  amount: '40',
499
441
  currency: 'USD',
500
442
  ...NO_CLIENT_META,
501
443
  });
502
444
  expect(result.status).toBe('pending');
503
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
504
- });
505
- it('per-player rule above the global threshold allows what global would block', async () => {
506
- const dz = makeDrizzle({
507
- select: [
508
- ...holdSelect().map((r) => [r]),
509
- [{ id: 'r-1', userId: 'u-1', threshold: '1000', reason: 'trusted', createdBy: 'a-1' }],
510
- [{ n: 1 }],
511
- [{ total: '0', n: 0 }],
512
- ...finalizeSelect(),
513
- ],
514
- returning: [...holdReturning(), ...finalizeReturning()],
515
- });
516
- const { svc, payment, audit } = makeSvc({
517
- drizzle: dz,
518
- directory: verifiedDirectory(),
519
- autoWithdrawal: { fiatThreshold: '10' },
445
+ });
446
+ it('a per-player rule above the global threshold allows what global would block', async () => {
447
+ const { svc, audit } = makeService({ autoWithdrawal: { fiatThreshold: '10' } });
448
+ const w = await seedWallet();
449
+ await svc.setAutoWithdrawalRule({
450
+ userId: w.userId,
451
+ threshold: '1000',
452
+ reason: 'trusted',
453
+ createdBy: randomUUID(),
520
454
  });
521
455
  const result = await svc.withdraw({
522
- userId: 'u-1',
456
+ userId: w.userId,
523
457
  amount: '40',
524
458
  currency: 'USD',
525
459
  ...NO_CLIENT_META,
526
460
  });
527
461
  expect(result.status).toBe('completed');
528
- expect(payment.processWithdrawal).toHaveBeenCalledTimes(1);
529
462
  expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({
530
- after: expect.objectContaining({ threshold: '1000', thresholdSource: 'per-player' }),
463
+ after: expect.objectContaining({ thresholdSource: 'per-player' }),
531
464
  }));
532
465
  });
533
- });
534
- describe('WalletService auto-withdrawal rule methods', () => {
535
- beforeEach(() => vi.clearAllMocks());
536
- it('setAutoWithdrawalRule upserts and returns the rule with a decimal threshold', async () => {
537
- const dz = makeDrizzle({
538
- returning: [
539
- [
540
- {
541
- id: 'r-1',
542
- userId: 'u-1',
543
- threshold: '500',
544
- reason: 'trusted',
545
- createdBy: 'a-1',
546
- createdAt: new Date('2026-01-01T00:00:00Z'),
547
- updatedAt: new Date('2026-01-01T00:00:00Z'),
548
- },
549
- ],
550
- ],
551
- });
552
- const { svc } = makeSvc({ drizzle: dz });
553
- const rule = await svc.setAutoWithdrawalRule({
554
- userId: 'u-1',
555
- threshold: '500',
466
+ it('a per-player rule overrides the crypto threshold too', async () => {
467
+ const { svc } = makeService({ autoWithdrawal: { cryptoThreshold: '0.5' } });
468
+ const w = await seedWallet({ currency: 'BTC', balance: '10' });
469
+ await svc.setAutoWithdrawalRule({
470
+ userId: w.userId,
471
+ threshold: '5',
556
472
  reason: 'trusted',
557
- createdBy: 'a-1',
473
+ createdBy: randomUUID(),
558
474
  });
559
- expect(rule).toMatchObject({
560
- id: 'r-1',
561
- userId: 'u-1',
562
- threshold: '500',
563
- reason: 'trusted',
475
+ const result = await svc.withdraw({
476
+ userId: w.userId,
477
+ amount: '1',
478
+ currency: 'BTC',
479
+ destinationAddress: 'bc1qexample',
480
+ ...NO_CLIENT_META,
481
+ });
482
+ expect(result.status).toBe('completed');
483
+ });
484
+ it('a zero per-player threshold turns auto-approval off for that player', async () => {
485
+ const { svc } = makeService({ autoWithdrawal: { fiatThreshold: '1000' } });
486
+ const w = await seedWallet();
487
+ await svc.setAutoWithdrawalRule({
488
+ userId: w.userId,
489
+ threshold: '0',
490
+ reason: 'manual only',
491
+ createdBy: randomUUID(),
492
+ });
493
+ const result = await svc.withdraw({
494
+ userId: w.userId,
495
+ amount: '40',
496
+ currency: 'USD',
497
+ ...NO_CLIENT_META,
564
498
  });
565
- expect(typeof rule.createdAt).toBe('string');
499
+ expect(result.status).toBe('pending');
500
+ });
501
+ });
502
+ describe('WalletService auto-withdrawal rule methods (real PG)', () => {
503
+ it('setAutoWithdrawalRule inserts, then upserts on a repeat for the same player', async () => {
504
+ const { svc } = makeService();
505
+ const userId = randomUUID();
506
+ const createdBy = randomUUID();
507
+ const created = await svc.setAutoWithdrawalRule({
508
+ userId,
509
+ threshold: '500',
510
+ reason: 'initial',
511
+ createdBy,
512
+ });
513
+ const updated = await svc.setAutoWithdrawalRule({
514
+ userId,
515
+ threshold: '750',
516
+ reason: 'raised',
517
+ createdBy,
518
+ });
519
+ expect(created.id).toBe(updated.id);
520
+ expect(Number(updated.threshold)).toBe(750);
521
+ expect(updated.reason).toBe('raised');
522
+ const rows = await db.drizzle.db
523
+ .select()
524
+ .from(autoWithdrawalRule)
525
+ .where(eq(autoWithdrawalRule.userId, userId));
526
+ expect(rows).toHaveLength(1);
566
527
  });
567
528
  it('getAutoWithdrawalRule returns null when no rule exists', async () => {
568
- const dz = makeDrizzle({ select: [[]] });
569
- const { svc } = makeSvc({ drizzle: dz });
570
- expect(await svc.getAutoWithdrawalRule('u-1')).toBeNull();
529
+ const { svc } = makeService();
530
+ expect(await svc.getAutoWithdrawalRule(randomUUID())).toBeNull();
531
+ });
532
+ it('getAutoWithdrawalRule serializes the timestamps as ISO strings', async () => {
533
+ const { svc } = makeService();
534
+ const userId = randomUUID();
535
+ await svc.setAutoWithdrawalRule({
536
+ userId,
537
+ threshold: '500',
538
+ reason: 'initial',
539
+ createdBy: randomUUID(),
540
+ });
541
+ const rule = await svc.getAutoWithdrawalRule(userId);
542
+ expect(rule?.createdAt).toBe(new Date(rule?.createdAt ?? '').toISOString());
571
543
  });
572
544
  it('deleteAutoWithdrawalRule reports whether a row was removed', async () => {
573
- const dz = makeDrizzle({ returning: [[{ id: 'r-1' }]] });
574
- const { svc } = makeSvc({ drizzle: dz });
575
- expect(await svc.deleteAutoWithdrawalRule('u-1')).toBe(true);
576
- const empty = makeDrizzle({ returning: [[]] });
577
- const { svc: svc2 } = makeSvc({ drizzle: empty });
578
- expect(await svc2.deleteAutoWithdrawalRule('u-1')).toBe(false);
545
+ const { svc } = makeService();
546
+ const userId = randomUUID();
547
+ await svc.setAutoWithdrawalRule({
548
+ userId,
549
+ threshold: '500',
550
+ reason: 'initial',
551
+ createdBy: randomUUID(),
552
+ });
553
+ expect(await svc.deleteAutoWithdrawalRule(userId)).toBe(true);
554
+ expect(await svc.deleteAutoWithdrawalRule(userId)).toBe(false);
555
+ expect(await svc.getAutoWithdrawalRule(userId)).toBeNull();
579
556
  });
580
557
  });
581
558
  //# sourceMappingURL=wallet-auto-withdrawal.test.js.map