test: harden business boundary conditions
This commit is contained in:
@@ -50,3 +50,70 @@ describe('WalletsService payment rules', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,12 +61,17 @@ export class WalletsService {
|
||||
}
|
||||
|
||||
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
|
||||
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const amount = money(dto.amount);
|
||||
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
|
||||
throw new BadRequestException('调账金额最多保留两位小数');
|
||||
}
|
||||
if (amount === 0) throw new BadRequestException('调账金额不能为 0');
|
||||
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const wallet = await this.getOrCreateWallet(manager, dto.studentId);
|
||||
const nextBalance = money(Number(wallet.balance) + dto.amount);
|
||||
const nextBalance = money(Number(wallet.balance) + amount);
|
||||
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
|
||||
wallet.balance = nextBalance;
|
||||
await manager.save(wallet);
|
||||
@@ -75,29 +80,43 @@ export class WalletsService {
|
||||
studentId: dto.studentId,
|
||||
billId: null,
|
||||
type: dto.type,
|
||||
amount: money(dto.amount),
|
||||
amount,
|
||||
balanceAfter: nextBalance,
|
||||
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
|
||||
return { wallet: finalWallet, payments };
|
||||
});
|
||||
}
|
||||
|
||||
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
|
||||
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill;
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount)));
|
||||
if (amount <= 0) {
|
||||
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid';
|
||||
if (bill.status === 'cancelled') return bill;
|
||||
|
||||
const total = money(bill.totalAmount);
|
||||
const paid = Math.max(0, Math.min(money(bill.paidAmount), total));
|
||||
const remaining = money(Math.max(0, total - paid));
|
||||
if (remaining <= 0) {
|
||||
bill.paidAmount = total;
|
||||
bill.outstandingAmount = 0;
|
||||
bill.status = 'paid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));
|
||||
if (amount <= 0) {
|
||||
bill.paidAmount = paid;
|
||||
bill.outstandingAmount = remaining;
|
||||
bill.status = paid > 0 ? 'partially_paid' : 'unpaid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
wallet.balance = money(Number(wallet.balance) - amount);
|
||||
bill.paidAmount = money(Number(bill.paidAmount) + amount);
|
||||
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount));
|
||||
bill.paidAmount = money(paid + amount);
|
||||
bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount)));
|
||||
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
|
||||
await manager.save(wallet);
|
||||
await manager.save(bill);
|
||||
@@ -109,14 +128,16 @@ export class WalletsService {
|
||||
amount: -amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `账单 #${bill.id} 自动扣款`,
|
||||
recordedBy: recordedBy || null,
|
||||
recordedBy: recordedBy ?? null,
|
||||
}),
|
||||
);
|
||||
return bill;
|
||||
}
|
||||
|
||||
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
|
||||
const paid = money(bill.paidAmount);
|
||||
if (bill.status === 'cancelled') return bill;
|
||||
|
||||
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
|
||||
if (paid > 0) {
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
wallet.balance = money(Number(wallet.balance) + paid);
|
||||
@@ -129,7 +150,7 @@ export class WalletsService {
|
||||
amount: paid,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `取消账单 #${bill.id} 冲正:${reason}`,
|
||||
recordedBy: recordedBy || null,
|
||||
recordedBy: recordedBy ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user