@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.
Files changed (165) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/contract/index.d.ts +6 -0
  3. package/dist/admin-console/contract/index.d.ts.map +1 -1
  4. package/dist/admin-console/plugin.d.ts +1 -0
  5. package/dist/admin-console/plugin.d.ts.map +1 -1
  6. package/dist/admin-console/router/index.d.ts +3 -0
  7. package/dist/admin-console/router/index.d.ts.map +1 -1
  8. package/dist/analytics/plugin.d.ts +1 -0
  9. package/dist/analytics/plugin.d.ts.map +1 -1
  10. package/dist/audit/plugin.d.ts +1 -0
  11. package/dist/audit/plugin.d.ts.map +1 -1
  12. package/dist/casino/gaming/plugin.d.ts +1 -0
  13. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  14. package/dist/casino/lobby/plugin.d.ts +1 -0
  15. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  16. package/dist/cms/plugin.d.ts +1 -0
  17. package/dist/cms/plugin.d.ts.map +1 -1
  18. package/dist/compliance/__tests__/rg-monitoring.service.int.test.js +11 -0
  19. package/dist/compliance/__tests__/rg-monitoring.service.int.test.js.map +1 -1
  20. package/dist/compliance/plugin.d.ts +1 -0
  21. package/dist/compliance/plugin.d.ts.map +1 -1
  22. package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
  23. package/dist/compliance/service/rg-monitoring.service.js +6 -2
  24. package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
  25. package/dist/contracts/adapters/deposit-hold.d.ts +25 -0
  26. package/dist/contracts/adapters/deposit-hold.d.ts.map +1 -0
  27. package/dist/contracts/adapters/deposit-hold.js +3 -0
  28. package/dist/contracts/adapters/deposit-hold.js.map +1 -0
  29. package/dist/contracts/adapters/index.d.ts +2 -0
  30. package/dist/contracts/adapters/index.d.ts.map +1 -1
  31. package/dist/contracts/adapters/index.js +1 -0
  32. package/dist/contracts/adapters/index.js.map +1 -1
  33. package/dist/contracts/adapters/payment.d.ts +11 -0
  34. package/dist/contracts/adapters/payment.d.ts.map +1 -1
  35. package/dist/contracts/adapters/payment.js.map +1 -1
  36. package/dist/contracts/adapters/play-eligibility.d.ts +4 -1
  37. package/dist/contracts/adapters/play-eligibility.d.ts.map +1 -1
  38. package/dist/contracts/adapters/play-eligibility.js.map +1 -1
  39. package/dist/contracts/schemas/events.d.ts +7 -0
  40. package/dist/contracts/schemas/events.d.ts.map +1 -1
  41. package/dist/contracts/schemas/events.js +3 -0
  42. package/dist/contracts/schemas/events.js.map +1 -1
  43. package/dist/contracts/schemas/mail.d.ts +15 -1
  44. package/dist/contracts/schemas/mail.d.ts.map +1 -1
  45. package/dist/contracts/schemas/mail.js +3 -0
  46. package/dist/contracts/schemas/mail.js.map +1 -1
  47. package/dist/contracts/schemas/wallet-tx.d.ts +4 -2
  48. package/dist/contracts/schemas/wallet-tx.d.ts.map +1 -1
  49. package/dist/contracts/schemas/wallet-tx.js +3 -0
  50. package/dist/contracts/schemas/wallet-tx.js.map +1 -1
  51. package/dist/engagement/chat/__tests__/context-rooms.int.test.js +25 -0
  52. package/dist/engagement/chat/__tests__/context-rooms.int.test.js.map +1 -1
  53. package/dist/engagement/chat/drizzle/migrations/0021_chat_message_created_at_clock.sql +1 -0
  54. package/dist/engagement/chat/drizzle/migrations/meta/0021_snapshot.json +2100 -0
  55. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +7 -0
  56. package/dist/engagement/chat/plugin.d.ts +1 -0
  57. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  58. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  59. package/dist/engagement/chat/schema/index.js +3 -1
  60. package/dist/engagement/chat/schema/index.js.map +1 -1
  61. package/dist/engagement/chat-commands/plugin.d.ts +1 -0
  62. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  63. package/dist/engagement/notifications/__tests__/notification-event-map.test.js +20 -0
  64. package/dist/engagement/notifications/__tests__/notification-event-map.test.js.map +1 -1
  65. package/dist/engagement/notifications/contract/index.d.ts +7 -3
  66. package/dist/engagement/notifications/contract/index.d.ts.map +1 -1
  67. package/dist/engagement/notifications/contract/index.js +1 -0
  68. package/dist/engagement/notifications/contract/index.js.map +1 -1
  69. package/dist/engagement/notifications/plugin.d.ts +1 -0
  70. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  71. package/dist/engagement/notifications/plugin.js +17 -0
  72. package/dist/engagement/notifications/plugin.js.map +1 -1
  73. package/dist/engagement/notifications/router/index.d.ts +3 -2
  74. package/dist/engagement/notifications/router/index.d.ts.map +1 -1
  75. package/dist/engagement/social/plugin.d.ts +1 -0
  76. package/dist/engagement/social/plugin.d.ts.map +1 -1
  77. package/dist/fx/exchange-rate/plugin.d.ts +1 -0
  78. package/dist/fx/exchange-rate/plugin.d.ts.map +1 -1
  79. package/dist/iam/plugin.d.ts +1 -0
  80. package/dist/iam/plugin.d.ts.map +1 -1
  81. package/dist/mail/adapters/default-email-template-renderer.d.ts.map +1 -1
  82. package/dist/mail/adapters/default-email-template-renderer.js +5 -0
  83. package/dist/mail/adapters/default-email-template-renderer.js.map +1 -1
  84. package/dist/mail/contract/index.d.ts +8 -0
  85. package/dist/mail/contract/index.d.ts.map +1 -1
  86. package/dist/mail/plugin.d.ts +1 -0
  87. package/dist/mail/plugin.d.ts.map +1 -1
  88. package/dist/pam/identity/__tests__/play-eligibility.service.int.test.js +17 -0
  89. package/dist/pam/identity/__tests__/play-eligibility.service.int.test.js.map +1 -1
  90. package/dist/pam/identity/plugin.d.ts +1 -0
  91. package/dist/pam/identity/plugin.d.ts.map +1 -1
  92. package/dist/pam/identity/service/play-eligibility.service.d.ts.map +1 -1
  93. package/dist/pam/identity/service/play-eligibility.service.js +2 -1
  94. package/dist/pam/identity/service/play-eligibility.service.js.map +1 -1
  95. package/dist/pam/player-management/plugin.d.ts +1 -0
  96. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  97. package/dist/pam/player-note/plugin.d.ts +1 -0
  98. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  99. package/dist/pam/profile/plugin.d.ts +1 -0
  100. package/dist/pam/profile/plugin.d.ts.map +1 -1
  101. package/dist/pam/tag/plugin.d.ts +1 -0
  102. package/dist/pam/tag/plugin.d.ts.map +1 -1
  103. package/dist/promo/bonus/plugin.d.ts +1 -0
  104. package/dist/promo/bonus/plugin.d.ts.map +1 -1
  105. package/dist/promo/gamification/plugin.d.ts +1 -0
  106. package/dist/promo/gamification/plugin.d.ts.map +1 -1
  107. package/dist/server/runtime/core-token-catalog.d.ts +1 -0
  108. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  109. package/dist/server/runtime/core-token-catalog.js +2 -1
  110. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  111. package/dist/wallet/__tests__/reconciliation.service.int.test.js +516 -66
  112. package/dist/wallet/__tests__/reconciliation.service.int.test.js.map +1 -1
  113. package/dist/wallet/__tests__/settlement-network.test.js +15 -1
  114. package/dist/wallet/__tests__/settlement-network.test.js.map +1 -1
  115. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js +17 -2
  116. package/dist/wallet/__tests__/wallet-auto-withdrawal.int.test.js.map +1 -1
  117. package/dist/wallet/__tests__/wallet-custody-routes.router.test.js +19 -0
  118. package/dist/wallet/__tests__/wallet-custody-routes.router.test.js.map +1 -1
  119. package/dist/wallet/__tests__/wallet.service.int.test.js +95 -0
  120. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  121. package/dist/wallet/admin-reporting.d.ts +2 -2
  122. package/dist/wallet/contract/agent-tools.d.ts +2 -0
  123. package/dist/wallet/contract/agent-tools.d.ts.map +1 -1
  124. package/dist/wallet/contract/index.d.ts +58 -3
  125. package/dist/wallet/contract/index.d.ts.map +1 -1
  126. package/dist/wallet/contract/index.js +23 -4
  127. package/dist/wallet/contract/index.js.map +1 -1
  128. package/dist/wallet/drizzle/migrations/0030_brainy_black_panther.sql +1 -0
  129. package/dist/wallet/drizzle/migrations/0031_eminent_star_brand.sql +1 -0
  130. package/dist/wallet/drizzle/migrations/0032_clumsy_ink.sql +1 -0
  131. package/dist/wallet/drizzle/migrations/0033_lucky_silver_samurai.sql +10 -0
  132. package/dist/wallet/drizzle/migrations/meta/0030_snapshot.json +2090 -0
  133. package/dist/wallet/drizzle/migrations/meta/0031_snapshot.json +2099 -0
  134. package/dist/wallet/drizzle/migrations/meta/0032_snapshot.json +2105 -0
  135. package/dist/wallet/drizzle/migrations/meta/0033_snapshot.json +2182 -0
  136. package/dist/wallet/drizzle/migrations/meta/_journal.json +28 -0
  137. package/dist/wallet/plugin.d.ts +1 -0
  138. package/dist/wallet/plugin.d.ts.map +1 -1
  139. package/dist/wallet/plugin.js +2 -1
  140. package/dist/wallet/plugin.js.map +1 -1
  141. package/dist/wallet/router/index.d.ts +27 -1
  142. package/dist/wallet/router/index.d.ts.map +1 -1
  143. package/dist/wallet/router/index.js +19 -3
  144. package/dist/wallet/router/index.js.map +1 -1
  145. package/dist/wallet/schema/index.d.ts +118 -8
  146. package/dist/wallet/schema/index.d.ts.map +1 -1
  147. package/dist/wallet/schema/index.js +17 -0
  148. package/dist/wallet/schema/index.js.map +1 -1
  149. package/dist/wallet/server.d.ts +1 -1
  150. package/dist/wallet/server.d.ts.map +1 -1
  151. package/dist/wallet/server.js +1 -1
  152. package/dist/wallet/server.js.map +1 -1
  153. package/dist/wallet/service/reconciliation-finding.service.d.ts +14 -0
  154. package/dist/wallet/service/reconciliation-finding.service.d.ts.map +1 -1
  155. package/dist/wallet/service/reconciliation-finding.service.js +60 -11
  156. package/dist/wallet/service/reconciliation-finding.service.js.map +1 -1
  157. package/dist/wallet/service/reconciliation.service.d.ts +56 -17
  158. package/dist/wallet/service/reconciliation.service.d.ts.map +1 -1
  159. package/dist/wallet/service/reconciliation.service.js +318 -76
  160. package/dist/wallet/service/reconciliation.service.js.map +1 -1
  161. package/dist/wallet/service/wallet.service.d.ts +95 -7
  162. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  163. package/dist/wallet/service/wallet.service.js +230 -9
  164. package/dist/wallet/service/wallet.service.js.map +1 -1
  165. 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 { wallet, walletBalance, walletTransaction, walletCustodySweep, walletJobRun, walletReconciliationFinding, } from '../schema/index.js';
