@openora/core 0.4.1-canary.278 → 0.4.1-canary.280
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/admin-console/contract/index.d.ts +6 -0
- package/dist/admin-console/contract/index.d.ts.map +1 -1
- package/dist/admin-console/plugin.d.ts +1 -0
- package/dist/admin-console/plugin.d.ts.map +1 -1
- package/dist/admin-console/router/index.d.ts +3 -0
- package/dist/admin-console/router/index.d.ts.map +1 -1
- package/dist/analytics/plugin.d.ts +1 -0
- package/dist/analytics/plugin.d.ts.map +1 -1
- package/dist/audit/plugin.d.ts +1 -0
- package/dist/audit/plugin.d.ts.map +1 -1
- package/dist/casino/gaming/plugin.d.ts +1 -0
- package/dist/casino/gaming/plugin.d.ts.map +1 -1
- package/dist/casino/lobby/plugin.d.ts +1 -0
- package/dist/casino/lobby/plugin.d.ts.map +1 -1
- package/dist/cms/plugin.d.ts +1 -0
- package/dist/cms/plugin.d.ts.map +1 -1
- package/dist/compliance/__tests__/rg-monitoring.service.int.test.js +11 -0
- package/dist/compliance/__tests__/rg-monitoring.service.int.test.js.map +1 -1
- package/dist/compliance/plugin.d.ts +1 -0
- package/dist/compliance/plugin.d.ts.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
- package/dist/compliance/service/rg-monitoring.service.js +6 -2
- package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
- package/dist/contracts/adapters/deposit-hold.d.ts +25 -0
- package/dist/contracts/adapters/deposit-hold.d.ts.map +1 -0
- package/dist/contracts/adapters/deposit-hold.js +3 -0
- package/dist/contracts/adapters/deposit-hold.js.map +1 -0
- package/dist/contracts/adapters/index.d.ts +2 -0
- package/dist/contracts/adapters/index.d.ts.map +1 -1
- package/dist/contracts/adapters/index.js +1 -0
- package/dist/contracts/adapters/index.js.map +1 -1
- package/dist/contracts/adapters/payment.d.ts +11 -0
- package/dist/contracts/adapters/payment.d.ts.map +1 -1
- package/dist/contracts/adapters/payment.js.map +1 -1
- package/dist/contracts/adapters/play-eligibility.d.ts +4 -1
- package/dist/contracts/adapters/play-eligibility.d.ts.map +1 -1
- package/dist/contracts/adapters/play-eligibility.js.map +1 -1
- package/dist/contracts/schemas/events.d.ts +7 -0
- package/dist/contracts/schemas/events.d.ts.map +1 -1
- package/dist/contracts/schemas/events.js +3 -0
- package/dist/contracts/schemas/events.js.map +1 -1
- package/dist/contracts/schemas/mail.d.ts +15 -1
- package/dist/contracts/schemas/mail.d.ts.map +1 -1
- package/dist/contracts/schemas/mail.js +3 -0
- package/dist/contracts/schemas/mail.js.map +1 -1
- package/dist/contracts/schemas/wallet-tx.d.ts +4 -2
- package/dist/contracts/schemas/wallet-tx.d.ts.map +1 -1
- package/dist/contracts/schemas/wallet-tx.js +3 -0
- package/dist/contracts/schemas/wallet-tx.js.map +1 -1
- package/dist/engagement/chat/__tests__/context-rooms.int.test.js +25 -0
- package/dist/engagement/chat/__tests__/context-rooms.int.test.js.map +1 -1
- package/dist/engagement/chat/drizzle/migrations/0021_chat_message_created_at_clock.sql +1 -0
- package/dist/engagement/chat/drizzle/migrations/meta/0021_snapshot.json +2100 -0
- package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
- package/dist/engagement/chat/plugin.d.ts +1 -0
- package/dist/engagement/chat/plugin.d.ts.map +1 -1
- package/dist/engagement/chat/schema/index.d.ts.map +1 -1
- package/dist/engagement/chat/schema/index.js +3 -1
- package/dist/engagement/chat/schema/index.js.map +1 -1
- package/dist/engagement/chat-commands/plugin.d.ts +1 -0
- package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
- package/dist/engagement/notifications/__tests__/notification-event-map.test.js +20 -0
- package/dist/engagement/notifications/__tests__/notification-event-map.test.js.map +1 -1
- package/dist/engagement/notifications/contract/index.d.ts +7 -3
- package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
- package/dist/engagement/notifications/contract/index.js +1 -0
- package/dist/engagement/notifications/contract/index.js.map +1 -1
- package/dist/engagement/notifications/plugin.d.ts +1 -0
- package/dist/engagement/notifications/plugin.d.ts.map +1 -1
- package/dist/engagement/notifications/plugin.js +17 -0
- package/dist/engagement/notifications/plugin.js.map +1 -1
- package/dist/engagement/notifications/router/index.d.ts +3 -2
- package/dist/engagement/notifications/router/index.d.ts.map +1 -1
- package/dist/engagement/social/plugin.d.ts +1 -0
- package/dist/engagement/social/plugin.d.ts.map +1 -1
- package/dist/fx/exchange-rate/plugin.d.ts +1 -0
- package/dist/fx/exchange-rate/plugin.d.ts.map +1 -1
- package/dist/iam/plugin.d.ts +1 -0
- package/dist/iam/plugin.d.ts.map +1 -1
- package/dist/mail/adapters/default-email-template-renderer.d.ts.map +1 -1
- package/dist/mail/adapters/default-email-template-renderer.js +5 -0
- package/dist/mail/adapters/default-email-template-renderer.js.map +1 -1
- package/dist/mail/contract/index.d.ts +8 -0
- package/dist/mail/contract/index.d.ts.map +1 -1
- package/dist/mail/plugin.d.ts +1 -0
- package/dist/mail/plugin.d.ts.map +1 -1
- package/dist/pam/identity/__tests__/play-eligibility.service.int.test.js +17 -0
- package/dist/pam/identity/__tests__/play-eligibility.service.int.test.js.map +1 -1
- package/dist/pam/identity/plugin.d.ts +1 -0
- package/dist/pam/identity/plugin.d.ts.map +1 -1
- package/dist/pam/identity/service/play-eligibility.service.d.ts.map +1 -1
- package/dist/pam/identity/service/play-eligibility.service.js +2 -1
- package/dist/pam/identity/service/play-eligibility.service.js.map +1 -1
- package/dist/pam/player-management/plugin.d.ts +1 -0
- package/dist/pam/player-management/plugin.d.ts.map +1 -1
- package/dist/pam/player-note/plugin.d.ts +1 -0
- package/dist/pam/player-note/plugin.d.ts.map +1 -1
- package/dist/pam/profile/plugin.d.ts +1 -0
- package/dist/pam/profile/plugin.d.ts.map +1 -1
- package/dist/pam/tag/plugin.d.ts +1 -0
- package/dist/pam/tag/plugin.d.ts.map +1 -1
- package/dist/promo/bonus/plugin.d.ts +1 -0
- package/dist/promo/bonus/plugin.d.ts.map +1 -1
- package/dist/promo/gamification/plugin.d.ts +1 -0
- package/dist/promo/gamification/plugin.d.ts.map +1 -1
- package/dist/server/runtime/core-token-catalog.d.ts +1 -0
- package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
- package/dist/server/runtime/core-token-catalog.js +2 -1
- package/dist/server/runtime/core-token-catalog.js.map +1 -1
- package/dist/wallet/__tests__/reconciliation.service.int.test.js +516 -66
- package/dist/wallet/__tests__/reconciliation.service.int.test.js.map +1 -1
- package/dist/wallet/__tests__/settlement-network.test.js +15 -1
- package/dist/wallet/__tests__/settlement-network.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js +17 -2
- package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet-custody-routes.router.test.js +19 -0
- package/dist/wallet/__tests__/wallet-custody-routes.router.test.js.map +1 -1
- package/dist/wallet/__tests__/wallet.service.int.test.js +95 -0
- package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
- package/dist/wallet/admin-reporting.d.ts +2 -2
- package/dist/wallet/contract/agent-tools.d.ts +2 -0
- package/dist/wallet/contract/agent-tools.d.ts.map +1 -1
- package/dist/wallet/contract/index.d.ts +58 -3
- package/dist/wallet/contract/index.d.ts.map +1 -1
- package/dist/wallet/contract/index.js +23 -4
- package/dist/wallet/contract/index.js.map +1 -1
- package/dist/wallet/drizzle/migrations/0030_brainy_black_panther.sql +1 -0
- package/dist/wallet/drizzle/migrations/0031_eminent_star_brand.sql +1 -0
- package/dist/wallet/drizzle/migrations/0032_clumsy_ink.sql +1 -0
- package/dist/wallet/drizzle/migrations/0033_lucky_silver_samurai.sql +10 -0
- package/dist/wallet/drizzle/migrations/meta/0030_snapshot.json +2090 -0
- package/dist/wallet/drizzle/migrations/meta/0031_snapshot.json +2099 -0
- package/dist/wallet/drizzle/migrations/meta/0032_snapshot.json +2105 -0
- package/dist/wallet/drizzle/migrations/meta/0033_snapshot.json +2182 -0
- package/dist/wallet/drizzle/migrations/meta/_journal.json +28 -0
- package/dist/wallet/plugin.d.ts +1 -0
- package/dist/wallet/plugin.d.ts.map +1 -1
- package/dist/wallet/plugin.js +2 -1
- package/dist/wallet/plugin.js.map +1 -1
- package/dist/wallet/router/index.d.ts +27 -1
- package/dist/wallet/router/index.d.ts.map +1 -1
- package/dist/wallet/router/index.js +19 -3
- package/dist/wallet/router/index.js.map +1 -1
- package/dist/wallet/schema/index.d.ts +118 -8
- package/dist/wallet/schema/index.d.ts.map +1 -1
- package/dist/wallet/schema/index.js +17 -0
- package/dist/wallet/schema/index.js.map +1 -1
- package/dist/wallet/server.d.ts +1 -1
- package/dist/wallet/server.d.ts.map +1 -1
- package/dist/wallet/server.js +1 -1
- package/dist/wallet/server.js.map +1 -1
- package/dist/wallet/service/reconciliation-finding.service.d.ts +14 -0
- package/dist/wallet/service/reconciliation-finding.service.d.ts.map +1 -1
- package/dist/wallet/service/reconciliation-finding.service.js +60 -11
- package/dist/wallet/service/reconciliation-finding.service.js.map +1 -1
- package/dist/wallet/service/reconciliation.service.d.ts +56 -17
- package/dist/wallet/service/reconciliation.service.d.ts.map +1 -1
- package/dist/wallet/service/reconciliation.service.js +318 -76
- package/dist/wallet/service/reconciliation.service.js.map +1 -1
- package/dist/wallet/service/wallet.service.d.ts +95 -7
- package/dist/wallet/service/wallet.service.d.ts.map +1 -1
- package/dist/wallet/service/wallet.service.js +230 -9
- package/dist/wallet/service/wallet.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,9 +5,10 @@ import { findOneOrThrow } from '@openora/core/server';
|
|
|
5
5
|
import { createTestDb } from '@openora/core/testing';
|
|
6
6
|
import { mock, makeEventBus, makeIdentityReader, makeExchangeRateReader, makeAuditWriter, makePaymentProviderRegistry, } from '../../testing/mock.js';
|
|
7
7
|
import { migrate } from '../migrate.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { OUT_OF_CYCLE_RUN_ID, lockVendorDeposit, recordReconciliationFinding, } from '../service/reconciliation-finding.service.js';
|
|
9
|
+
import { wallet, walletBalance, walletTransaction, walletCustodySweep, walletJobRun, walletReconciliationFinding, walletDepositAddress, } from '../schema/index.js';
|
|
10
|
+
import { DepositAlreadyOnLedgerError, DepositStillHeldError, WalletService, } from '../service/wallet.service.js';
|
|
11
|
+
import { ReconciliationService, DepositAlreadyRefundedError, ReconciliationAttributionError, ReconciliationCreditMismatchError, ReconciliationFindingNotFoundError, ReconciliationOutcomeNotAllowedError, ReconciliationRefundAmountError, } from '../service/reconciliation.service.js';
|
|
11
12
|
let db;
|
|
12
13
|
beforeAll(async () => {
|
|
13
14
|
db = await createTestDb([migrate]);
|
|
@@ -16,7 +17,7 @@ afterAll(async () => {
|
|
|
16
17
|
await db.drop();
|
|
17
18
|
});
|
|
18
19
|
beforeEach(async () => {
|
|
19
|
-
await db.drizzle.db.execute(sql `TRUNCATE ${walletTransaction}, ${walletCustodySweep}, ${walletJobRun}, ${walletReconciliationFinding}, ${wallet} RESTART IDENTITY CASCADE`);
|
|
20
|
+
await db.drizzle.db.execute(sql `TRUNCATE ${walletTransaction}, ${walletCustodySweep}, ${walletJobRun}, ${walletReconciliationFinding}, ${walletDepositAddress}, ${wallet} RESTART IDENTITY CASCADE`);
|
|
20
21
|
});
|
|
21
22
|
const RECONCILIATION_CONFIG = {
|
|
22
23
|
cron: '0 * * * *',
|
|
@@ -42,6 +43,7 @@ function makeServices(payment, overrides = {}) {
|
|
|
42
43
|
identityReader: overrides.identityReader ?? makeIdentityReader(),
|
|
43
44
|
platformConfig,
|
|
44
45
|
rates: makeExchangeRateReader(),
|
|
46
|
+
depositHold: overrides.depositHold,
|
|
45
47
|
});
|
|
46
48
|
const reconciliation = new ReconciliationService({
|
|
47
49
|
drizzle: db.drizzle,
|
|
@@ -552,9 +554,11 @@ describe('ReconciliationService.runCycle - audit', () => {
|
|
|
552
554
|
});
|
|
553
555
|
});
|
|
554
556
|
describe('ReconciliationService.resolveFinding', () => {
|
|
555
|
-
async function seedFinding(kind = '
|
|
557
|
+
async function seedFinding(kind = 'stuck_sweep', values = {}) {
|
|
556
558
|
const runId = randomUUID();
|
|
557
|
-
await db.drizzle.db
|
|
559
|
+
await db.drizzle.db
|
|
560
|
+
.insert(walletJobRun)
|
|
561
|
+
.values({ jobName: 'wallet-reconciliation', runId, finishedAt: new Date() });
|
|
558
562
|
const [row] = await db.drizzle.db
|
|
559
563
|
.insert(walletReconciliationFinding)
|
|
560
564
|
.values({
|
|
@@ -564,19 +568,74 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
564
568
|
currency: 'BTC',
|
|
565
569
|
amount: '1',
|
|
566
570
|
externalId: randomUUID(),
|
|
571
|
+
...values,
|
|
567
572
|
})
|
|
568
573
|
.returning();
|
|
569
574
|
return findOneOrThrow([row], new Error('seedFinding: insert returned no row'));
|
|
570
575
|
}
|
|
576
|
+
/** A player wallet with a BTC deposit address, and a missing deposit to that address. */
|
|
577
|
+
async function seedMissingDeposit() {
|
|
578
|
+
const w = await seedWallet('BTC', '0');
|
|
579
|
+
await db.drizzle.db.insert(walletDepositAddress).values({
|
|
580
|
+
userId: w.userId,
|
|
581
|
+
currency: 'BTC',
|
|
582
|
+
network: 'bitcoin',
|
|
583
|
+
address: `bc1q${randomUUID().slice(0, 8)}`,
|
|
584
|
+
providerName: 'default',
|
|
585
|
+
});
|
|
586
|
+
const [address] = await db.drizzle.db
|
|
587
|
+
.select()
|
|
588
|
+
.from(walletDepositAddress)
|
|
589
|
+
.where(eq(walletDepositAddress.userId, w.userId));
|
|
590
|
+
const { address: depositAddress } = findOneOrThrow([address], new Error('no address'));
|
|
591
|
+
const externalId = randomUUID();
|
|
592
|
+
const finding = await seedFinding('missing_deposit', {
|
|
593
|
+
externalId,
|
|
594
|
+
network: 'bitcoin',
|
|
595
|
+
address: depositAddress,
|
|
596
|
+
txHash: '0xdeposit',
|
|
597
|
+
sourceAddress: 'bc1qplayer',
|
|
598
|
+
});
|
|
599
|
+
const redelivery = {
|
|
600
|
+
kind: 'deposit',
|
|
601
|
+
address: depositAddress,
|
|
602
|
+
network: 'bitcoin',
|
|
603
|
+
amount: '1',
|
|
604
|
+
currency: 'BTC',
|
|
605
|
+
txHash: '0xdeposit',
|
|
606
|
+
externalId,
|
|
607
|
+
};
|
|
608
|
+
return { w, finding, redelivery };
|
|
609
|
+
}
|
|
610
|
+
const playerReader = () => mock({
|
|
611
|
+
...makeIdentityReader(),
|
|
612
|
+
getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
|
|
613
|
+
});
|
|
614
|
+
async function depositRows(walletId) {
|
|
615
|
+
return db.drizzle.db
|
|
616
|
+
.select()
|
|
617
|
+
.from(walletTransaction)
|
|
618
|
+
.where(and(eq(walletTransaction.walletId, walletId), eq(walletTransaction.type, 'deposit')));
|
|
619
|
+
}
|
|
620
|
+
async function findingById(id) {
|
|
621
|
+
const [row] = await db.drizzle.db
|
|
622
|
+
.select()
|
|
623
|
+
.from(walletReconciliationFinding)
|
|
624
|
+
.where(eq(walletReconciliationFinding.id, id));
|
|
625
|
+
return row;
|
|
626
|
+
}
|
|
627
|
+
const refund = (amount = '1', destinationAddress = 'bc1qplayer', note) => ({
|
|
628
|
+
outcome: 'refunded',
|
|
629
|
+
amount,
|
|
630
|
+
txHash: '0xrefund',
|
|
631
|
+
destinationAddress,
|
|
632
|
+
...(note ? { note } : {}),
|
|
633
|
+
});
|
|
571
634
|
it('rejects a credited resolution whose transaction amount does not match the finding', async () => {
|
|
572
635
|
const finding = await seedFinding();
|
|
573
636
|
const w = await seedWallet('BTC', '0');
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
identityReader: mock({
|
|
577
|
-
...makeIdentityReader(),
|
|
578
|
-
getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
|
|
579
|
-
}),
|
|
637
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
638
|
+
identityReader: playerReader(),
|
|
580
639
|
});
|
|
581
640
|
const creditTx = await walletSvc.manualAdjust({
|
|
582
641
|
adminId: randomUUID(),
|
|
@@ -597,12 +656,8 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
597
656
|
it('accepts a credited resolution whose manual-credit transaction matches exactly', async () => {
|
|
598
657
|
const finding = await seedFinding();
|
|
599
658
|
const w = await seedWallet('BTC', '0');
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
identityReader: mock({
|
|
603
|
-
...makeIdentityReader(),
|
|
604
|
-
getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
|
|
605
|
-
}),
|
|
659
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
660
|
+
identityReader: playerReader(),
|
|
606
661
|
});
|
|
607
662
|
const creditTx = await walletSvc.manualAdjust({
|
|
608
663
|
adminId: randomUUID(),
|
|
@@ -622,9 +677,290 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
622
677
|
expect(resolved.status).toBe('resolved');
|
|
623
678
|
expect(resolved.transactionId).toBe(creditTx.transactionId);
|
|
624
679
|
});
|
|
625
|
-
it('
|
|
680
|
+
it('refuses a non-deposit credited resolution that names no manual credit', async () => {
|
|
626
681
|
const finding = await seedFinding();
|
|
682
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
683
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'credited' })).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
684
|
+
});
|
|
685
|
+
it('refuses to close a deposit finding with a manual credit made before the hold policy ran', async () => {
|
|
686
|
+
const { finding } = await seedMissingDeposit();
|
|
687
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
688
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
689
|
+
outcome: 'credited',
|
|
690
|
+
transactionId: randomUUID(),
|
|
691
|
+
})).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
692
|
+
});
|
|
693
|
+
it('credits a missing deposit as a deposit the limit window counts, keyed on the vendor id', async () => {
|
|
694
|
+
const { w, finding, redelivery } = await seedMissingDeposit();
|
|
695
|
+
const adminId = randomUUID();
|
|
696
|
+
const policy = holdingPolicy(null);
|
|
697
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
698
|
+
identityReader: playerReader(),
|
|
699
|
+
depositHold: policy,
|
|
700
|
+
});
|
|
701
|
+
const resolved = await reconciliation.resolveFinding(adminId, finding.id, {
|
|
702
|
+
outcome: 'credited',
|
|
703
|
+
});
|
|
704
|
+
expect(policy.holdReason).toHaveBeenCalledWith(expect.anything(), {
|
|
705
|
+
userId: w.userId,
|
|
706
|
+
amount: '1.000000000000000000',
|
|
707
|
+
currency: 'BTC',
|
|
708
|
+
});
|
|
709
|
+
const [deposit] = await depositRows(w.id);
|
|
710
|
+
expect(deposit).toMatchObject({
|
|
711
|
+
id: resolved.transactionId,
|
|
712
|
+
status: 'completed',
|
|
713
|
+
providerName: 'default',
|
|
714
|
+
providerRefId: finding.externalId,
|
|
715
|
+
reviewedBy: adminId,
|
|
716
|
+
reviewedAt: expect.any(Date),
|
|
717
|
+
});
|
|
718
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
719
|
+
// The vendor redelivers the same deposit: it replays, it does not credit again.
|
|
720
|
+
await walletSvc.creditDepositByAddress(redelivery);
|
|
721
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
722
|
+
expect(await depositRows(w.id)).toHaveLength(1);
|
|
723
|
+
});
|
|
724
|
+
it('refuses to credit a restricted player before any money moves', async () => {
|
|
725
|
+
const { w, finding } = await seedMissingDeposit();
|
|
726
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
727
|
+
identityReader: playerReader(),
|
|
728
|
+
depositHold: holdingPolicy('rg_restricted'),
|
|
729
|
+
});
|
|
730
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'credited' })).rejects.toBeInstanceOf(DepositStillHeldError);
|
|
731
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
732
|
+
expect(await depositRows(w.id)).toHaveLength(0);
|
|
733
|
+
expect((await findingById(finding.id))?.status).toBe('open');
|
|
734
|
+
});
|
|
735
|
+
it('refuses to credit a deposit to a player other than the one its address was issued to', async () => {
|
|
736
|
+
const { w, finding } = await seedMissingDeposit();
|
|
737
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
738
|
+
identityReader: playerReader(),
|
|
739
|
+
});
|
|
740
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
741
|
+
outcome: 'credited',
|
|
742
|
+
userId: randomUUID(),
|
|
743
|
+
})).rejects.toBeInstanceOf(ReconciliationAttributionError);
|
|
744
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
745
|
+
});
|
|
746
|
+
it('credits an unattributed deposit only to the player the admin names', async () => {
|
|
627
747
|
const w = await seedWallet('BTC', '0');
|
|
748
|
+
const finding = await seedFinding('unattributed_deposit', { address: 'bc1qnobody' });
|
|
749
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
750
|
+
identityReader: playerReader(),
|
|
751
|
+
});
|
|
752
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'credited' })).rejects.toBeInstanceOf(ReconciliationAttributionError);
|
|
753
|
+
await reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
754
|
+
outcome: 'credited',
|
|
755
|
+
userId: w.userId,
|
|
756
|
+
});
|
|
757
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
758
|
+
});
|
|
759
|
+
it('credits only the shortfall of an underpaid amount mismatch, and refuses an overpaid one', async () => {
|
|
760
|
+
const w = await seedWallet('BTC', '0');
|
|
761
|
+
const [ledger] = await db.drizzle.db
|
|
762
|
+
.insert(walletTransaction)
|
|
763
|
+
.values({
|
|
764
|
+
walletId: w.id,
|
|
765
|
+
type: 'deposit',
|
|
766
|
+
amount: '0.4',
|
|
767
|
+
currency: 'BTC',
|
|
768
|
+
status: 'completed',
|
|
769
|
+
direction: 'credit',
|
|
770
|
+
providerName: 'default',
|
|
771
|
+
providerRefId: randomUUID(),
|
|
772
|
+
})
|
|
773
|
+
.returning();
|
|
774
|
+
const ledgerId = findOneOrThrow([ledger], new Error('no ledger row')).id;
|
|
775
|
+
const underpaid = await seedFinding('amount_mismatch', { transactionId: ledgerId });
|
|
776
|
+
const overpaid = await seedFinding('amount_mismatch', {
|
|
777
|
+
transactionId: ledgerId,
|
|
778
|
+
amount: '0.1',
|
|
779
|
+
});
|
|
780
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
781
|
+
identityReader: playerReader(),
|
|
782
|
+
});
|
|
783
|
+
await reconciliation.resolveFinding(randomUUID(), underpaid.id, { outcome: 'credited' });
|
|
784
|
+
await expect(reconciliation.resolveFinding(randomUUID(), overpaid.id, { outcome: 'credited' })).rejects.toBeInstanceOf(ReconciliationCreditMismatchError);
|
|
785
|
+
expect(await balanceOf(w.id)).toBe('0.600000000000000000');
|
|
786
|
+
});
|
|
787
|
+
it('refuses a refund for an amount or currency mismatch the player was already credited for', async () => {
|
|
788
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
789
|
+
for (const kind of ['amount_mismatch', 'currency_mismatch']) {
|
|
790
|
+
const finding = await seedFinding(kind);
|
|
791
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, refund())).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
it('refuses a refund of anything but the whole deposit', async () => {
|
|
795
|
+
const finding = await seedFinding('unattributed_deposit');
|
|
796
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
797
|
+
for (const amount of ['1.5', '0.9']) {
|
|
798
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, refund(amount))).rejects.toBeInstanceOf(ReconciliationRefundAmountError);
|
|
799
|
+
}
|
|
800
|
+
expect((await findingById(finding.id))?.status).toBe('open');
|
|
801
|
+
});
|
|
802
|
+
it('refuses a refund to an address other than the sender unless a note says why', async () => {
|
|
803
|
+
const finding = await seedFinding('unattributed_deposit', { sourceAddress: 'bc1qsender' });
|
|
804
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
805
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, refund('1', 'bc1qelsewhere'))).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
806
|
+
expect((await findingById(finding.id))?.status).toBe('open');
|
|
807
|
+
const resolved = await reconciliation.resolveFinding(randomUUID(), finding.id, refund('1', 'bc1qelsewhere', 'sender is an exchange; player asked for their own wallet'));
|
|
808
|
+
expect(resolved.status).toBe('resolved');
|
|
809
|
+
});
|
|
810
|
+
/** Runs `move` while another transaction holds the vendor deposit lock; reports whether it waited. */
|
|
811
|
+
async function waitsForVendorLock(externalId, move) {
|
|
812
|
+
let release = () => { };
|
|
813
|
+
const held = new Promise((resolve) => {
|
|
814
|
+
release = resolve;
|
|
815
|
+
});
|
|
816
|
+
let lockTaken = () => { };
|
|
817
|
+
const taken = new Promise((resolve) => {
|
|
818
|
+
lockTaken = resolve;
|
|
819
|
+
});
|
|
820
|
+
const blocker = db.drizzle.db.transaction(async (txn) => {
|
|
821
|
+
await lockVendorDeposit(txn, 'default', externalId);
|
|
822
|
+
lockTaken();
|
|
823
|
+
await held;
|
|
824
|
+
});
|
|
825
|
+
await taken;
|
|
826
|
+
let settled = false;
|
|
827
|
+
const moving = move().finally(() => {
|
|
828
|
+
settled = true;
|
|
829
|
+
});
|
|
830
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
831
|
+
const waited = !settled;
|
|
832
|
+
release();
|
|
833
|
+
await blocker;
|
|
834
|
+
await moving;
|
|
835
|
+
return waited;
|
|
836
|
+
}
|
|
837
|
+
it('serializes an admin resolve and a webhook credit of the same vendor deposit', async () => {
|
|
838
|
+
const { w, redelivery } = await seedMissingDeposit();
|
|
839
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
840
|
+
identityReader: playerReader(),
|
|
841
|
+
});
|
|
842
|
+
expect(await waitsForVendorLock(redelivery.externalId, () => walletSvc.creditDepositByAddress(redelivery))).toBe(true);
|
|
843
|
+
const unattributed = await seedFinding('unattributed_deposit', { externalId: randomUUID() });
|
|
844
|
+
expect(await waitsForVendorLock(unattributed.externalId ?? '', () => reconciliation.resolveFinding(randomUUID(), unattributed.id, refund()))).toBe(true);
|
|
845
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
846
|
+
});
|
|
847
|
+
it('records a refunded missing deposit so a vendor redelivery does not credit it', async () => {
|
|
848
|
+
const { w, finding, redelivery } = await seedMissingDeposit();
|
|
849
|
+
const rgLimits = mock({
|
|
850
|
+
referenceCurrency: vi.fn(async () => null),
|
|
851
|
+
checkDeposit: vi.fn(async () => ({ allowed: true })),
|
|
852
|
+
});
|
|
853
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
854
|
+
identityReader: playerReader(),
|
|
855
|
+
rgLimits,
|
|
856
|
+
});
|
|
857
|
+
await reconciliation.resolveFinding(randomUUID(), finding.id, refund());
|
|
858
|
+
const [refunded] = await depositRows(w.id);
|
|
859
|
+
expect(refunded).toMatchObject({
|
|
860
|
+
status: 'refunded',
|
|
861
|
+
providerRefId: finding.externalId,
|
|
862
|
+
amount: '1.000000000000000000',
|
|
863
|
+
});
|
|
864
|
+
await walletSvc.creditDepositByAddress(redelivery);
|
|
865
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
866
|
+
expect(await depositRows(w.id)).toHaveLength(1);
|
|
867
|
+
expect(rgLimits.checkDeposit).not.toHaveBeenCalled();
|
|
868
|
+
});
|
|
869
|
+
it('refunds an unattributed deposit once: a redelivery or re-poll files nothing and credits nothing', async () => {
|
|
870
|
+
const externalId = randomUUID();
|
|
871
|
+
const finding = await seedFinding('unattributed_deposit', {
|
|
872
|
+
externalId,
|
|
873
|
+
address: 'bc1qnobody',
|
|
874
|
+
network: 'bitcoin',
|
|
875
|
+
});
|
|
876
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]));
|
|
877
|
+
await reconciliation.resolveFinding(randomUUID(), finding.id, refund());
|
|
878
|
+
await walletSvc.creditDepositByAddress({
|
|
879
|
+
kind: 'deposit',
|
|
880
|
+
address: 'bc1qnobody',
|
|
881
|
+
network: 'bitcoin',
|
|
882
|
+
amount: '1',
|
|
883
|
+
currency: 'BTC',
|
|
884
|
+
txHash: '0xdeposit',
|
|
885
|
+
externalId,
|
|
886
|
+
});
|
|
887
|
+
await recordReconciliationFinding(db.drizzle.db, {
|
|
888
|
+
runId: OUT_OF_CYCLE_RUN_ID,
|
|
889
|
+
providerName: 'default',
|
|
890
|
+
kind: 'missing_deposit',
|
|
891
|
+
currency: 'BTC',
|
|
892
|
+
amount: '1',
|
|
893
|
+
externalId,
|
|
894
|
+
});
|
|
895
|
+
const findings = await db.drizzle.db
|
|
896
|
+
.select()
|
|
897
|
+
.from(walletReconciliationFinding)
|
|
898
|
+
.where(eq(walletReconciliationFinding.externalId, externalId));
|
|
899
|
+
expect(findings).toEqual([expect.objectContaining({ id: finding.id, status: 'resolved' })]);
|
|
900
|
+
const credits = await db.drizzle.db
|
|
901
|
+
.select()
|
|
902
|
+
.from(walletTransaction)
|
|
903
|
+
.where(eq(walletTransaction.providerRefId, externalId));
|
|
904
|
+
expect(credits).toHaveLength(0);
|
|
905
|
+
});
|
|
906
|
+
it('refuses a second refund, or a credit, of a vendor deposit refunded from another finding', async () => {
|
|
907
|
+
const { w, finding: missing, redelivery } = await seedMissingDeposit();
|
|
908
|
+
const unattributed = await seedFinding('unattributed_deposit', {
|
|
909
|
+
externalId: redelivery.externalId,
|
|
910
|
+
});
|
|
911
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
912
|
+
identityReader: playerReader(),
|
|
913
|
+
});
|
|
914
|
+
await reconciliation.resolveFinding(randomUUID(), unattributed.id, refund());
|
|
915
|
+
await expect(reconciliation.resolveFinding(randomUUID(), missing.id, refund())).rejects.toBeInstanceOf(DepositAlreadyRefundedError);
|
|
916
|
+
await expect(reconciliation.resolveFinding(randomUUID(), missing.id, { outcome: 'credited' })).rejects.toBeInstanceOf(DepositAlreadyRefundedError);
|
|
917
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
918
|
+
expect((await findingById(missing.id))?.status).toBe('open');
|
|
919
|
+
});
|
|
920
|
+
it('refuses to refund an unattributed deposit the ledger already credited', async () => {
|
|
921
|
+
const { w, finding: missing, redelivery } = await seedMissingDeposit();
|
|
922
|
+
const unattributed = await seedFinding('unattributed_deposit', {
|
|
923
|
+
externalId: redelivery.externalId,
|
|
924
|
+
});
|
|
925
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
926
|
+
identityReader: playerReader(),
|
|
927
|
+
});
|
|
928
|
+
await reconciliation.resolveFinding(randomUUID(), missing.id, { outcome: 'credited' });
|
|
929
|
+
await expect(reconciliation.resolveFinding(randomUUID(), unattributed.id, refund())).rejects.toBeInstanceOf(DepositAlreadyOnLedgerError);
|
|
930
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
931
|
+
});
|
|
932
|
+
it('refuses to refund a missing deposit the webhook has credited in the meantime', async () => {
|
|
933
|
+
const { w, finding, redelivery } = await seedMissingDeposit();
|
|
934
|
+
const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
935
|
+
identityReader: playerReader(),
|
|
936
|
+
});
|
|
937
|
+
await walletSvc.creditDepositByAddress(redelivery);
|
|
938
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, refund())).rejects.toBeInstanceOf(DepositAlreadyOnLedgerError);
|
|
939
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
940
|
+
expect((await findingById(finding.id))?.status).toBe('open');
|
|
941
|
+
});
|
|
942
|
+
it('audits the refund amount and whether it went anywhere but the sender', async () => {
|
|
943
|
+
const { reconciliation, audit } = makeServices(listTransactionsReturning([]));
|
|
944
|
+
const cases = [
|
|
945
|
+
{ sourceAddress: 'bc1qsender', destination: 'BC1QSENDER', differs: false },
|
|
946
|
+
{ sourceAddress: 'bc1qsender', destination: 'bc1qelsewhere', differs: true },
|
|
947
|
+
{ sourceAddress: null, destination: 'bc1qelsewhere', differs: null },
|
|
948
|
+
];
|
|
949
|
+
for (const { sourceAddress, destination, differs } of cases) {
|
|
950
|
+
const finding = await seedFinding('unattributed_deposit', { sourceAddress });
|
|
951
|
+
const resolved = await reconciliation.resolveFinding(randomUUID(), finding.id, refund('1', destination, 'returned to the address the player gave'));
|
|
952
|
+
expect(resolved.resolutionNote).toBe(`refunded 1 BTC on chain in 0xrefund to ${destination}: returned to the address the player gave`);
|
|
953
|
+
expect(audit.recordInTransaction).toHaveBeenLastCalledWith(expect.anything(), expect.objectContaining({
|
|
954
|
+
after: expect.objectContaining({
|
|
955
|
+
refundAmount: '1',
|
|
956
|
+
refundCurrency: 'BTC',
|
|
957
|
+
refundDestinationDiffersFromSource: differs,
|
|
958
|
+
}),
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
it('files an rg_limit_breach when a hand-credited deposit lands past the player limit', async () => {
|
|
963
|
+
const { w, finding } = await seedMissingDeposit();
|
|
628
964
|
const rgLimits = mock({
|
|
629
965
|
referenceCurrency: vi.fn(async () => null),
|
|
630
966
|
checkDeposit: vi.fn(async () => ({
|
|
@@ -632,36 +968,19 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
632
968
|
limitType: 'deposit',
|
|
633
969
|
period: 'daily',
|
|
634
970
|
limit: '0.5',
|
|
635
|
-
used: '
|
|
971
|
+
used: '1.4',
|
|
636
972
|
})),
|
|
637
973
|
});
|
|
638
|
-
const {
|
|
639
|
-
identityReader:
|
|
640
|
-
...makeIdentityReader(),
|
|
641
|
-
getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
|
|
642
|
-
}),
|
|
974
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
975
|
+
identityReader: playerReader(),
|
|
643
976
|
rgLimits,
|
|
644
977
|
});
|
|
645
|
-
const
|
|
646
|
-
adminId: randomUUID(),
|
|
647
|
-
userId: w.userId,
|
|
648
|
-
direction: 'credit',
|
|
649
|
-
amount: '1',
|
|
650
|
-
currency: 'BTC',
|
|
651
|
-
reason: 'reconciliation credit',
|
|
652
|
-
idempotencyKey: randomUUID(),
|
|
653
|
-
ip: null,
|
|
654
|
-
userAgent: null,
|
|
655
|
-
});
|
|
656
|
-
await reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
978
|
+
const resolved = await reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
657
979
|
outcome: 'credited',
|
|
658
|
-
transactionId: creditTx.transactionId,
|
|
659
980
|
});
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
// ledger's own amount.
|
|
664
|
-
expect(rgLimits.checkDeposit).toHaveBeenCalledWith(expect.anything(), w.userId, '1.000000000000000000', 'BTC');
|
|
981
|
+
// The credit is a committed deposit row the window already counts, so the gate is asked
|
|
982
|
+
// about nothing more - the same `0` the webhook path asks with.
|
|
983
|
+
expect(rgLimits.checkDeposit).toHaveBeenCalledWith(expect.anything(), w.userId, '0', 'BTC');
|
|
665
984
|
const [breach] = await db.drizzle.db
|
|
666
985
|
.select()
|
|
667
986
|
.from(walletReconciliationFinding)
|
|
@@ -670,38 +989,20 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
670
989
|
kind: 'rg_limit_breach',
|
|
671
990
|
status: 'open',
|
|
672
991
|
amount: '1.000000000000000000',
|
|
673
|
-
transactionId:
|
|
992
|
+
transactionId: resolved.transactionId,
|
|
674
993
|
});
|
|
675
994
|
expect(breach?.detail).toContain('daily deposit limit of 0.5');
|
|
676
995
|
});
|
|
677
996
|
it('files no breach when the hand-credited deposit stays inside the limit', async () => {
|
|
678
|
-
const finding = await
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
identityReader: mock({
|
|
682
|
-
...makeIdentityReader(),
|
|
683
|
-
getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
|
|
684
|
-
}),
|
|
997
|
+
const { finding } = await seedMissingDeposit();
|
|
998
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
999
|
+
identityReader: playerReader(),
|
|
685
1000
|
rgLimits: mock({
|
|
686
1001
|
referenceCurrency: vi.fn(async () => null),
|
|
687
1002
|
checkDeposit: vi.fn(async () => ({ allowed: true })),
|
|
688
1003
|
}),
|
|
689
1004
|
});
|
|
690
|
-
|
|
691
|
-
adminId: randomUUID(),
|
|
692
|
-
userId: w.userId,
|
|
693
|
-
direction: 'credit',
|
|
694
|
-
amount: '1',
|
|
695
|
-
currency: 'BTC',
|
|
696
|
-
reason: 'reconciliation credit',
|
|
697
|
-
idempotencyKey: randomUUID(),
|
|
698
|
-
ip: null,
|
|
699
|
-
userAgent: null,
|
|
700
|
-
});
|
|
701
|
-
await reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
702
|
-
outcome: 'credited',
|
|
703
|
-
transactionId: creditTx.transactionId,
|
|
704
|
-
});
|
|
1005
|
+
await reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'credited' });
|
|
705
1006
|
const breaches = await db.drizzle.db
|
|
706
1007
|
.select()
|
|
707
1008
|
.from(walletReconciliationFinding)
|
|
@@ -726,6 +1027,155 @@ describe('ReconciliationService.resolveFinding', () => {
|
|
|
726
1027
|
expect(audit.recordInTransaction).toHaveBeenCalledTimes(1);
|
|
727
1028
|
expect(second.resolutionNote).toBe('confirmed non-issue');
|
|
728
1029
|
});
|
|
1030
|
+
async function seedHeldDeposit(walletId) {
|
|
1031
|
+
const [deposit] = await db.drizzle.db
|
|
1032
|
+
.insert(walletTransaction)
|
|
1033
|
+
.values({
|
|
1034
|
+
walletId,
|
|
1035
|
+
type: 'deposit',
|
|
1036
|
+
amount: '1',
|
|
1037
|
+
currency: 'BTC',
|
|
1038
|
+
status: 'on_hold',
|
|
1039
|
+
direction: 'credit',
|
|
1040
|
+
reviewReason: 'self_excluded',
|
|
1041
|
+
})
|
|
1042
|
+
.returning();
|
|
1043
|
+
const held = findOneOrThrow([deposit], new Error('seedHeldDeposit: insert returned no row'));
|
|
1044
|
+
const runId = randomUUID();
|
|
1045
|
+
await db.drizzle.db.insert(walletJobRun).values({ jobName: 'wallet-reconciliation', runId });
|
|
1046
|
+
const [row] = await db.drizzle.db
|
|
1047
|
+
.insert(walletReconciliationFinding)
|
|
1048
|
+
.values({
|
|
1049
|
+
runId,
|
|
1050
|
+
providerName: 'default',
|
|
1051
|
+
kind: 'held_deposit',
|
|
1052
|
+
currency: 'BTC',
|
|
1053
|
+
amount: '1',
|
|
1054
|
+
externalId: randomUUID(),
|
|
1055
|
+
transactionId: held.id,
|
|
1056
|
+
})
|
|
1057
|
+
.returning();
|
|
1058
|
+
return { held, finding: findOneOrThrow([row], new Error('seedHeldDeposit: no finding')) };
|
|
1059
|
+
}
|
|
1060
|
+
async function statusOf(transactionId) {
|
|
1061
|
+
const [row] = await db.drizzle.db
|
|
1062
|
+
.select({ status: walletTransaction.status })
|
|
1063
|
+
.from(walletTransaction)
|
|
1064
|
+
.where(eq(walletTransaction.id, transactionId));
|
|
1065
|
+
return row?.status;
|
|
1066
|
+
}
|
|
1067
|
+
const holdingPolicy = (reason) => ({
|
|
1068
|
+
holdReason: vi.fn(async () => reason),
|
|
1069
|
+
});
|
|
1070
|
+
async function reviewOf(transactionId) {
|
|
1071
|
+
const [row] = await db.drizzle.db
|
|
1072
|
+
.select({
|
|
1073
|
+
reviewedBy: walletTransaction.reviewedBy,
|
|
1074
|
+
reviewedAt: walletTransaction.reviewedAt,
|
|
1075
|
+
})
|
|
1076
|
+
.from(walletTransaction)
|
|
1077
|
+
.where(eq(walletTransaction.id, transactionId));
|
|
1078
|
+
return row;
|
|
1079
|
+
}
|
|
1080
|
+
it('closes a held deposit refunded on chain with the refund tx hash and address as evidence', async () => {
|
|
1081
|
+
const w = await seedWallet('BTC', '0');
|
|
1082
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1083
|
+
const adminId = randomUUID();
|
|
1084
|
+
const { reconciliation, audit } = makeServices(listTransactionsReturning([]));
|
|
1085
|
+
const resolved = await reconciliation.resolveFinding(adminId, finding.id, refund());
|
|
1086
|
+
expect(resolved.status).toBe('resolved');
|
|
1087
|
+
expect(resolved.resolutionNote).toBe('refunded 1 BTC on chain in 0xrefund to bc1qplayer');
|
|
1088
|
+
expect(await statusOf(held.id)).toBe('refunded');
|
|
1089
|
+
expect((await reviewOf(held.id))?.reviewedBy).toBe(adminId);
|
|
1090
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
1091
|
+
expect(audit.recordInTransaction).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
1092
|
+
action: 'wallet.reconciliation_finding.resolved',
|
|
1093
|
+
after: expect.objectContaining({
|
|
1094
|
+
outcome: 'refunded',
|
|
1095
|
+
refundedDepositId: held.id,
|
|
1096
|
+
refundAmount: '1',
|
|
1097
|
+
refundTxHash: '0xrefund',
|
|
1098
|
+
refundDestinationAddress: 'bc1qplayer',
|
|
1099
|
+
}),
|
|
1100
|
+
}));
|
|
1101
|
+
});
|
|
1102
|
+
it('refuses to dismiss a held deposit, since the money is still in custody', async () => {
|
|
1103
|
+
const w = await seedWallet('BTC', '0');
|
|
1104
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1105
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
1106
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
1107
|
+
outcome: 'dismissed',
|
|
1108
|
+
note: 'refunded on chain',
|
|
1109
|
+
})).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
1110
|
+
expect(await statusOf(held.id)).toBe('on_hold');
|
|
1111
|
+
});
|
|
1112
|
+
it('refuses to close a held deposit with a manual credit that bypasses the hold policy', async () => {
|
|
1113
|
+
const w = await seedWallet('BTC', '0');
|
|
1114
|
+
const { finding } = await seedHeldDeposit(w.id);
|
|
1115
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
1116
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, {
|
|
1117
|
+
outcome: 'credited',
|
|
1118
|
+
transactionId: randomUUID(),
|
|
1119
|
+
})).rejects.toBeInstanceOf(ReconciliationOutcomeNotAllowedError);
|
|
1120
|
+
});
|
|
1121
|
+
it('releases a held deposit to the balance once the hold policy lets it in', async () => {
|
|
1122
|
+
const w = await seedWallet('BTC', '0');
|
|
1123
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1124
|
+
const adminId = randomUUID();
|
|
1125
|
+
const policy = holdingPolicy(null);
|
|
1126
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), { depositHold: policy });
|
|
1127
|
+
const resolved = await reconciliation.resolveFinding(adminId, finding.id, {
|
|
1128
|
+
outcome: 'released',
|
|
1129
|
+
});
|
|
1130
|
+
expect(resolved.status).toBe('resolved');
|
|
1131
|
+
expect(await statusOf(held.id)).toBe('completed');
|
|
1132
|
+
expect(await reviewOf(held.id)).toMatchObject({
|
|
1133
|
+
reviewedBy: adminId,
|
|
1134
|
+
reviewedAt: expect.any(Date),
|
|
1135
|
+
});
|
|
1136
|
+
expect(await balanceOf(w.id)).toBe('1.000000000000000000');
|
|
1137
|
+
expect(policy.holdReason).toHaveBeenCalledWith(expect.anything(), {
|
|
1138
|
+
userId: w.userId,
|
|
1139
|
+
amount: '1.000000000000000000',
|
|
1140
|
+
currency: 'BTC',
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
it('refuses to release a held deposit while the player is still restricted', async () => {
|
|
1144
|
+
const w = await seedWallet('BTC', '0');
|
|
1145
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1146
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
1147
|
+
depositHold: holdingPolicy('rg_restricted'),
|
|
1148
|
+
});
|
|
1149
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'released' })).rejects.toBeInstanceOf(DepositStillHeldError);
|
|
1150
|
+
expect(await statusOf(held.id)).toBe('on_hold');
|
|
1151
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
1152
|
+
expect((await findingRows())[0]?.status).toBe('open');
|
|
1153
|
+
});
|
|
1154
|
+
it('refuses to release a held deposit when no hold policy is bound to re-check it', async () => {
|
|
1155
|
+
const w = await seedWallet('BTC', '0');
|
|
1156
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1157
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]));
|
|
1158
|
+
await expect(reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'released' })).rejects.toBeInstanceOf(DepositStillHeldError);
|
|
1159
|
+
expect(await statusOf(held.id)).toBe('on_hold');
|
|
1160
|
+
expect(await balanceOf(w.id)).toBe('0.000000000000000000');
|
|
1161
|
+
expect((await findingRows())[0]?.status).toBe('open');
|
|
1162
|
+
});
|
|
1163
|
+
it('stamps a released deposit with the database clock, the one the limit window reads', async () => {
|
|
1164
|
+
const w = await seedWallet('BTC', '0');
|
|
1165
|
+
const { held, finding } = await seedHeldDeposit(w.id);
|
|
1166
|
+
const { reconciliation } = makeServices(listTransactionsReturning([]), {
|
|
1167
|
+
depositHold: holdingPolicy(null),
|
|
1168
|
+
});
|
|
1169
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
1170
|
+
vi.setSystemTime(new Date('2001-01-01T00:00:00Z'));
|
|
1171
|
+
try {
|
|
1172
|
+
await reconciliation.resolveFinding(randomUUID(), finding.id, { outcome: 'released' });
|
|
1173
|
+
}
|
|
1174
|
+
finally {
|
|
1175
|
+
vi.useRealTimers();
|
|
1176
|
+
}
|
|
1177
|
+
expect((await reviewOf(held.id))?.reviewedAt?.getUTCFullYear()).toBeGreaterThan(2001);
|
|
1178
|
+
});
|
|
729
1179
|
it('404s resolving a finding that does not exist', async () => {
|
|
730
1180
|
const payment = listTransactionsReturning([]);
|
|
731
1181
|
const { reconciliation } = makeServices(payment);
|