@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/admin-console/__tests__/backoffice.router.test.js +67 -51
- package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
- package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
- package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
- package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
- package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
- package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
- package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
- package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/financial-analytics.service.js +14 -17
- package/dist/analytics/service/financial-analytics.service.js.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
- package/dist/analytics/service/funnel-analytics.service.js +8 -11
- package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
- package/dist/audit/__tests__/audit.service.test.js +128 -251
- package/dist/audit/__tests__/audit.service.test.js.map +1 -1
- package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
- package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
- package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
- package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
- package/dist/cms/__tests__/cms.reads.test.js +59 -80
- package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
- package/dist/cms/__tests__/cms.router.test.js +62 -20
- package/dist/cms/__tests__/cms.router.test.js.map +1 -1
- package/dist/cms/__tests__/cms.service.test.js +116 -14
- package/dist/cms/__tests__/cms.service.test.js.map +1 -1
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
- package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
- package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
- package/dist/compliance/__tests__/assert.test.d.ts +2 -0
- package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/assert.test.js +34 -0
- package/dist/compliance/__tests__/assert.test.js.map +1 -0
- package/dist/compliance/__tests__/compliance.service.test.js +149 -84
- package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
- package/dist/compliance/__tests__/kyc.service.test.js +247 -155
- package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
- package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
- package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
- package/dist/compliance/__tests__/limits.contract.test.js +43 -0
- package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
- package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
- package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.contract.test.js +1 -20
- package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.router.test.js +113 -65
- package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
- package/dist/compliance/__tests__/rg.service.test.js +277 -235
- package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.js +11 -8
- package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
- package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
- package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
- package/dist/contracts/__tests__/compose-contract.test.js +27 -0
- package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
- package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
- package/dist/contracts/adapters/__tests__/token.test.js +14 -0
- package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/events.test.js +51 -0
- package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
- package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
- package/dist/contracts/schemas/reporting.d.ts +7 -2
- package/dist/contracts/schemas/reporting.d.ts.map +1 -1
- package/dist/contracts/schemas/reporting.js +2 -1
- package/dist/contracts/schemas/reporting.js.map +1 -1
- package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
- package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
- package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
- package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
- package/dist/iam/__tests__/iam.service.test.js +367 -596
- package/dist/iam/__tests__/iam.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
- package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
- package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
- package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
- package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
- package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
- package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
- package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
- package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
- package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
- package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
- package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
- package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
- package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
- package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
- package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
- package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
- package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
- package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
- package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
- package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
- package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
- package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
- package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
- package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
- package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
- package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
- package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
- package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
- package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
- package/dist/server/db/__tests__/query-helpers.test.js +72 -1
- package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
- package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
- package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
- package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
- package/dist/server/kernel/__tests__/cache.test.js +26 -13
- package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
- package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
- package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
- package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
- package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
- package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
- package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
- package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
- package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
- package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
- package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
- package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
- package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
- package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/request-context.test.js +64 -0
- package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
- package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
- package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
- package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
- package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
- package/dist/server/kernel/bullmq-job-queue.js +8 -2
- package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
- package/dist/server/runtime/__tests__/create-app.test.js +26 -18
- package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
- package/dist/testing/index.d.ts +2 -6
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +2 -10
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/mock.d.ts +28 -16
- package/dist/testing/mock.d.ts.map +1 -1
- package/dist/testing/mock.js +58 -37
- package/dist/testing/mock.js.map +1 -1
- package/dist/testing/real-infra.d.ts +47 -0
- package/dist/testing/real-infra.d.ts.map +1 -0
- package/dist/testing/real-infra.js +122 -0
- package/dist/testing/real-infra.js.map +1 -0
- package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
- package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
- package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
- package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
- package/dist/wallet/__tests__/rail-for.test.js +22 -0
- package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
- package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
- package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
- package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
- package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
- package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
- package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.router.test.js +56 -36
- package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.test.js +623 -887
- package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
- package/package.json +1 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
- package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
- package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
- package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
- package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
- package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/cache.test.js +0 -89
- package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
- package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
- package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
- package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
- package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
- package/dist/testing/fakes/cache.d.ts +0 -14
- package/dist/testing/fakes/cache.d.ts.map +0 -1
- package/dist/testing/fakes/cache.js +0 -56
- package/dist/testing/fakes/cache.js.map +0 -1
- package/dist/testing/fakes/event-bus.d.ts +0 -17
- package/dist/testing/fakes/event-bus.d.ts.map +0 -1
- package/dist/testing/fakes/event-bus.js +0 -30
- package/dist/testing/fakes/event-bus.js.map +0 -1
- package/dist/testing/fakes/job-queue.d.ts +0 -40
- package/dist/testing/fakes/job-queue.d.ts.map +0 -1
- package/dist/testing/fakes/job-queue.js +0 -200
- package/dist/testing/fakes/job-queue.js.map +0 -1
- package/dist/testing/fakes/rate-limiter.d.ts +0 -11
- package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
- package/dist/testing/fakes/rate-limiter.js +0 -39
- package/dist/testing/fakes/rate-limiter.js.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
- package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-authorizer.js +0 -10
- package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
- package/dist/testing/fakes/realtime-transport.d.ts +0 -2
- package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
- package/dist/testing/fakes/realtime-transport.js +0 -2
- package/dist/testing/fakes/realtime-transport.js.map +0 -1
- /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
- /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 {
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
expect(
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
131
|
+
userId: w.userId,
|
|
73
132
|
amount: '40',
|
|
74
133
|
currency: 'USD',
|
|
75
134
|
...NO_CLIENT_META,
|
|
76
135
|
});
|
|
77
|
-
expect(result).
|
|
78
|
-
expect(
|
|
79
|
-
expect(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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('
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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: '
|
|
152
|
+
destinationAddress: 'bc1qexample',
|
|
104
153
|
...NO_CLIENT_META,
|
|
105
154
|
});
|
|
106
|
-
expect(
|
|
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
|
|
110
|
-
|
|
111
|
-
});
|
|
112
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
|
|
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('
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
204
|
+
idempotencyKey,
|
|
158
205
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
251
|
+
idempotencyKey: randomUUID(),
|
|
196
252
|
});
|
|
197
|
-
const
|
|
198
|
-
userId:
|
|
199
|
-
amount: '
|
|
253
|
+
const b = await svc.deposit({
|
|
254
|
+
userId: w.userId,
|
|
255
|
+
amount: '10',
|
|
200
256
|
currency: 'USD',
|
|
201
|
-
idempotencyKey:
|
|
202
|
-
});
|
|
203
|
-
expect(
|
|
204
|
-
expect(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
300
|
+
idempotencyKey,
|
|
286
301
|
...NO_CLIENT_META,
|
|
287
302
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
307
|
+
idempotencyKey,
|
|
305
308
|
...NO_CLIENT_META,
|
|
306
|
-
})
|
|
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('
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
323
|
+
idempotencyKey,
|
|
329
324
|
...NO_CLIENT_META,
|
|
330
325
|
});
|
|
331
|
-
|
|
332
|
-
userId:
|
|
333
|
-
amount: '
|
|
326
|
+
await expect(svc.withdraw({
|
|
327
|
+
userId: w.userId,
|
|
328
|
+
amount: '41',
|
|
334
329
|
currency: 'USD',
|
|
335
|
-
idempotencyKey
|
|
330
|
+
idempotencyKey,
|
|
336
331
|
...NO_CLIENT_META,
|
|
337
|
-
});
|
|
338
|
-
expect(
|
|
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('
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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:
|
|
349
|
+
idempotencyKey: randomUUID(),
|
|
379
350
|
...NO_CLIENT_META,
|
|
380
351
|
});
|
|
381
|
-
expect(
|
|
382
|
-
expect(
|
|
352
|
+
expect(await txRows(w.id)).toHaveLength(2);
|
|
353
|
+
expect(await balanceOf(w.userId)).toBe(20);
|
|
383
354
|
});
|
|
384
|
-
it('
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
363
|
+
idempotencyKey,
|
|
445
364
|
});
|
|
446
|
-
const
|
|
447
|
-
userId:
|
|
365
|
+
const withdrawn = await svc.withdraw({
|
|
366
|
+
userId: w.userId,
|
|
448
367
|
amount: '40',
|
|
449
368
|
currency: 'USD',
|
|
450
|
-
idempotencyKey
|
|
369
|
+
idempotencyKey,
|
|
451
370
|
...NO_CLIENT_META,
|
|
452
371
|
});
|
|
453
|
-
expect(
|
|
454
|
-
|
|
455
|
-
expect(
|
|
456
|
-
expect(
|
|
457
|
-
expect(
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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(
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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('
|
|
595
|
-
const
|
|
596
|
-
const
|
|
597
|
-
await
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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('
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
];
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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:
|
|
728
|
-
{ userId:
|
|
518
|
+
{ userId: verified.userId, username: 'a', kycStatus: 'verified' },
|
|
519
|
+
{ userId: pendingKyc.userId, username: 'b', kycStatus: 'pending' },
|
|
729
520
|
]);
|
|
730
|
-
const svc =
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
expect(
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
});
|
|
751
|
-
const small =
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
579
|
+
externalId,
|
|
836
580
|
status: 'completed',
|
|
581
|
+
txHash: '0xabc',
|
|
837
582
|
});
|
|
838
|
-
expect(
|
|
839
|
-
expect(events
|
|
840
|
-
|
|
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
|
-
|
|
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('
|
|
847
|
-
const events =
|
|
848
|
-
const
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
});
|
|
852
|
-
|
|
853
|
-
|
|
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
|
|
877
|
-
const events =
|
|
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:
|
|
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
|
|
890
|
-
const
|
|
891
|
-
const
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
};
|
|
916
|
-
const
|
|
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
|
-
|
|
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
|
|
937
|
-
const
|
|
938
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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 =
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
|
991
|
-
const events =
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|