import { BadRequestException } from '@nestjs/common'; import { WalletsService } from './wallets.service'; import { Bill } from '../entities/bill.entity'; const manager = (walletBalance: number) => { const wallet = { id: 1, studentId: 10, balance: walletBalance }; const saved: any[] = []; return { wallet, saved, value: { findOne: jest.fn(async () => wallet), findOneByOrFail: jest.fn(async () => wallet), save: jest.fn(async (value: any) => { saved.push(value); return value; }), create: jest.fn((_entity: unknown, value: unknown) => value), createQueryBuilder: jest.fn(), }, }; }; describe('WalletsService payment rules', () => { const service = new WalletsService({} as any, {} as any, {} as any, {} as any); it('partially pays a bill when balance is insufficient', async () => { const ctx = manager(40); const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill; const result = await service.debitBill(ctx.value as any, bill, 1); expect(result.status).toBe('partially_paid'); expect(Number(result.paidAmount)).toBe(40); expect(Number(result.outstandingAmount)).toBe(60); expect(Number(ctx.wallet.balance)).toBe(0); expect(ctx.saved.some((row) => row.type === 'bill_payment' && Number(row.amount) === -40)).toBe(true); }); it('marks a bill paid when balance covers it', async () => { const ctx = manager(120); const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 0, outstandingAmount: 100, status: 'unpaid' } as Bill; const result = await service.debitBill(ctx.value as any, bill); expect(result.status).toBe('paid'); expect(Number(result.outstandingAmount)).toBe(0); expect(Number(ctx.wallet.balance)).toBe(20); }); it('refunds paid amount and cancels the bill', async () => { const ctx = manager(10); const bill = { id: 9, studentId: 10, totalAmount: 100, paidAmount: 40, outstandingAmount: 60, status: 'partially_paid' } as Bill; const result = await service.refundBill(ctx.value as any, bill, '录入错误', 1); expect(result.status).toBe('cancelled'); expect(Number(ctx.wallet.balance)).toBe(50); expect(ctx.saved.some((row) => row.type === 'bill_refund' && Number(row.amount) === 40)).toBe(true); }); }); describe('WalletsService financial boundaries', () => { it('caps a debit at total minus already paid even when outstandingAmount is stale', async () => { const ctx = manager(50); const service = new WalletsService({} as any, {} as any, {} as any, {} as any); const bill = { id: 10, studentId: 10, totalAmount: 100, paidAmount: 90, outstandingAmount: 100, status: 'partially_paid', } as Bill; await service.debitBill(ctx.value as any, bill); expect(bill).toMatchObject({ paidAmount: 100, outstandingAmount: 0, status: 'paid' }); expect(ctx.wallet.balance).toBe(40); expect(ctx.saved.some((row) => row.type === 'bill_payment' && row.amount === -10)).toBe(true); }); it('caps a refund at the bill total when paidAmount is corrupt', async () => { const ctx = manager(10); const service = new WalletsService({} as any, {} as any, {} as any, {} as any); const bill = { id: 11, studentId: 10, totalAmount: 100, paidAmount: 150, outstandingAmount: 0, status: 'paid', } as Bill; await service.refundBill(ctx.value as any, bill, '冲正'); expect(ctx.wallet.balance).toBe(110); expect(ctx.saved.some((row) => row.type === 'bill_refund' && row.amount === 100)).toBe(true); }); it('does not issue a second refund for an already cancelled bill', async () => { const ctx = manager(10); const service = new WalletsService({} as any, {} as any, {} as any, {} as any); const bill = { id: 12, studentId: 10, totalAmount: 100, paidAmount: 100, outstandingAmount: 0, status: 'cancelled', } as Bill; await service.refundBill(ctx.value as any, bill, '重复取消'); expect(ctx.wallet.balance).toBe(10); expect(ctx.saved).toHaveLength(0); }); it('rejects an amount that rounds to zero before opening a transaction', async () => { const dataSource = { transaction: jest.fn() }; const service = new WalletsService({} as any, {} as any, {} as any, dataSource as any); await expect(service.changeBalance({ studentId: 1, amount: 0.004, type: 'adjustment' })).rejects.toBeInstanceOf( BadRequestException, ); expect(dataSource.transaction).not.toHaveBeenCalled(); }); });