@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,1005 +1,741 @@
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, walletDepositAddress } from '../schema/index.js';
3
8
  import { WalletService, WalletNotFoundError, WithdrawalNotFoundError, WithdrawalNotPendingError, InsufficientBalanceError, CurrencyMismatchError, IdempotencyKeyReuseError, DepositAddressUnsupportedError, DestinationAddressRequiredError, } from '../service/wallet.service.js';
4
- function makeDirectory(summaries = []) {
5
- return {
6
- count: vi.fn(),
7
- list: vi.fn(),
8
- get: vi.fn(),
9
- update: vi.fn(),
10
- lookupPlayers: vi.fn().mockResolvedValue(summaries),
11
- };
9
+ let db;
10
+ function makePsp() {
11
+ const settled = async () => ({
12
+ externalId: randomUUID(),
13
+ status: 'completed',
14
+ });
15
+ return { processDeposit: vi.fn(settled), processWithdrawal: vi.fn(settled) };
16
+ }
17
+ function makeService(overrides = {}) {
18
+ const events = makeEventBus();
19
+ const psp = makePsp();
20
+ const audit = makeAuditWriter();
21
+ const svc = new WalletService({
22
+ drizzle: db.drizzle,
23
+ events: events,
24
+ payment: mock(psp),
25
+ audit,
26
+ ...overrides,
27
+ });
28
+ return { svc, events, psp, audit };
12
29
  }
13
- const svcOf = (drizzle, events, payment, directory) => new WalletService(mock({ drizzle, events, payment, directory }));
14
- describe('WalletService domain errors', () => {
15
- it('WalletNotFoundError carries the userId', () => {
16
- const err = new WalletNotFoundError('user-123');
17
- expect(err.name).toBe('WalletNotFoundError');
18
- expect(err.message).toContain('user-123');
19
- });
20
- it('InsufficientBalanceError carries available and requested amounts', () => {
21
- const err = new InsufficientBalanceError('50', '100');
22
- expect(err.message).toContain('50');
23
- expect(err.message).toContain('100');
30
+ const queueService = () => makeService().svc;
31
+ function makeDirectory(summaries) {
32
+ return mock({
33
+ lookupPlayers: vi.fn(async (ids) => summaries.filter((s) => ids.includes(s.userId))),
24
34
  });
35
+ }
36
+ async function seedWallet(overrides = {}) {
37
+ const [row] = await db.drizzle.db
38
+ .insert(wallet)
39
+ .values({ userId: randomUUID(), balance: '0', currency: 'USD', ...overrides })
40
+ .returning();
41
+ return row;
42
+ }
43
+ async function seedTx(walletId, overrides = {}) {
44
+ const [row] = await db.drizzle.db
45
+ .insert(walletTransaction)
46
+ .values({
47
+ walletId,
48
+ type: 'withdrawal',
49
+ amount: '10',
50
+ currency: 'USD',
51
+ status: 'pending',
52
+ rail: 'fiat',
53
+ ...overrides,
54
+ })
55
+ .returning();
56
+ return row;
57
+ }
58
+ async function balanceOf(userId) {
59
+ const [row] = await db.drizzle.db.select().from(wallet).where(eq(wallet.userId, userId));
60
+ return Number(row?.balance);
61
+ }
62
+ async function txRows(walletId) {
63
+ return db.drizzle.db
64
+ .select()
65
+ .from(walletTransaction)
66
+ .where(eq(walletTransaction.walletId, walletId));
67
+ }
68
+ async function txById(id) {
69
+ const [row] = await db.drizzle.db
70
+ .select()
71
+ .from(walletTransaction)
72
+ .where(eq(walletTransaction.id, id));
73
+ return row;
74
+ }
75
+ const emittedTopics = (events) => events.emit.mock.calls.map(([topic]) => topic);
76
+ beforeAll(async () => {
77
+ db = await createTestDb([migrate]);
78
+ });
79
+ afterAll(async () => {
80
+ await db.drop();
25
81
  });
26
- describe('WalletService.deposit', () => {
27
- let events;
28
- let payment;
29
- beforeEach(() => {
30
- events = makeEvents();
31
- payment = makePayment();
32
- });
33
- it('records the fiat rail for a fiat currency', async () => {
34
- const drizzle = makeDrizzle({
35
- select: [[{ id: 'w-1', userId: 'u-1', balance: '0', currency: 'USD' }], []],
36
- returning: [[{ id: 'tx-1' }]],
37
- });
38
- const valuesSpy = readPrivate(drizzle.db, 'values');
39
- const svc = svcOf(drizzle, events, payment);
40
- await svc.deposit({ userId: 'u-1', amount: '40', currency: 'USD', provider: 'stripe' });
41
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'deposit', rail: 'fiat' }));
82
+ beforeEach(async () => {
83
+ await db.drizzle.db.execute(sql `TRUNCATE ${walletTransaction}, ${walletDepositAddress}, ${wallet} RESTART IDENTITY CASCADE`);
84
+ });
85
+ describe('WalletService.deposit (real PG)', () => {
86
+ it('creates the wallet on a first deposit, credits it, and writes one completed fiat ledger row', async () => {
87
+ const { svc, events, psp } = makeService();
88
+ const userId = randomUUID();
89
+ const externalId = randomUUID();
90
+ psp.processDeposit.mockResolvedValueOnce({ externalId, status: 'completed' });
91
+ const result = await svc.deposit({
92
+ userId,
93
+ amount: '40',
94
+ currency: 'USD',
95
+ provider: 'stripe',
96
+ });
97
+ expect(result.status).toBe('completed');
98
+ expect(await balanceOf(userId)).toBe(40);
99
+ const [created] = await db.drizzle.db.select().from(wallet).where(eq(wallet.userId, userId));
100
+ const rows = await txRows(created.id);
101
+ expect(rows).toHaveLength(1);
102
+ expect(rows[0]).toMatchObject({
103
+ type: 'deposit',
104
+ status: 'completed',
105
+ rail: 'fiat',
106
+ providerName: 'stripe',
107
+ providerRefId: externalId,
108
+ });
109
+ expect(events.emit).toHaveBeenCalledWith('wallet.deposit.completed', expect.objectContaining({ userId, amount: '40', transactionId: result.transactionId }));
42
110
  });
43
111
  it('records the crypto rail for a crypto currency', async () => {
44
- const drizzle = makeDrizzle({
45
- select: [[{ id: 'w-1', userId: 'u-1', balance: '0', currency: 'BTC' }], []],
46
- returning: [[{ id: 'tx-2' }]],
47
- });
48
- const valuesSpy = readPrivate(drizzle.db, 'values');
49
- const svc = svcOf(drizzle, events, payment);
50
- await svc.deposit({ userId: 'u-1', amount: '1', currency: 'BTC', provider: 'fireblocks' });
51
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'deposit', rail: 'crypto' }));
112
+ const { svc } = makeService();
113
+ const w = await seedWallet({ currency: 'BTC' });
114
+ await svc.deposit({ userId: w.userId, amount: '1', currency: 'BTC', provider: 'fireblocks' });
115
+ const rows = await txRows(w.id);
116
+ expect(rows[0]).toMatchObject({ type: 'deposit', rail: 'crypto' });
117
+ });
118
+ it('accumulates repeated deposits with SQL numeric arithmetic', async () => {
119
+ const { svc } = makeService();
120
+ const w = await seedWallet({ balance: '10.5' });
121
+ await svc.deposit({ userId: w.userId, amount: '0.25', currency: 'USD' });
122
+ await svc.deposit({ userId: w.userId, amount: '0.25', currency: 'USD' });
123
+ expect(await balanceOf(w.userId)).toBe(11);
52
124
  });
53
125
  });
54
- describe('WalletService.withdraw', () => {
55
- let events;
56
- let payment;
57
- beforeEach(() => {
58
- events = makeEvents();
59
- payment = makePayment();
60
- });
61
- it('locks the wallet row, holds funds, creates a pending withdrawal, and emits requested', async () => {
62
- const drizzle = makeDrizzle({
63
- select: [[{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }]],
64
- returning: [
65
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
66
- [{ id: 'w-1' }], // guarded debit affected exactly one row
67
- ],
68
- });
69
- const forSpy = readPrivate(drizzle.db, 'for');
70
- const svc = svcOf(drizzle, events, payment);
126
+ describe('WalletService.withdraw (real PG)', () => {
127
+ it('holds the funds immediately, leaves the row pending, and emits requested', async () => {
128
+ const { svc, events } = makeService();
129
+ const w = await seedWallet({ balance: '100' });
71
130
  const result = await svc.withdraw({
72
- userId: 'u-1',
131
+ userId: w.userId,
73
132
  amount: '40',
74
133
  currency: 'USD',
75
134
  ...NO_CLIENT_META,
76
135
  });
77
- expect(result).toEqual({ transactionId: 'tx-1', status: 'pending' });
78
- expect(forSpy).toHaveBeenCalledWith('update');
79
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
80
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.requested', {
81
- userId: 'u-1',
82
- amount: '40',
83
- currency: 'USD',
84
- transactionId: 'tx-1',
85
- ip: null,
86
- userAgent: null,
136
+ expect(result.status).toBe('pending');
137
+ expect(await balanceOf(w.userId)).toBe(60);
138
+ expect(await txById(result.transactionId)).toMatchObject({
139
+ type: 'withdrawal',
140
+ status: 'pending',
141
+ rail: 'fiat',
87
142
  });
143
+ expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.requested', expect.objectContaining({ userId: w.userId, amount: '40', ip: null, userAgent: null }));
88
144
  });
89
- it('derives the crypto rail for crypto currencies', async () => {
90
- const drizzle = makeDrizzle({
91
- select: [[{ id: 'w-1', userId: 'u-1', balance: '5', currency: 'BTC' }]],
92
- returning: [
93
- [{ id: 'tx-2', walletId: 'w-1', amount: '1', currency: 'BTC', status: 'pending' }],
94
- [{ id: 'w-1' }],
95
- ],
96
- });
97
- const valuesSpy = readPrivate(drizzle.db, 'values');
98
- const svc = svcOf(drizzle, events, payment);
99
- await svc.withdraw({
100
- userId: 'u-1',
145
+ it('stores the destination address and the crypto rail for a crypto withdrawal', async () => {
146
+ const { svc } = makeService();
147
+ const w = await seedWallet({ balance: '2', currency: 'BTC' });
148
+ const result = await svc.withdraw({
149
+ userId: w.userId,
101
150
  amount: '1',
102
151
  currency: 'BTC',
103
- destinationAddress: 'bc1qtest',
152
+ destinationAddress: 'bc1qexample',
104
153
  ...NO_CLIENT_META,
105
154
  });
106
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ rail: 'crypto' }));
155
+ expect(await txById(result.transactionId)).toMatchObject({
156
+ rail: 'crypto',
157
+ destinationAddress: 'bc1qexample',
158
+ });
107
159
  });
108
160
  it('throws DestinationAddressRequiredError for a crypto withdrawal with no address', async () => {
109
- const drizzle = makeDrizzle({
110
- select: [[{ id: 'w-1', userId: 'u-1', balance: '5', currency: 'BTC' }]],
111
- });
112
- const svc = svcOf(drizzle, events, payment);
113
- await expect(svc.withdraw({ userId: 'u-1', amount: '1', currency: 'BTC', ...NO_CLIENT_META })).rejects.toThrow(DestinationAddressRequiredError);
114
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
161
+ const { svc } = makeService();
162
+ const w = await seedWallet({ balance: '2', currency: 'BTC' });
163
+ await expect(svc.withdraw({ userId: w.userId, amount: '1', currency: 'BTC', ...NO_CLIENT_META })).rejects.toBeInstanceOf(DestinationAddressRequiredError);
164
+ expect(await balanceOf(w.userId)).toBe(2);
115
165
  });
116
166
  it('throws CurrencyMismatchError when the request currency differs from the wallet', async () => {
117
- const drizzle = makeDrizzle({
118
- select: [[{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }]],
119
- });
120
- const svc = svcOf(drizzle, events, payment);
121
- await expect(svc.withdraw({ userId: 'u-1', amount: '40', currency: 'EUR', ...NO_CLIENT_META })).rejects.toBeInstanceOf(CurrencyMismatchError);
167
+ const { svc } = makeService();
168
+ const w = await seedWallet({ balance: '100', currency: 'USD' });
169
+ await expect(svc.withdraw({ userId: w.userId, amount: '10', currency: 'EUR', ...NO_CLIENT_META })).rejects.toBeInstanceOf(CurrencyMismatchError);
170
+ });
171
+ it('throws WalletNotFoundError when the user has no wallet', async () => {
172
+ const { svc } = makeService();
173
+ await expect(svc.withdraw({ userId: randomUUID(), amount: '10', currency: 'USD', ...NO_CLIENT_META })).rejects.toBeInstanceOf(WalletNotFoundError);
174
+ });
175
+ it('rolls the whole transaction back when the guarded debit finds too little balance', async () => {
176
+ const { svc, events } = makeService();
177
+ const w = await seedWallet({ balance: '30' });
178
+ await expect(svc.withdraw({ userId: w.userId, amount: '40', currency: 'USD', ...NO_CLIENT_META })).rejects.toBeInstanceOf(InsufficientBalanceError);
179
+ expect(await balanceOf(w.userId)).toBe(30);
180
+ expect(await txRows(w.id)).toHaveLength(0);
122
181
  expect(events.emit).not.toHaveBeenCalled();
123
182
  });
124
- it('rejects when the guarded debit affects zero rows (insufficient balance)', async () => {
125
- const drizzle = makeDrizzle({
126
- select: [[{ id: 'w-1', userId: 'u-1', balance: '10', currency: 'USD' }]],
127
- returning: [
128
- [{ id: 'tx-x', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
129
- [], // guarded UPDATE ... WHERE balance >= amount matched no row
130
- ],
131
- });
132
- const svc = svcOf(drizzle, events, payment);
133
- await expect(svc.withdraw({ userId: 'u-1', amount: '40', currency: 'USD', ...NO_CLIENT_META })).rejects.toBeInstanceOf(InsufficientBalanceError);
134
- expect(events.emit).not.toHaveBeenCalled();
183
+ it('lets two concurrent withdrawals share a balance without overdrawing it', async () => {
184
+ const { svc } = makeService();
185
+ const w = await seedWallet({ balance: '100' });
186
+ const settled = await Promise.allSettled([
187
+ svc.withdraw({ userId: w.userId, amount: '60', currency: 'USD', ...NO_CLIENT_META }),
188
+ svc.withdraw({ userId: w.userId, amount: '60', currency: 'USD', ...NO_CLIENT_META }),
189
+ ]);
190
+ expect(settled.filter((s) => s.status === 'fulfilled')).toHaveLength(1);
191
+ expect(await balanceOf(w.userId)).toBe(40);
192
+ expect(await txRows(w.id)).toHaveLength(1);
135
193
  });
136
194
  });
137
- describe('WalletService idempotency - deposit', () => {
138
- let events;
139
- let payment;
140
- beforeEach(() => {
141
- events = makeEvents();
142
- payment = makePayment();
143
- });
144
- it('replay short-circuits BEFORE the PSP call, does not insert again, and does not re-emit', async () => {
145
- const drizzle = makeDrizzle({
146
- select: [
147
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
148
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
149
- ],
150
- });
151
- const insertSpy = readPrivate(drizzle.db, 'insert');
152
- const svc = svcOf(drizzle, events, payment);
153
- const result = await svc.deposit({
154
- userId: 'u-1',
155
- amount: '40',
195
+ describe('WalletService idempotency - deposit (real PG)', () => {
196
+ it('replays before the PSP call: one row, one credit, one event', async () => {
197
+ const { svc, events, psp } = makeService();
198
+ const w = await seedWallet();
199
+ const idempotencyKey = randomUUID();
200
+ const first = await svc.deposit({
201
+ userId: w.userId,
202
+ amount: '25',
156
203
  currency: 'USD',
157
- idempotencyKey: 'key-1',
204
+ idempotencyKey,
158
205
  });
159
- expect(result).toEqual({ transactionId: 'tx-1', status: 'completed' });
160
- expect(payment.processDeposit).not.toHaveBeenCalled();
161
- expect(insertSpy).not.toHaveBeenCalled();
162
- expect(events.emit).not.toHaveBeenCalled();
163
- });
164
- it('throws IdempotencyKeyReuseError when a replayed key was used with a different amount', async () => {
165
- const drizzle = makeDrizzle({
166
- select: [
167
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
168
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
169
- ],
206
+ const replay = await svc.deposit({
207
+ userId: w.userId,
208
+ amount: '25',
209
+ currency: 'USD',
210
+ idempotencyKey,
211
+ });
212
+ expect(replay.transactionId).toBe(first.transactionId);
213
+ expect(psp.processDeposit).toHaveBeenCalledTimes(1);
214
+ expect(await txRows(w.id)).toHaveLength(1);
215
+ expect(await balanceOf(w.userId)).toBe(25);
216
+ expect(emittedTopics(events)).toEqual(['wallet.deposit.completed']);
217
+ });
218
+ it('throws IdempotencyKeyReuseError when the key is replayed with a different amount', async () => {
219
+ const { svc } = makeService();
220
+ const w = await seedWallet();
221
+ const idempotencyKey = randomUUID();
222
+ await svc.deposit({ userId: w.userId, amount: '25', currency: 'USD', idempotencyKey });
223
+ await expect(svc.deposit({ userId: w.userId, amount: '26', currency: 'USD', idempotencyKey })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
224
+ expect(await balanceOf(w.userId)).toBe(25);
225
+ });
226
+ it('treats an unnormalized replay amount as a match, not a reuse', async () => {
227
+ const { svc } = makeService();
228
+ const w = await seedWallet();
229
+ const idempotencyKey = randomUUID();
230
+ const first = await svc.deposit({
231
+ userId: w.userId,
232
+ amount: '25.00',
233
+ currency: 'USD',
234
+ idempotencyKey,
170
235
  });
171
- const svc = svcOf(drizzle, events, payment);
172
- await expect(svc.deposit({ userId: 'u-1', amount: '99', currency: 'USD', idempotencyKey: 'key-1' })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
173
- expect(payment.processDeposit).not.toHaveBeenCalled();
236
+ const replay = await svc.deposit({
237
+ userId: w.userId,
238
+ amount: '25',
239
+ currency: 'USD',
240
+ idempotencyKey,
241
+ });
242
+ expect(replay.transactionId).toBe(first.transactionId);
174
243
  });
175
244
  it('distinct idempotency keys create distinct transactions', async () => {
176
- const drizzle = makeDrizzle({
177
- select: [
178
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }], // pre-PSP replay check: wallet lookup
179
- [], // pre-PSP replay check: no existing tx for key-a
180
- [], // bare-awaited balance credit for call 1 (no .returning(); reuses pre-PSP wallet)
181
- [{ id: 'w-1', userId: 'u-1', balance: '140', currency: 'USD' }], // pre-PSP replay check: wallet lookup
182
- [], // pre-PSP replay check: no existing tx for key-b
183
- [], // bare-awaited balance credit for call 2 (no .returning())
184
- ],
185
- returning: [
186
- [{ id: 'tx-a', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
187
- [{ id: 'tx-b', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
188
- ],
189
- });
190
- const svc = svcOf(drizzle, events, payment);
191
- const r1 = await svc.deposit({
192
- userId: 'u-1',
193
- amount: '40',
245
+ const { svc } = makeService();
246
+ const w = await seedWallet();
247
+ const a = await svc.deposit({
248
+ userId: w.userId,
249
+ amount: '10',
194
250
  currency: 'USD',
195
- idempotencyKey: 'key-a',
251
+ idempotencyKey: randomUUID(),
196
252
  });
197
- const r2 = await svc.deposit({
198
- userId: 'u-1',
199
- amount: '40',
253
+ const b = await svc.deposit({
254
+ userId: w.userId,
255
+ amount: '10',
200
256
  currency: 'USD',
201
- idempotencyKey: 'key-b',
202
- });
203
- expect(r1.transactionId).toBe('tx-a');
204
- expect(r2.transactionId).toBe('tx-b');
205
- expect(events.emit).toHaveBeenCalledTimes(2);
206
- });
207
- it('with no idempotencyKey, stores a null key and behaves unchanged', async () => {
208
- const drizzle = makeDrizzle({
209
- select: [
210
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
211
- [], // bare-awaited balance credit (no .returning())
212
- ],
213
- returning: [
214
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
215
- ],
216
- });
217
- const valuesSpy = readPrivate(drizzle.db, 'values');
218
- const svc = svcOf(drizzle, events, payment);
219
- const result = await svc.deposit({ userId: 'u-1', amount: '40', currency: 'USD' });
220
- expect(result).toEqual({ transactionId: 'tx-1', status: 'completed' });
221
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: null }));
222
- expect(events.emit).toHaveBeenCalledWith('wallet.deposit.completed', expect.objectContaining({ transactionId: 'tx-1' }));
223
- });
224
- it('a race on the partial unique index re-reads the winner instead of aborting the transaction', async () => {
225
- const drizzle = makeDrizzle({
226
- select: [
227
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }], // pre-PSP replay check: wallet lookup
228
- [], // pre-PSP replay check: not found yet, both requests race past it
229
- [
230
- {
231
- id: 'tx-winner',
232
- walletId: 'w-1',
233
- amount: '40',
234
- currency: 'USD',
235
- status: 'completed',
236
- },
237
- ], // txn: re-read after the conflicting insert
238
- ],
239
- returning: [
240
- [], // onConflictDoNothing().returning() - the loser's insert is a no-op, not a thrown error
241
- ],
242
- });
243
- const svc = svcOf(drizzle, events, payment);
244
- const result = await svc.deposit({
245
- userId: 'u-1',
246
- amount: '40',
247
- currency: 'USD',
248
- idempotencyKey: 'key-1',
249
- });
250
- expect(result).toEqual({ transactionId: 'tx-winner', status: 'completed' });
251
- expect(events.emit).not.toHaveBeenCalled();
257
+ idempotencyKey: randomUUID(),
258
+ });
259
+ expect(a.transactionId).not.toBe(b.transactionId);
260
+ expect(await balanceOf(w.userId)).toBe(20);
261
+ });
262
+ it('stores a null key and credits every call when no idempotencyKey is given', async () => {
263
+ const { svc } = makeService();
264
+ const w = await seedWallet();
265
+ await svc.deposit({ userId: w.userId, amount: '10', currency: 'USD' });
266
+ await svc.deposit({ userId: w.userId, amount: '10', currency: 'USD' });
267
+ const rows = await txRows(w.id);
268
+ expect(rows).toHaveLength(2);
269
+ expect(rows.every((r) => r.idempotencyKey === null)).toBe(true);
270
+ expect(await balanceOf(w.userId)).toBe(20);
271
+ });
272
+ it('never double-credits when two first attempts race on the same key', async () => {
273
+ const { svc } = makeService();
274
+ const w = await seedWallet();
275
+ const idempotencyKey = randomUUID();
276
+ await Promise.all([
277
+ svc.deposit({ userId: w.userId, amount: '10', currency: 'USD', idempotencyKey }),
278
+ svc.deposit({ userId: w.userId, amount: '10', currency: 'USD', idempotencyKey }),
279
+ ]);
280
+ expect(await txRows(w.id)).toHaveLength(1);
281
+ expect(await balanceOf(w.userId)).toBe(10);
252
282
  });
253
- it('throws IdempotencyKeyReuseError when a replayed key was used with a different currency', async () => {
254
- const drizzle = makeDrizzle({
255
- select: [
256
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
257
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'completed' }],
258
- ],
259
- });
260
- const svc = svcOf(drizzle, events, payment);
261
- await expect(svc.deposit({ userId: 'u-1', amount: '40', currency: 'EUR', idempotencyKey: 'key-1' })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
262
- expect(payment.processDeposit).not.toHaveBeenCalled();
283
+ it('throws IdempotencyKeyReuseError when the key is replayed with a different currency', async () => {
284
+ const { svc } = makeService();
285
+ const w = await seedWallet({ currency: 'USD' });
286
+ const idempotencyKey = randomUUID();
287
+ await svc.deposit({ userId: w.userId, amount: '10', currency: 'USD', idempotencyKey });
288
+ await expect(svc.deposit({ userId: w.userId, amount: '10', currency: 'EUR', idempotencyKey })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
263
289
  });
264
290
  });
265
- describe('WalletService idempotency - withdraw', () => {
266
- let events;
267
- let payment;
268
- beforeEach(() => {
269
- events = makeEvents();
270
- payment = makePayment();
271
- });
272
- it('replay returns the first stored transaction, does not insert again, and does not re-emit', async () => {
273
- const drizzle = makeDrizzle({
274
- select: [
275
- [{ id: 'w-1', userId: 'u-1', balance: '60', currency: 'USD' }],
276
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
277
- ],
278
- });
279
- const insertSpy = readPrivate(drizzle.db, 'insert');
280
- const svc = svcOf(drizzle, events, payment);
281
- const result = await svc.withdraw({
282
- userId: 'u-1',
291
+ describe('WalletService idempotency - withdraw (real PG)', () => {
292
+ it('replays the stored transaction without a second hold or event', async () => {
293
+ const { svc, events } = makeService();
294
+ const w = await seedWallet({ balance: '100' });
295
+ const idempotencyKey = randomUUID();
296
+ const first = await svc.withdraw({
297
+ userId: w.userId,
283
298
  amount: '40',
284
299
  currency: 'USD',
285
- idempotencyKey: 'key-1',
300
+ idempotencyKey,
286
301
  ...NO_CLIENT_META,
287
302
  });
288
- expect(result).toEqual({ transactionId: 'tx-1', status: 'pending' });
289
- expect(insertSpy).not.toHaveBeenCalled();
290
- expect(events.emit).not.toHaveBeenCalled();
291
- });
292
- it('throws IdempotencyKeyReuseError when a replayed key was used with a different amount', async () => {
293
- const drizzle = makeDrizzle({
294
- select: [
295
- [{ id: 'w-1', userId: 'u-1', balance: '60', currency: 'USD' }],
296
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
297
- ],
298
- });
299
- const svc = svcOf(drizzle, events, payment);
300
- await expect(svc.withdraw({
301
- userId: 'u-1',
302
- amount: '99',
303
+ const replay = await svc.withdraw({
304
+ userId: w.userId,
305
+ amount: '40',
303
306
  currency: 'USD',
304
- idempotencyKey: 'key-1',
307
+ idempotencyKey,
305
308
  ...NO_CLIENT_META,
306
- })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
309
+ });
310
+ expect(replay).toEqual(first);
311
+ expect(await txRows(w.id)).toHaveLength(1);
312
+ expect(await balanceOf(w.userId)).toBe(60);
313
+ expect(emittedTopics(events)).toEqual(['wallet.withdrawal.requested']);
307
314
  });
308
- it('distinct idempotency keys create distinct transactions', async () => {
309
- const drizzle = makeDrizzle({
310
- select: [
311
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
312
- [], // no existing tx for key-a
313
- [{ id: 'w-1', userId: 'u-1', balance: '60', currency: 'USD' }],
314
- [], // no existing tx for key-b
315
- ],
316
- returning: [
317
- [{ id: 'tx-a', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
318
- [{ id: 'w-1' }], // guarded debit for call 1
319
- [{ id: 'tx-b', walletId: 'w-1', amount: '20', currency: 'USD', status: 'pending' }],
320
- [{ id: 'w-1' }], // guarded debit for call 2
321
- ],
322
- });
323
- const svc = svcOf(drizzle, events, payment);
324
- const r1 = await svc.withdraw({
325
- userId: 'u-1',
315
+ it('throws IdempotencyKeyReuseError when the key is replayed with a different amount', async () => {
316
+ const { svc } = makeService();
317
+ const w = await seedWallet({ balance: '100' });
318
+ const idempotencyKey = randomUUID();
319
+ await svc.withdraw({
320
+ userId: w.userId,
326
321
  amount: '40',
327
322
  currency: 'USD',
328
- idempotencyKey: 'key-a',
323
+ idempotencyKey,
329
324
  ...NO_CLIENT_META,
330
325
  });
331
- const r2 = await svc.withdraw({
332
- userId: 'u-1',
333
- amount: '20',
326
+ await expect(svc.withdraw({
327
+ userId: w.userId,
328
+ amount: '41',
334
329
  currency: 'USD',
335
- idempotencyKey: 'key-b',
330
+ idempotencyKey,
336
331
  ...NO_CLIENT_META,
337
- });
338
- expect(r1.transactionId).toBe('tx-a');
339
- expect(r2.transactionId).toBe('tx-b');
340
- expect(events.emit).toHaveBeenCalledTimes(2);
332
+ })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
333
+ expect(await balanceOf(w.userId)).toBe(60);
341
334
  });
342
- it('with no idempotencyKey, stores a null key and behaves unchanged', async () => {
343
- const drizzle = makeDrizzle({
344
- select: [[{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }]],
345
- returning: [
346
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
347
- [{ id: 'w-1' }],
348
- ],
349
- });
350
- const valuesSpy = readPrivate(drizzle.db, 'values');
351
- const svc = svcOf(drizzle, events, payment);
352
- const result = await svc.withdraw({
353
- userId: 'u-1',
335
+ it('distinct idempotency keys hold funds twice', async () => {
336
+ const { svc } = makeService();
337
+ const w = await seedWallet({ balance: '100' });
338
+ await svc.withdraw({
339
+ userId: w.userId,
354
340
  amount: '40',
355
341
  currency: 'USD',
342
+ idempotencyKey: randomUUID(),
356
343
  ...NO_CLIENT_META,
357
344
  });
358
- expect(result).toEqual({ transactionId: 'tx-1', status: 'pending' });
359
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({ idempotencyKey: null }));
360
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.requested', expect.objectContaining({ transactionId: 'tx-1' }));
361
- });
362
- it('a race on the partial unique index re-reads the winner instead of aborting the transaction', async () => {
363
- const drizzle = makeDrizzle({
364
- select: [
365
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }],
366
- [], // pre-insert check: not found yet, both requests race past it
367
- [{ id: 'tx-winner', walletId: 'w-1', amount: '40', currency: 'USD', status: 'pending' }],
368
- ],
369
- returning: [
370
- [], // onConflictDoNothing().returning() - the loser's insert is a no-op, not a thrown error
371
- ],
372
- });
373
- const svc = svcOf(drizzle, events, payment);
374
- const result = await svc.withdraw({
375
- userId: 'u-1',
345
+ await svc.withdraw({
346
+ userId: w.userId,
376
347
  amount: '40',
377
348
  currency: 'USD',
378
- idempotencyKey: 'key-1',
349
+ idempotencyKey: randomUUID(),
379
350
  ...NO_CLIENT_META,
380
351
  });
381
- expect(result).toEqual({ transactionId: 'tx-winner', status: 'pending' });
382
- expect(events.emit).not.toHaveBeenCalled();
352
+ expect(await txRows(w.id)).toHaveLength(2);
353
+ expect(await balanceOf(w.userId)).toBe(20);
383
354
  });
384
- it('throws IdempotencyKeyReuseError when a replayed key was used with a different currency', async () => {
385
- const drizzle = makeDrizzle({
386
- select: [
387
- [{ id: 'w-1', userId: 'u-1', balance: '60', currency: 'USD' }],
388
- [{ id: 'tx-1', walletId: 'w-1', amount: '40', currency: 'EUR', status: 'pending' }],
389
- ],
390
- });
391
- const svc = svcOf(drizzle, events, payment);
392
- await expect(svc.withdraw({
393
- userId: 'u-1',
394
- amount: '40',
395
- currency: 'USD',
396
- idempotencyKey: 'key-1',
397
- ...NO_CLIENT_META,
398
- })).rejects.toBeInstanceOf(IdempotencyKeyReuseError);
399
- });
400
- });
401
- describe('WalletService idempotency - namespaced across operations', () => {
402
- let events;
403
- let payment;
404
- beforeEach(() => {
405
- events = makeEvents();
406
- payment = makePayment();
407
- });
408
- it('the same raw key used for a deposit then a withdraw creates two distinct rows, not a false replay', async () => {
409
- const drizzle = makeDrizzle({
410
- select: [
411
- [{ id: 'w-1', userId: 'u-1', balance: '100', currency: 'USD' }], // deposit pre-PSP: wallet lookup
412
- [], // deposit pre-PSP: no existing tx under the deposit namespace
413
- [], // bare-awaited balance credit (no .returning())
414
- [{ id: 'w-1', userId: 'u-1', balance: '140', currency: 'USD' }], // withdraw: current (for update)
415
- [], // withdraw pre-insert check: no existing tx under the withdraw namespace
416
- ],
417
- returning: [
418
- [
419
- {
420
- id: 'tx-deposit',
421
- walletId: 'w-1',
422
- amount: '40',
423
- currency: 'USD',
424
- status: 'completed',
425
- },
426
- ],
427
- [
428
- {
429
- id: 'tx-withdraw',
430
- walletId: 'w-1',
431
- amount: '40',
432
- currency: 'USD',
433
- status: 'pending',
434
- },
435
- ],
436
- [{ id: 'w-1' }], // withdraw guarded debit
437
- ],
438
- });
439
- const svc = svcOf(drizzle, events, payment);
440
- const deposit = await svc.deposit({
441
- userId: 'u-1',
355
+ it('namespaces the key per operation so a deposit and a withdraw never collide', async () => {
356
+ const { svc } = makeService();
357
+ const w = await seedWallet({ balance: '100' });
358
+ const idempotencyKey = randomUUID();
359
+ const deposited = await svc.deposit({
360
+ userId: w.userId,
442
361
  amount: '40',
443
362
  currency: 'USD',
444
- idempotencyKey: 'shared-key',
363
+ idempotencyKey,
445
364
  });
446
- const withdraw = await svc.withdraw({
447
- userId: 'u-1',
365
+ const withdrawn = await svc.withdraw({
366
+ userId: w.userId,
448
367
  amount: '40',
449
368
  currency: 'USD',
450
- idempotencyKey: 'shared-key',
369
+ idempotencyKey,
451
370
  ...NO_CLIENT_META,
452
371
  });
453
- expect(deposit.transactionId).toBe('tx-deposit');
454
- expect(withdraw.transactionId).toBe('tx-withdraw');
455
- expect(deposit.transactionId).not.toBe(withdraw.transactionId);
456
- expect(events.emit).toHaveBeenCalledWith('wallet.deposit.completed', expect.objectContaining({ transactionId: 'tx-deposit' }));
457
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.requested', expect.objectContaining({ transactionId: 'tx-withdraw' }));
372
+ expect(deposited.transactionId).not.toBe(withdrawn.transactionId);
373
+ const rows = await txRows(w.id);
374
+ expect(rows.map((r) => r.type).sort()).toEqual(['deposit', 'withdrawal']);
375
+ expect(new Set(rows.map((r) => r.idempotencyKey)).size).toBe(2);
376
+ expect(await balanceOf(w.userId)).toBe(100);
458
377
  });
459
378
  });
460
- describe('WalletService.approveWithdrawal', () => {
461
- let events;
462
- let payment;
463
- beforeEach(() => {
464
- events = makeEvents();
465
- payment = makePayment();
466
- });
467
- it('moves pending -> processing -> completed, calls the PSP, and emits approved then completed', async () => {
468
- const drizzle = makeDrizzle({
469
- select: [
470
- [
471
- {
472
- id: 'tx-1',
473
- walletId: 'w-1',
474
- type: 'withdrawal',
475
- status: 'pending',
476
- amount: '40',
477
- currency: 'USD',
478
- },
479
- ],
480
- [{ userId: 'u-1' }], // userIdForWallet
481
- [], // final "completed" update (awaited, unused)
482
- ],
483
- returning: [
484
- [
485
- {
486
- id: 'tx-1',
487
- walletId: 'w-1',
488
- type: 'withdrawal',
489
- status: 'processing',
490
- amount: '40',
491
- currency: 'USD',
492
- rail: 'fiat',
493
- },
494
- ],
495
- ],
496
- });
497
- const setSpy = readPrivate(drizzle.db, 'set');
498
- const svc = svcOf(drizzle, events, payment);
499
- const result = await svc.approveWithdrawal('admin-1', 'tx-1');
500
- expect(result).toEqual({ transactionId: 'tx-1', status: 'completed' });
501
- expect(payment.processWithdrawal).toHaveBeenCalledWith('40', 'USD', {
502
- transactionId: 'tx-1',
503
- userId: 'u-1',
504
- rail: 'fiat',
505
- adminId: 'admin-1',
506
- });
507
- // The PSP reference and derived provider name persist on the completing update.
508
- expect(setSpy).toHaveBeenCalledWith({
379
+ describe('WalletService.approveWithdrawal (real PG)', () => {
380
+ it('moves pending -> completed, records the provider, and emits approved then completed', async () => {
381
+ const { svc, events, psp } = makeService();
382
+ const w = await seedWallet({ balance: '60' });
383
+ const adminId = randomUUID();
384
+ const pending = await seedTx(w.id, { amount: '40' });
385
+ const result = await svc.approveWithdrawal(adminId, pending.id, NO_CLIENT_META);
386
+ expect(result).toEqual({ transactionId: pending.id, status: 'completed' });
387
+ expect(psp.processWithdrawal).toHaveBeenCalledTimes(1);
388
+ expect(await txById(pending.id)).toMatchObject({
509
389
  status: 'completed',
390
+ reviewedBy: adminId,
510
391
  providerName: 'psp',
511
- providerRefId: 'ext-2',
512
- });
513
- expect(events.emit).toHaveBeenNthCalledWith(1, 'wallet.withdrawal.approved', {
514
- userId: 'u-1',
515
- amount: '40',
516
- currency: 'USD',
517
- transactionId: 'tx-1',
518
- adminId: 'admin-1',
519
- ip: null,
520
- userAgent: null,
521
392
  });
522
- expect(events.emit).toHaveBeenNthCalledWith(2, 'wallet.withdrawal.completed', {
523
- userId: 'u-1',
524
- amount: '40',
525
- currency: 'USD',
526
- transactionId: 'tx-1',
527
- });
528
- });
529
- it('on PSP failure marks failed, refunds, emits failed, and rethrows', async () => {
530
- const drizzle = makeDrizzle({
531
- select: [
532
- [
533
- {
534
- id: 'tx-1',
535
- walletId: 'w-1',
536
- type: 'withdrawal',
537
- status: 'pending',
538
- amount: '40',
539
- currency: 'USD',
540
- },
541
- ],
542
- [{ userId: 'u-1' }], // userIdForWallet
543
- [], // refund balance update (awaited, unused)
544
- ],
545
- returning: [
546
- [
547
- {
548
- id: 'tx-1',
549
- walletId: 'w-1',
550
- type: 'withdrawal',
551
- status: 'processing',
552
- amount: '40',
553
- currency: 'USD',
554
- },
555
- ],
556
- [{ id: 'tx-1' }],
557
- ],
558
- });
559
- payment.processWithdrawal.mockRejectedValueOnce(new Error('psp down'));
560
- const setSpy = readPrivate(drizzle.db, 'set');
561
- const svc = svcOf(drizzle, events, payment);
562
- await expect(svc.approveWithdrawal('admin-1', 'tx-1')).rejects.toThrow('psp down');
563
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' }));
564
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ balance: expect.anything() }));
565
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.failed', {
566
- userId: 'u-1',
567
- amount: '40',
568
- currency: 'USD',
569
- transactionId: 'tx-1',
570
- adminId: 'admin-1',
571
- });
572
- expect(events.emit).not.toHaveBeenCalledWith('wallet.withdrawal.completed', expect.anything());
573
- });
574
- it('rejects a double-approve (status not pending) as a conflict', async () => {
575
- const drizzle = makeDrizzle({
576
- select: [
577
- [
578
- {
579
- id: 'tx-1',
580
- walletId: 'w-1',
581
- type: 'withdrawal',
582
- status: 'processing',
583
- amount: '40',
584
- currency: 'USD',
585
- },
586
- ],
587
- ],
588
- });
589
- const svc = svcOf(drizzle, events, payment);
590
- await expect(svc.approveWithdrawal('admin-1', 'tx-1')).rejects.toBeInstanceOf(WithdrawalNotPendingError);
591
- expect(payment.processWithdrawal).not.toHaveBeenCalled();
592
- expect(events.emit).not.toHaveBeenCalled();
393
+ expect(await balanceOf(w.userId)).toBe(60);
394
+ expect(emittedTopics(events)).toEqual([
395
+ 'wallet.withdrawal.approved',
396
+ 'wallet.withdrawal.completed',
397
+ ]);
398
+ });
399
+ it('records the fireblocks provider for a crypto-rail withdrawal', async () => {
400
+ const { svc } = makeService();
401
+ const w = await seedWallet({ currency: 'BTC' });
402
+ const pending = await seedTx(w.id, { currency: 'BTC', rail: 'crypto', amount: '1' });
403
+ await svc.approveWithdrawal(randomUUID(), pending.id);
404
+ expect(await txById(pending.id)).toMatchObject({ providerName: 'fireblocks' });
405
+ });
406
+ it('marks failed, refunds the hold, emits failed, and rethrows when the PSP throws', async () => {
407
+ const { svc, events, psp } = makeService();
408
+ const w = await seedWallet({ balance: '20' });
409
+ const pending = await seedTx(w.id, { amount: '40' });
410
+ psp.processWithdrawal.mockRejectedValueOnce(new Error('psp down'));
411
+ await expect(svc.approveWithdrawal(randomUUID(), pending.id)).rejects.toThrow('psp down');
412
+ expect(await txById(pending.id)).toMatchObject({ status: 'failed' });
413
+ expect(await balanceOf(w.userId)).toBe(60);
414
+ expect(emittedTopics(events)).toEqual([
415
+ 'wallet.withdrawal.approved',
416
+ 'wallet.withdrawal.failed',
417
+ ]);
593
418
  });
594
- it('throws WithdrawalNotFoundError when missing', async () => {
595
- const drizzle = makeDrizzle({ select: [[]] });
596
- const svc = svcOf(drizzle, events, payment);
597
- await expect(svc.approveWithdrawal('admin-1', 'missing')).rejects.toBeInstanceOf(WithdrawalNotFoundError);
419
+ it('marks failed and refunds when the PSP answers with a failed status', async () => {
420
+ const { svc, psp } = makeService();
421
+ const w = await seedWallet({ balance: '20' });
422
+ const pending = await seedTx(w.id, { amount: '40' });
423
+ psp.processWithdrawal.mockResolvedValueOnce({ externalId: randomUUID(), status: 'failed' });
424
+ const result = await svc.approveWithdrawal(randomUUID(), pending.id);
425
+ expect(result.status).toBe('failed');
426
+ expect(await balanceOf(w.userId)).toBe(60);
427
+ });
428
+ it('leaves the row processing when an async vendor returns a non-terminal status', async () => {
429
+ const { svc, events, psp } = makeService();
430
+ const w = await seedWallet({ balance: '20' });
431
+ const pending = await seedTx(w.id, { amount: '40' });
432
+ const externalId = randomUUID();
433
+ psp.processWithdrawal.mockResolvedValueOnce({ externalId, status: 'processing' });
434
+ const result = await svc.approveWithdrawal(randomUUID(), pending.id);
435
+ expect(result.status).toBe('processing');
436
+ expect(await txById(pending.id)).toMatchObject({
437
+ status: 'processing',
438
+ providerRefId: externalId,
439
+ });
440
+ expect(await balanceOf(w.userId)).toBe(20);
441
+ expect(emittedTopics(events)).toEqual(['wallet.withdrawal.approved']);
442
+ });
443
+ it('rejects a double approve as a conflict', async () => {
444
+ const { svc } = makeService();
445
+ const w = await seedWallet({ balance: '60' });
446
+ const pending = await seedTx(w.id, { amount: '40' });
447
+ await svc.approveWithdrawal(randomUUID(), pending.id);
448
+ await expect(svc.approveWithdrawal(randomUUID(), pending.id)).rejects.toBeInstanceOf(WithdrawalNotPendingError);
449
+ });
450
+ it('throws WithdrawalNotFoundError when the id is unknown', async () => {
451
+ const { svc } = makeService();
452
+ await expect(svc.approveWithdrawal(randomUUID(), randomUUID())).rejects.toBeInstanceOf(WithdrawalNotFoundError);
598
453
  });
599
454
  });
600
- describe('WalletService.rejectWithdrawal', () => {
601
- let events;
602
- let payment;
603
- beforeEach(() => {
604
- events = makeEvents();
605
- payment = makePayment();
606
- });
607
- it('returns funds, sets rejected, and emits rejected with reason', async () => {
608
- const drizzle = makeDrizzle({
609
- select: [
610
- [
611
- {
612
- id: 'tx-1',
613
- walletId: 'w-1',
614
- type: 'withdrawal',
615
- status: 'pending',
616
- amount: '40',
617
- currency: 'USD',
618
- },
619
- ],
620
- [], // awaited balance credit-back update (result unused)
621
- [{ userId: 'u-1' }], // userIdForWallet
622
- ],
623
- returning: [
624
- [
625
- {
626
- id: 'tx-1',
627
- walletId: 'w-1',
628
- type: 'withdrawal',
629
- status: 'rejected',
630
- amount: '40',
631
- currency: 'USD',
632
- },
633
- ],
634
- ],
635
- });
636
- const setSpy = readPrivate(drizzle.db, 'set');
637
- const svc = svcOf(drizzle, events, payment);
638
- const result = await svc.rejectWithdrawal('admin-1', 'tx-1', 'AML hold');
639
- expect(result).toEqual({ transactionId: 'tx-1', status: 'rejected' });
640
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ balance: expect.anything() }));
641
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.rejected', {
642
- userId: 'u-1',
643
- amount: '40',
644
- currency: 'USD',
645
- transactionId: 'tx-1',
646
- adminId: 'admin-1',
647
- reason: 'AML hold',
648
- ip: null,
649
- userAgent: null,
650
- });
651
- });
652
- it('rejects a double-reject (status not pending) as a conflict', async () => {
653
- const drizzle = makeDrizzle({
654
- select: [
655
- [
656
- {
657
- id: 'tx-1',
658
- walletId: 'w-1',
659
- type: 'withdrawal',
660
- status: 'rejected',
661
- amount: '40',
662
- currency: 'USD',
663
- },
664
- ],
665
- ],
666
- });
667
- const svc = svcOf(drizzle, events, payment);
668
- await expect(svc.rejectWithdrawal('admin-1', 'tx-1', 'again')).rejects.toBeInstanceOf(WithdrawalNotPendingError);
669
- expect(events.emit).not.toHaveBeenCalled();
455
+ describe('WalletService.rejectWithdrawal (real PG)', () => {
456
+ it('returns the held funds, records the reason, and emits rejected', async () => {
457
+ const { svc, events } = makeService();
458
+ const w = await seedWallet({ balance: '20' });
459
+ const adminId = randomUUID();
460
+ const pending = await seedTx(w.id, { amount: '40' });
461
+ const result = await svc.rejectWithdrawal(adminId, pending.id, 'suspicious', NO_CLIENT_META);
462
+ expect(result).toEqual({ transactionId: pending.id, status: 'rejected' });
463
+ expect(await balanceOf(w.userId)).toBe(60);
464
+ expect(await txById(pending.id)).toMatchObject({
465
+ status: 'rejected',
466
+ reviewedBy: adminId,
467
+ reviewReason: 'suspicious',
468
+ });
469
+ expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.rejected', expect.objectContaining({ userId: w.userId, reason: 'suspicious', adminId }));
470
+ });
471
+ it('rejects a double reject as a conflict', async () => {
472
+ const { svc } = makeService();
473
+ const w = await seedWallet({ balance: '20' });
474
+ const pending = await seedTx(w.id, { amount: '40' });
475
+ await svc.rejectWithdrawal(randomUUID(), pending.id, 'first');
476
+ await expect(svc.rejectWithdrawal(randomUUID(), pending.id, 'second')).rejects.toBeInstanceOf(WithdrawalNotPendingError);
477
+ expect(await balanceOf(w.userId)).toBe(60);
670
478
  });
671
479
  });
672
- describe('WalletService.listWithdrawals', () => {
673
- const queueRow = (id, userId, overrides = {}) => ({
674
- tx: {
675
- id,
676
- walletId: 'w-1',
677
- amount: '40',
678
- currency: 'USD',
679
- rail: 'fiat',
680
- status: 'pending',
681
- createdAt: new Date('2026-06-01T00:00:00Z'),
682
- ...overrides,
683
- },
684
- userId,
685
- });
686
- it('enriches rows with username + kycStatus via the directory port', async () => {
687
- const drizzle = makeDrizzle({ select: [[queueRow('tx-1', 'u-1')]] });
688
- const directory = makeDirectory([{ userId: 'u-1', username: 'alice', kycStatus: 'verified' }]);
689
- const svc = svcOf(drizzle, makeEvents(), makePayment(), directory);
690
- const result = await svc.listWithdrawals({ page: 1, limit: 50 });
691
- expect(directory.lookupPlayers).toHaveBeenCalledWith(['u-1']);
692
- expect(result.total).toBe(1);
693
- expect(result.items[0]).toMatchObject({
694
- transactionId: 'tx-1',
695
- username: 'alice',
480
+ describe('WalletService.listWithdrawals (real PG)', () => {
481
+ it('enriches rows with username and kycStatus from the directory port', async () => {
482
+ const w = await seedWallet();
483
+ const directory = makeDirectory([
484
+ { userId: w.userId, username: 'player1', kycStatus: 'verified' },
485
+ ]);
486
+ const { svc } = makeService({ directory });
487
+ await seedTx(w.id, { amount: '10' });
488
+ const { items, total } = await svc.listWithdrawals({ page: 1, limit: 20 });
489
+ expect(total).toBe(1);
490
+ expect(items[0]).toMatchObject({
491
+ userId: w.userId,
492
+ username: 'player1',
696
493
  kycStatus: 'verified',
697
- rail: 'fiat',
698
494
  riskTags: [],
699
- requestedAt: '2026-06-01T00:00:00.000Z',
700
495
  });
701
496
  });
702
- it('applies the kycStatus filter in memory with a correct total and a full page', async () => {
703
- const rows = [
704
- queueRow('tx-1', 'u-1'),
705
- queueRow('tx-2', 'u-2'),
706
- queueRow('tx-3', 'u-3'),
707
- queueRow('tx-4', 'u-4'),
708
- ];
709
- const drizzle = makeDrizzle({ select: [rows] });
710
- const directory = makeDirectory([
711
- { userId: 'u-1', username: 'a', kycStatus: 'verified' },
712
- { userId: 'u-2', username: 'b', kycStatus: 'pending' },
713
- { userId: 'u-3', username: 'c', kycStatus: 'verified' },
714
- { userId: 'u-4', username: 'd', kycStatus: 'verified' },
715
- ]);
716
- const svc = svcOf(drizzle, makeEvents(), makePayment(), directory);
717
- const result = await svc.listWithdrawals({ page: 1, limit: 2, kycStatus: 'verified' });
718
- // 3 verified rows survive the in-memory filter; total reflects the filtered set, not the page.
719
- expect(result.total).toBe(3);
720
- expect(result.items).toHaveLength(2);
721
- expect(result.items.map((i) => i.transactionId)).toEqual(['tx-1', 'tx-3']);
722
- });
723
- it('returns the second page sliced from the filtered set', async () => {
724
- const rows = [queueRow('tx-1', 'u-1'), queueRow('tx-3', 'u-3')];
725
- const drizzle = makeDrizzle({ select: [rows] });
497
+ it('returns rows of every status when no status filter is given', async () => {
498
+ const w = await seedWallet();
499
+ await seedTx(w.id, { status: 'pending' });
500
+ await seedTx(w.id, { status: 'completed' });
501
+ await seedTx(w.id, { status: 'rejected' });
502
+ const { items } = await queueService().listWithdrawals({ page: 1, limit: 20 });
503
+ expect(items.map((i) => i.status).sort()).toEqual(['completed', 'pending', 'rejected']);
504
+ });
505
+ it('excludes deposits from the withdrawal queue', async () => {
506
+ const w = await seedWallet();
507
+ await seedTx(w.id, { type: 'deposit', status: 'completed' });
508
+ await seedTx(w.id, { type: 'withdrawal' });
509
+ const { total } = await queueService().listWithdrawals({ page: 1, limit: 20 });
510
+ expect(total).toBe(1);
511
+ });
512
+ it('applies the kycStatus filter to the total, not just the page', async () => {
513
+ const verified = await seedWallet();
514
+ const pendingKyc = await seedWallet();
515
+ await seedTx(verified.id);
516
+ await seedTx(pendingKyc.id);
726
517
  const directory = makeDirectory([
727
- { userId: 'u-1', username: 'a', kycStatus: 'verified' },
728
- { userId: 'u-3', username: 'c', kycStatus: 'verified' },
518
+ { userId: verified.userId, username: 'a', kycStatus: 'verified' },
519
+ { userId: pendingKyc.userId, username: 'b', kycStatus: 'pending' },
729
520
  ]);
730
- const svc = svcOf(drizzle, makeEvents(), makePayment(), directory);
731
- const result = await svc.listWithdrawals({ page: 2, limit: 1, kycStatus: 'verified' });
732
- expect(result.total).toBe(2);
733
- expect(result.items.map((i) => i.transactionId)).toEqual(['tx-3']);
734
- });
735
- it('returns rows of any status when no status filter is given (not pending-only)', async () => {
736
- const rows = [
737
- queueRow('tx-1', 'u-1', { status: 'pending' }),
738
- queueRow('tx-2', 'u-2', { status: 'completed' }),
739
- queueRow('tx-3', 'u-3', { status: 'rejected' }),
740
- ];
741
- const drizzle = makeDrizzle({ select: [rows] });
742
- const svc = svcOf(drizzle, makeEvents(), makePayment(), makeDirectory());
743
- const result = await svc.listWithdrawals({ page: 1, limit: 50 });
744
- expect(result.total).toBe(3);
745
- expect(result.items.map((i) => i.status)).toEqual(['pending', 'completed', 'rejected']);
746
- });
747
- it('tags large_amount when the amount is at/above the threshold, not below', async () => {
748
- const big = makeDrizzle({
749
- select: [[queueRow('tx-1', 'u-1', { amount: '5000' })], []],
750
- });
751
- const small = makeDrizzle({
752
- select: [[queueRow('tx-2', 'u-2', { amount: '4999.99' })], []],
753
- });
754
- const bigResult = await svcOf(big, makeEvents(), makePayment(), makeDirectory()).listWithdrawals({ page: 1, limit: 50 });
755
- const smallResult = await svcOf(small, makeEvents(), makePayment(), makeDirectory()).listWithdrawals({ page: 1, limit: 50 });
756
- expect(bigResult.items[0]?.riskTags).toContain('large_amount');
757
- expect(smallResult.items[0]?.riskTags).not.toContain('large_amount');
758
- });
759
- it('tags high_frequency when the grouped 24h count is >= 3 for the wallet, not below', async () => {
760
- // Second select entry = the batched velocity count query result.
761
- const frequent = makeDrizzle({
762
- select: [[queueRow('tx-1', 'u-1')], [{ walletId: 'w-1', n: 3 }]],
763
- });
764
- const rare = makeDrizzle({
765
- select: [[queueRow('tx-2', 'u-2')], [{ walletId: 'w-1', n: 2 }]],
766
- });
767
- const frequentResult = await svcOf(frequent, makeEvents(), makePayment(), makeDirectory()).listWithdrawals({ page: 1, limit: 50 });
768
- const rareResult = await svcOf(rare, makeEvents(), makePayment(), makeDirectory()).listWithdrawals({ page: 1, limit: 50 });
769
- expect(frequentResult.items[0]?.riskTags).toContain('high_frequency');
770
- expect(rareResult.items[0]?.riskTags).not.toContain('high_frequency');
771
- });
772
- });
773
- describe('WalletService.approveWithdrawal - async vendor', () => {
774
- it('leaves the withdrawal processing (not completed) when the vendor returns a non-terminal status', async () => {
775
- const events = makeEvents();
776
- const payment = makePayment();
777
- payment.processWithdrawal.mockResolvedValueOnce({ externalId: 'ext-3', status: 'processing' });
778
- const drizzle = makeDrizzle({
779
- select: [
780
- [
781
- {
782
- id: 'tx-1',
783
- walletId: 'w-1',
784
- type: 'withdrawal',
785
- status: 'pending',
786
- amount: '40',
787
- currency: 'USD',
788
- },
789
- ],
790
- [{ userId: 'u-1' }],
791
- [],
792
- ],
793
- returning: [
794
- [
795
- {
796
- id: 'tx-1',
797
- walletId: 'w-1',
798
- type: 'withdrawal',
799
- status: 'processing',
800
- amount: '40',
801
- currency: 'USD',
802
- rail: 'fiat',
803
- },
804
- ],
805
- ],
806
- });
807
- const setSpy = readPrivate(drizzle.db, 'set');
808
- const svc = svcOf(drizzle, events, payment);
809
- const result = await svc.approveWithdrawal('admin-1', 'tx-1');
810
- expect(result).toEqual({ transactionId: 'tx-1', status: 'processing' });
811
- expect(setSpy).toHaveBeenCalledWith({ providerName: 'psp', providerRefId: 'ext-3' });
812
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.approved', expect.objectContaining({ transactionId: 'tx-1' }));
813
- expect(events.emit).not.toHaveBeenCalledWith('wallet.withdrawal.completed', expect.anything());
521
+ const { svc } = makeService({ directory });
522
+ const { items, total } = await svc.listWithdrawals({
523
+ page: 1,
524
+ limit: 20,
525
+ kycStatus: 'verified',
526
+ });
527
+ expect(total).toBe(1);
528
+ expect(items[0]?.userId).toBe(verified.userId);
529
+ });
530
+ it('slices the requested page out of the filtered set', async () => {
531
+ const w = await seedWallet();
532
+ await seedTx(w.id, { createdAt: new Date('2026-01-01T00:00:00.000Z') });
533
+ await seedTx(w.id, { createdAt: new Date('2026-01-02T00:00:00.000Z') });
534
+ await seedTx(w.id, { createdAt: new Date('2026-01-03T00:00:00.000Z') });
535
+ const { items, total } = await queueService().listWithdrawals({ page: 2, limit: 2 });
536
+ expect(total).toBe(3);
537
+ expect(items).toHaveLength(1);
538
+ });
539
+ it('tags large_amount at or above the threshold, not below it', async () => {
540
+ const w = await seedWallet();
541
+ const big = await seedTx(w.id, { amount: '5000' });
542
+ const small = await seedTx(w.id, { amount: '4999.99' });
543
+ const { items } = await queueService().listWithdrawals({ page: 1, limit: 20 });
544
+ const byId = new Map(items.map((i) => [i.transactionId, i.riskTags]));
545
+ expect(byId.get(big.id)).toContain('large_amount');
546
+ expect(byId.get(small.id)).not.toContain('large_amount');
547
+ });
548
+ it('tags high_frequency once the wallet has three withdrawals in the trailing 24h', async () => {
549
+ const quiet = await seedWallet();
550
+ const busy = await seedWallet();
551
+ await seedTx(quiet.id);
552
+ await seedTx(busy.id);
553
+ await seedTx(busy.id);
554
+ await seedTx(busy.id);
555
+ const { items } = await queueService().listWithdrawals({ page: 1, limit: 20 });
556
+ const busyItem = items.find((i) => i.userId === busy.userId);
557
+ const quietItem = items.find((i) => i.userId === quiet.userId);
558
+ expect(busyItem?.riskTags).toContain('high_frequency');
559
+ expect(quietItem?.riskTags).not.toContain('high_frequency');
560
+ });
561
+ it('ignores withdrawals older than the velocity window', async () => {
562
+ const w = await seedWallet();
563
+ const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
564
+ await seedTx(w.id, { createdAt: old });
565
+ await seedTx(w.id, { createdAt: old });
566
+ await seedTx(w.id);
567
+ const { items } = await queueService().listWithdrawals({ page: 1, limit: 20 });
568
+ expect(items.every((i) => !i.riskTags.includes('high_frequency'))).toBe(true);
814
569
  });
815
570
  });
816
- describe('WalletService.reconcileWithdrawalStatus', () => {
817
- const PROCESSING_TX = {
818
- id: 'tx-1',
819
- walletId: 'w-1',
820
- type: 'withdrawal',
821
- status: 'processing',
822
- amount: '40',
823
- currency: 'USD',
824
- };
825
- it('completed: transitions processing -> completed and emits wallet.withdrawal.completed', async () => {
826
- const events = makeEvents();
827
- const drizzle = makeDrizzle({
828
- select: [[PROCESSING_TX], [{ userId: 'u-1' }]],
829
- returning: [[{ id: 'tx-1' }]],
830
- });
831
- const setSpy = readPrivate(drizzle.db, 'set');
832
- const svc = svcOf(drizzle, events, makePayment());
571
+ describe('WalletService.reconcileWithdrawalStatus (real PG)', () => {
572
+ it('transitions processing -> completed and emits completed', async () => {
573
+ const { svc, events } = makeService();
574
+ const w = await seedWallet();
575
+ const externalId = randomUUID();
576
+ const tx = await seedTx(w.id, { status: 'processing', providerRefId: externalId });
833
577
  await svc.reconcileWithdrawalStatus({
834
578
  kind: 'withdrawal',
835
- externalId: 'ext-9',
579
+ externalId,
836
580
  status: 'completed',
581
+ txHash: '0xabc',
837
582
  });
838
- expect(setSpy).toHaveBeenCalledWith({ status: 'completed' });
839
- expect(events.emit).toHaveBeenCalledWith('wallet.withdrawal.completed', {
840
- userId: 'u-1',
583
+ expect(await txById(tx.id)).toMatchObject({ status: 'completed', txHash: '0xabc' });
584
+ expect(emittedTopics(events)).toEqual(['wallet.withdrawal.completed']);
585
+ });
586
+ it('refunds and marks failed without an admin-attributed event', async () => {
587
+ const { svc, events } = makeService();
588
+ const w = await seedWallet({ balance: '0' });
589
+ const externalId = randomUUID();
590
+ const tx = await seedTx(w.id, {
591
+ status: 'processing',
841
592
  amount: '40',
842
- currency: 'USD',
843
- transactionId: 'tx-1',
593
+ providerRefId: externalId,
844
594
  });
595
+ await svc.reconcileWithdrawalStatus({ kind: 'withdrawal', externalId, status: 'failed' });
596
+ expect(await txById(tx.id)).toMatchObject({ status: 'failed' });
597
+ expect(await balanceOf(w.userId)).toBe(40);
598
+ expect(events.emit).not.toHaveBeenCalled();
845
599
  });
846
- it('failed: refunds and marks failed, without an admin-attributed event (system/webhook-driven)', async () => {
847
- const events = makeEvents();
848
- const drizzle = makeDrizzle({
849
- select: [[PROCESSING_TX], [{ userId: 'u-1' }], []],
850
- returning: [[{ id: 'tx-1' }]],
851
- });
852
- const setSpy = readPrivate(drizzle.db, 'set');
853
- const svc = svcOf(drizzle, events, makePayment());
854
- await svc.reconcileWithdrawalStatus({
855
- kind: 'withdrawal',
856
- externalId: 'ext-9',
857
- status: 'failed',
858
- });
859
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed' }));
860
- expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ balance: expect.anything() }));
861
- expect(events.emit).not.toHaveBeenCalledWith('wallet.withdrawal.failed', expect.anything());
862
- });
863
- it('no-ops when the transaction is already terminal (idempotent replay)', async () => {
864
- const events = makeEvents();
865
- const drizzle = makeDrizzle({ select: [[{ ...PROCESSING_TX, status: 'completed' }]] });
866
- const setSpy = readPrivate(drizzle.db, 'set');
867
- const svc = svcOf(drizzle, events, makePayment());
868
- await svc.reconcileWithdrawalStatus({
869
- kind: 'withdrawal',
870
- externalId: 'ext-9',
871
- status: 'completed',
872
- });
873
- expect(setSpy).not.toHaveBeenCalled();
600
+ it('no-ops on a replay of an already terminal transaction', async () => {
601
+ const { svc, events } = makeService();
602
+ const w = await seedWallet({ balance: '0' });
603
+ const externalId = randomUUID();
604
+ const tx = await seedTx(w.id, { status: 'completed', amount: '40', providerRefId: externalId });
605
+ await svc.reconcileWithdrawalStatus({ kind: 'withdrawal', externalId, status: 'failed' });
606
+ expect(await txById(tx.id)).toMatchObject({ status: 'completed' });
607
+ expect(await balanceOf(w.userId)).toBe(0);
874
608
  expect(events.emit).not.toHaveBeenCalled();
875
609
  });
876
- it('no-ops when no transaction matches the providerRefId', async () => {
877
- const events = makeEvents();
878
- const drizzle = makeDrizzle({ select: [[]] });
879
- const svc = svcOf(drizzle, events, makePayment());
610
+ it('no-ops when no transaction carries the providerRefId', async () => {
611
+ const { svc, events } = makeService();
880
612
  await svc.reconcileWithdrawalStatus({
881
613
  kind: 'withdrawal',
882
- externalId: 'unknown',
614
+ externalId: randomUUID(),
883
615
  status: 'completed',
884
616
  });
885
617
  expect(events.emit).not.toHaveBeenCalled();
886
618
  });
887
619
  });
888
- describe('WalletService.getOrCreateDepositAddress', () => {
889
- it('returns an already-issued address without calling the adapter again', async () => {
890
- const events = makeEvents();
891
- const payment = makePayment();
892
- const drizzle = makeDrizzle({
893
- select: [
894
- [
895
- {
896
- id: 'da-1',
897
- userId: 'u-1',
898
- currency: 'BTC',
899
- address: 'bc1qexisting',
900
- providerName: 'fireblocks',
901
- createdAt: new Date('2026-01-01T00:00:00Z'),
902
- },
903
- ],
904
- ],
905
- });
906
- const svc = svcOf(drizzle, events, payment);
907
- const result = await svc.getOrCreateDepositAddress('u-1', 'BTC');
908
- expect(result).toEqual({ address: 'bc1qexisting', currency: 'BTC' });
620
+ describe('WalletService.getOrCreateDepositAddress (real PG)', () => {
621
+ it('returns the already-issued address without calling the adapter again', async () => {
622
+ const userId = randomUUID();
623
+ const issueDepositAddress = vi.fn();
624
+ const { svc } = makeService({
625
+ payment: mock({ ...makePsp(), issueDepositAddress }),
626
+ });
627
+ await db.drizzle.db.insert(walletDepositAddress).values({
628
+ userId,
629
+ currency: 'BTC',
630
+ address: 'bc1qexisting',
631
+ providerName: 'fireblocks',
632
+ });
633
+ expect(await svc.getOrCreateDepositAddress(userId, 'BTC')).toEqual({
634
+ address: 'bc1qexisting',
635
+ currency: 'BTC',
636
+ });
637
+ expect(issueDepositAddress).not.toHaveBeenCalled();
909
638
  });
910
639
  it('issues and persists a new address on the first call', async () => {
911
- const events = makeEvents();
912
- const payment = {
913
- ...makePayment(),
914
- issueDepositAddress: vi.fn().mockResolvedValue({ address: 'bc1qnew' }),
915
- };
916
- const drizzle = makeDrizzle({
917
- select: [[]],
918
- returning: [
919
- [
920
- {
921
- id: 'da-2',
922
- userId: 'u-1',
923
- currency: 'BTC',
924
- address: 'bc1qnew',
925
- providerName: 'fireblocks',
926
- createdAt: new Date('2026-01-01T00:00:00Z'),
927
- },
928
- ],
929
- ],
930
- });
931
- const svc = svcOf(drizzle, events, payment);
932
- const result = await svc.getOrCreateDepositAddress('u-1', 'BTC');
640
+ const userId = randomUUID();
641
+ const issueDepositAddress = vi.fn(async () => ({ address: 'bc1qnew' }));
642
+ const { svc } = makeService({
643
+ payment: mock({ ...makePsp(), issueDepositAddress }),
644
+ });
645
+ const result = await svc.getOrCreateDepositAddress(userId, 'BTC');
933
646
  expect(result).toEqual({ address: 'bc1qnew', currency: 'BTC' });
934
- expect(payment.issueDepositAddress).toHaveBeenCalledWith('u-1', 'BTC');
647
+ const [stored] = await db.drizzle.db
648
+ .select()
649
+ .from(walletDepositAddress)
650
+ .where(eq(walletDepositAddress.userId, userId));
651
+ expect(stored).toMatchObject({ address: 'bc1qnew', providerName: 'fireblocks' });
652
+ });
653
+ it('issues one address when two calls race on the same user and currency', async () => {
654
+ const userId = randomUUID();
655
+ const issueDepositAddress = vi.fn(async () => ({ address: 'bc1qrace' }));
656
+ const { svc } = makeService({
657
+ payment: mock({ ...makePsp(), issueDepositAddress }),
658
+ });
659
+ const [a, b] = await Promise.all([
660
+ svc.getOrCreateDepositAddress(userId, 'BTC'),
661
+ svc.getOrCreateDepositAddress(userId, 'BTC'),
662
+ ]);
663
+ expect(a).toEqual(b);
664
+ const stored = await db.drizzle.db
665
+ .select()
666
+ .from(walletDepositAddress)
667
+ .where(eq(walletDepositAddress.userId, userId));
668
+ expect(stored).toHaveLength(1);
935
669
  });
936
- it('throws DepositAddressUnsupportedError when the bound adapter has no issueDepositAddress', async () => {
937
- const events = makeEvents();
938
- const drizzle = makeDrizzle({ select: [[]] });
939
- const svc = svcOf(drizzle, events, makePayment());
940
- await expect(svc.getOrCreateDepositAddress('u-1', 'BTC')).rejects.toBeInstanceOf(DepositAddressUnsupportedError);
670
+ it('throws DepositAddressUnsupportedError when the adapter cannot issue addresses', async () => {
671
+ const { svc } = makeService();
672
+ await expect(svc.getOrCreateDepositAddress(randomUUID(), 'BTC')).rejects.toBeInstanceOf(DepositAddressUnsupportedError);
941
673
  });
942
674
  });
943
- describe('WalletService.creditDepositByAddress', () => {
944
- const DEPOSIT_ADDRESS_ROW = {
945
- id: 'da-1',
946
- userId: 'u-1',
947
- currency: 'BTC',
948
- address: 'bc1qxyz',
949
- providerName: 'fireblocks',
950
- createdAt: new Date('2026-01-01T00:00:00Z'),
951
- };
952
- const WALLET_ROW = { id: 'w-1', userId: 'u-1', balance: '0', currency: 'BTC' };
953
- const EVENT = {
954
- kind: 'deposit',
955
- address: 'bc1qxyz',
956
- amount: '0.5',
957
- currency: 'BTC',
958
- txHash: '0xabc',
959
- externalId: 'vendor-ext-1',
960
- };
961
- it('resolves the address, credits the wallet, and emits wallet.deposit.completed', async () => {
962
- const events = makeEvents();
963
- const drizzle = makeDrizzle({
964
- select: [[DEPOSIT_ADDRESS_ROW], [WALLET_ROW], []],
965
- returning: [[{ id: 'tx-1', walletId: 'w-1' }]],
966
- });
967
- const valuesSpy = readPrivate(drizzle.db, 'values');
968
- const svc = svcOf(drizzle, events, makePayment());
969
- await svc.creditDepositByAddress(EVENT);
970
- expect(valuesSpy).toHaveBeenCalledWith(expect.objectContaining({
971
- type: 'deposit',
972
- providerRefId: 'vendor-ext-1',
973
- destinationAddress: 'bc1qxyz',
974
- txHash: '0xabc',
975
- }));
976
- expect(events.emit).toHaveBeenCalledWith('wallet.deposit.completed', {
977
- userId: 'u-1',
675
+ describe('WalletService.creditDepositByAddress (real PG)', () => {
676
+ async function seedAddress(userId, address, currency = 'BTC') {
677
+ await db.drizzle.db
678
+ .insert(walletDepositAddress)
679
+ .values({ userId, currency, address, providerName: 'fireblocks' });
680
+ }
681
+ it('resolves the address, credits the wallet, and emits deposit.completed', async () => {
682
+ const { svc, events } = makeService();
683
+ const w = await seedWallet({ currency: 'BTC', balance: '1' });
684
+ await seedAddress(w.userId, 'bc1qcredit');
685
+ await svc.creditDepositByAddress({
686
+ kind: 'deposit',
687
+ address: 'bc1qcredit',
978
688
  amount: '0.5',
979
689
  currency: 'BTC',
980
- transactionId: 'tx-1',
690
+ externalId: randomUUID(),
691
+ txHash: '0xdead',
981
692
  });
693
+ expect(await balanceOf(w.userId)).toBe(1.5);
694
+ expect(emittedTopics(events)).toEqual(['wallet.deposit.completed']);
982
695
  });
983
696
  it('logs and no-ops when the address is unknown', async () => {
984
- const events = makeEvents();
985
- const drizzle = makeDrizzle({ select: [[]] });
986
- const svc = svcOf(drizzle, events, makePayment());
987
- await svc.creditDepositByAddress(EVENT);
697
+ const { svc, events } = makeService();
698
+ await svc.creditDepositByAddress({
699
+ kind: 'deposit',
700
+ address: 'bc1qunknown',
701
+ amount: '1',
702
+ currency: 'BTC',
703
+ externalId: randomUUID(),
704
+ txHash: '0xunknown',
705
+ });
988
706
  expect(events.emit).not.toHaveBeenCalled();
989
707
  });
990
- it('is idempotent on a replayed externalId: re-reads the winner instead of double-crediting', async () => {
991
- const events = makeEvents();
992
- const drizzle = makeDrizzle({
993
- select: [
994
- [DEPOSIT_ADDRESS_ROW],
995
- [WALLET_ROW],
996
- [{ id: 'tx-1', walletId: 'w-1', providerRefId: 'vendor-ext-1' }],
997
- ],
998
- returning: [[]],
999
- });
1000
- const svc = svcOf(drizzle, events, makePayment());
1001
- await svc.creditDepositByAddress(EVENT);
1002
- expect(events.emit).not.toHaveBeenCalled();
708
+ it('is idempotent on a replayed externalId', async () => {
709
+ const { svc, events } = makeService();
710
+ const w = await seedWallet({ currency: 'BTC', balance: '0' });
711
+ await seedAddress(w.userId, 'bc1qreplay');
712
+ const externalId = randomUUID();
713
+ const event = {
714
+ kind: 'deposit',
715
+ address: 'bc1qreplay',
716
+ amount: '2',
717
+ currency: 'BTC',
718
+ externalId,
719
+ txHash: '0xreplay',
720
+ };
721
+ await svc.creditDepositByAddress(event);
722
+ await svc.creditDepositByAddress(event);
723
+ expect(await balanceOf(w.userId)).toBe(2);
724
+ expect(await txRows(w.id)).toHaveLength(1);
725
+ expect(emittedTopics(events)).toEqual(['wallet.deposit.completed']);
726
+ });
727
+ it('throws CurrencyMismatchError when the event currency differs from the address', async () => {
728
+ const { svc } = makeService();
729
+ const w = await seedWallet({ currency: 'BTC' });
730
+ await seedAddress(w.userId, 'bc1qmismatch');
731
+ await expect(svc.creditDepositByAddress({
732
+ kind: 'deposit',
733
+ address: 'bc1qmismatch',
734
+ amount: '1',
735
+ currency: 'ETH',
736
+ externalId: randomUUID(),
737
+ txHash: '0xmismatch',
738
+ })).rejects.toBeInstanceOf(CurrencyMismatchError);
1003
739
  });
1004
740
  });
1005
741
  //# sourceMappingURL=wallet.service.test.js.map