import { Decimal } from '@prisma/client/runtime/library'; import { DepositService } from './deposit.service'; import { expectAppError } from '../../testing/prisma-mock'; describe('DepositService', () => { const tx = { $queryRaw: jest.fn(), depositOrder: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn(), delete: jest.fn(), }, paymentMethod: { findUnique: jest.fn(), }, bet: { findMany: jest.fn(), }, user: { findFirst: jest.fn(), findUnique: jest.fn(), }, agentProfile: { findUnique: jest.fn(), }, depositOrderAuditLog: { create: jest.fn(), }, }; const prisma = { ...tx, $transaction: jest.fn(async (fn: (client: typeof tx) => Promise) => fn(tx)), }; const funds = { deposit: jest.fn(), withdraw: jest.fn(), }; const credit = { recalculateUsedCredit: jest.fn(), }; const playerMessages = { createDepositApprovedMessage: jest.fn(), createDepositRejectedMessage: jest.fn(), }; const systemConfig = { getInboxNotifySettings: jest.fn().mockResolvedValue({ inboxEnabled: true, deposit: true, banner: true, announcement: true, }), }; let service: DepositService; beforeEach(() => { jest.clearAllMocks(); service = new DepositService( prisma as never, funds as never, credit as never, playerMessages as never, systemConfig as never, ); tx.$queryRaw.mockResolvedValue([{ id: 1n }]); tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, amount: new Decimal(100), approvedAmount: new Decimal(100), status: 'APPROVED', reviewedAt: new Date(), screenshotUrl: '/uploads/a.png', }); tx.bet.findMany.mockResolvedValue([]); tx.user.findFirst.mockResolvedValue(null); tx.user.findUnique.mockResolvedValue({ locale: 'en-US' }); tx.agentProfile.findUnique.mockResolvedValue(null); credit.recalculateUsedCredit.mockResolvedValue(undefined); playerMessages.createDepositApprovedMessage.mockResolvedValue({}); playerMessages.createDepositRejectedMessage.mockResolvedValue({}); }); it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => { tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, amount: new Decimal(100), approvedAmount: null, status: 'PENDING', reviewedAt: null, screenshotUrl: '/uploads/a.png', }); tx.depositOrderAuditLog.create.mockResolvedValue({ id: 501n }); tx.user.findFirst.mockResolvedValue({ parentId: 3n }); tx.agentProfile.findUnique.mockResolvedValue({ creditLimit: new Decimal(1000), usedCredit: new Decimal(100), }); await service.approveDepositOrder(1n, 2n, 100, 'Bank receipt checked'); expect(tx.depositOrder.update).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'APPROVED', approvedAmount: new Decimal(100), }), }), ); expect(funds.deposit).toHaveBeenCalledWith( expect.objectContaining({ userId: 7n, amount: new Decimal(100), operatorId: 2n, remark: 'Bank receipt checked', referenceId: 'DEP-1', transactionType: 'PLAYER_DEPOSIT', businessKey: 'deposit:DEP-1:approve:501', tx, }), ); expect(credit.recalculateUsedCredit).toHaveBeenCalledTimes(2); expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(1, 3n, tx); expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(2, 3n, tx); }); it('uses a fresh business key when re-approving after revoke', async () => { const reviewedAt = new Date('2026-06-15T14:08:48.000Z'); tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, amount: new Decimal(2), approvedAmount: null, status: 'PENDING', reviewedAt: null, screenshotUrl: '/uploads/a.png', }); tx.depositOrderAuditLog.create.mockResolvedValueOnce({ id: 601n }); await service.approveDepositOrder(1n, 2n, 2, 'Second approval'); expect(funds.deposit).toHaveBeenCalledWith( expect.objectContaining({ amount: new Decimal(2), businessKey: 'deposit:DEP-1:approve:601', }), ); }); it('uses approval cycle key when revoking a funded deposit for re-review', async () => { const reviewedAt = new Date(); tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, amount: new Decimal(21), approvedAmount: new Decimal(21), status: 'APPROVED', reviewedAt, screenshotUrl: '/uploads/a.png', }); tx.depositOrderAuditLog.create.mockResolvedValue({ id: 701n }); await service.reopenDepositOrderForReview(1n, 2n); expect(funds.withdraw).toHaveBeenCalledWith( expect.objectContaining({ amount: new Decimal(21), businessKey: `deposit:DEP-1:reopen-reverse:${reviewedAt.getTime()}`, }), ); }); it('blocks approved deposit revoke when bets exist after approval', async () => { tx.bet.findMany.mockResolvedValue([{ id: 99n }]); await expect( service.reopenDepositOrderForReview(1n, 2n), ).rejects.toMatchObject(expectAppError('DEPOSIT_REVOKE_SETTLED_BETS')); expect(funds.withdraw).not.toHaveBeenCalled(); expect(tx.depositOrder.update).not.toHaveBeenCalled(); }); it('does not delete a funded deposit order', async () => { await expect( service.deleteDepositOrder(1n, 2n), ).rejects.toMatchObject(expectAppError('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN')); expect(tx.depositOrder.delete).not.toHaveBeenCalled(); }); it('reapplies a rejected deposit on the same order for the player', async () => { tx.depositOrder.findUnique .mockResolvedValueOnce({ id: 1n, orderNo: 'DEP-1', playerId: 7n, paymentMethodId: 5n, methodType: 'BANK', amount: new Decimal(11), approvedAmount: null, status: 'REJECTED', reviewedAt: new Date(), rejectReason: 'Screenshot unclear', remark: 'Screenshot unclear', screenshotUrl: '/uploads/deposits/old.png', }) .mockResolvedValueOnce({ id: 1n, orderNo: 'DEP-1', playerId: 7n, amount: new Decimal(11), status: 'PENDING', createdAt: new Date(), }); tx.depositOrder.findFirst.mockResolvedValue(null); tx.paymentMethod.findUnique.mockResolvedValue({ id: 5n, methodType: 'BANK', isActive: true, }); const result = await service.reapplyDepositOrder( 7n, 1n, '/uploads/deposits/new.png', 11, ); expect(tx.depositOrder.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 1n }, data: expect.objectContaining({ status: 'PENDING', screenshotUrl: '/uploads/deposits/new.png', rejectReason: null, remark: null, reviewerId: null, reviewedAt: null, }), }), ); expect(tx.depositOrderAuditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: 'REOPENED', actorId: 7n, actorType: 'PLAYER', statusBefore: 'REJECTED', statusAfter: 'PENDING', }), }), ); expect(result?.orderNo).toBe('DEP-1'); }); it('blocks reapply when player already has another pending deposit', async () => { tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, paymentMethodId: 5n, methodType: 'BANK', amount: new Decimal(11), status: 'REJECTED', screenshotUrl: '/uploads/deposits/old.png', }); tx.depositOrder.findFirst.mockResolvedValue({ id: 99n }); await expect( service.reapplyDepositOrder(7n, 1n, '/uploads/deposits/new.png'), ).rejects.toMatchObject(expectAppError('DEPOSIT_PENDING_ORDER_EXISTS')); expect(tx.depositOrder.update).not.toHaveBeenCalled(); }); it('blocks reapply for non-rejected deposit orders', async () => { tx.depositOrder.findUnique.mockResolvedValue({ id: 1n, orderNo: 'DEP-1', playerId: 7n, paymentMethodId: 5n, methodType: 'BANK', amount: new Decimal(11), status: 'PENDING', screenshotUrl: '/uploads/deposits/old.png', }); await expect( service.reapplyDepositOrder(7n, 1n, '/uploads/deposits/new.png'), ).rejects.toMatchObject(expectAppError('ORDER_NOT_REJECTED')); }); });