9
- import { WalletService } from '../service/wallet.service.js';
10
- import { ReconciliationService, ReconciliationCreditMismatchError, ReconciliationFindingNotFoundError, } from '../service/reconciliation.service.js';
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 = 'missing_deposit') {
557
+ async function seedFinding(kind = 'stuck_sweep', values = {}) {
556
558
  const runId = randomUUID();
557
- await db.drizzle.db.insert(walletJobRun).values({ jobName: 'wallet-reconciliation', runId });
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 payment = listTransactionsReturning([]);
575
- const { wallet: walletSvc, reconciliation } = makeServices(payment, {
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 payment = listTransactionsReturning([]);
601
- const { wallet: walletSvc, reconciliation } = makeServices(payment, {
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('files an rg_limit_breach when an admin hand-credits a deposit past the player limit', async () => {
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: '0.4',
971
+ used: '1.4',
636
972
  })),
637
973
  });
638
- const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
639
- identityReader: mock({
640
- ...makeIdentityReader(),
641
- getPlayerIdByUserId: vi.fn().mockResolvedValue(randomUUID()),
642
- }),
974
+ const { reconciliation } = makeServices(listTransactionsReturning([]), {
975
+ identityReader: playerReader(),
643
976
  rgLimits,
644
977
  });
645
- const creditTx = await walletSvc.manualAdjust({
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
- // A manual_credit is invisible to the deposit window, so the credit itself is the
661
- // attempted move - the opposite of the webhook path, which asks with '0'.
662
- // The stored decimal, not the admin's input string: the gate is asked with the
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: creditTx.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 seedFinding();
679
- const w = await seedWallet('BTC', '0');
680
- const { wallet: walletSvc, reconciliation } = makeServices(listTransactionsReturning([]), {
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
- const creditTx = await walletSvc.manualAdjust({
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);