- teams.service.spec.ts: pick a checkpoint older than the adjustment's own month so the exclusion test actually fails without the exclusion filter - teams.service.ts: only negate fine/levy/fee amounts when positive, matching TeamMembersService.recomputeBalance and Transaction.setBalance() exactly, instead of negating unconditionally - teams.service.ts: outstanding-history helper now returns a positive value when players owe money, matching the house convention already established by getOverview()'s team.outstanding - overview.spec.ts: assert the balance chart's legend becomes visible - teams.service.spec.ts: add coverage for inactive-player exclusion and a positive-balance (prepaid credit) case Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
208 lines
6.7 KiB
TypeScript
208 lines
6.7 KiB
TypeScript
import { TeamsService } from './teams.service';
|
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
|
|
|
function monthKey(monthsAgo: number): string {
|
|
const now = new Date();
|
|
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
}
|
|
|
|
function isoDate(monthsAgo: number, day: number): string {
|
|
const now = new Date();
|
|
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
|
}
|
|
|
|
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
|
const repository = { findOneOrFail: jest.fn() };
|
|
const access = { assertMember: jest.fn() };
|
|
let service: TeamsService;
|
|
|
|
beforeEach(() => {
|
|
jest.resetAllMocks();
|
|
access.assertMember.mockResolvedValue(undefined);
|
|
service = new TeamsService(
|
|
repository as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{} as any,
|
|
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
|
|
access as any,
|
|
);
|
|
});
|
|
|
|
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
|
repository.findOneOrFail.mockResolvedValue({
|
|
id: 9,
|
|
balance: 100,
|
|
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
|
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
|
players: [
|
|
{
|
|
id: 1,
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
active: true,
|
|
balance: -30,
|
|
transactions: [
|
|
{
|
|
date: isoDate(2, 10),
|
|
amount: 30,
|
|
type: { id: 11, name: 'fine' },
|
|
note: 'Zu spät zum Training',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.getOverviewStats(9, 42);
|
|
|
|
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
|
|
|
expect(beforeFine?.balance).toBe(100);
|
|
expect(beforeFine?.theoreticalBalance).toBe(100);
|
|
expect(now?.balance).toBe(100);
|
|
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
|
expect(now?.theoreticalBalance).toBe(130);
|
|
});
|
|
|
|
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
|
repository.findOneOrFail.mockResolvedValue({
|
|
id: 9,
|
|
balance: 50,
|
|
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
|
players: [
|
|
{
|
|
id: 1,
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
active: true,
|
|
balance: -20,
|
|
transactions: [
|
|
{
|
|
date: isoDate(6, 5),
|
|
amount: 999,
|
|
type: { id: 1, name: 'credit' },
|
|
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
|
},
|
|
{
|
|
date: isoDate(1, 10),
|
|
amount: 20,
|
|
type: { id: 11, name: 'fine' },
|
|
note: 'Zu spät',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.getOverviewStats(9, 42);
|
|
|
|
// Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
|
|
// where wrongly including the €999 adjustment would still show up as "already happened".
|
|
const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
|
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
|
|
|
// Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
|
|
// with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
|
|
// so it stays at the team's flat 50.
|
|
expect(beforeAdjustment?.theoreticalBalance).toBe(50);
|
|
expect(now?.theoreticalBalance).toBe(70);
|
|
});
|
|
|
|
it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
|
|
repository.findOneOrFail.mockResolvedValue({
|
|
id: 9,
|
|
balance: 100,
|
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
|
players: [
|
|
{
|
|
id: 1,
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
active: true,
|
|
balance: 0,
|
|
transactions: [],
|
|
},
|
|
{
|
|
id: 2,
|
|
firstName: 'Bea',
|
|
lastName: 'Beispiel',
|
|
active: false,
|
|
balance: -40,
|
|
transactions: [
|
|
{
|
|
date: isoDate(2, 10),
|
|
amount: 40,
|
|
type: { id: 11, name: 'fine' },
|
|
note: 'Zu spät',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.getOverviewStats(9, 42);
|
|
|
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
|
|
|
// The inactive player's unpaid fine is real debt, but no longer counted at all —
|
|
// the theoretical line must match the actual cash balance exactly.
|
|
expect(now?.balance).toBe(100);
|
|
expect(now?.theoreticalBalance).toBe(100);
|
|
});
|
|
|
|
it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
|
|
repository.findOneOrFail.mockResolvedValue({
|
|
id: 9,
|
|
balance: 100,
|
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
|
players: [
|
|
{
|
|
id: 1,
|
|
firstName: 'Alex',
|
|
lastName: 'Muster',
|
|
active: true,
|
|
balance: 30,
|
|
transactions: [
|
|
{
|
|
date: isoDate(2, 10),
|
|
amount: 30,
|
|
type: { id: 1, name: 'credit' },
|
|
note: 'Vorauszahlung',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await service.getOverviewStats(9, 42);
|
|
|
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
|
|
|
// Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
|
|
// line dips below the actual cash balance at this checkpoint.
|
|
expect(now?.balance).toBe(100);
|
|
expect(now?.theoreticalBalance).toBe(70);
|
|
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
|
|
});
|
|
|
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
|
repository.findOneOrFail.mockResolvedValue({
|
|
id: 9,
|
|
balance: 0,
|
|
transactions: [],
|
|
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
|
});
|
|
|
|
const result = await service.getOverviewStats(9, 42);
|
|
|
|
expect(result.balanceHistory).toEqual([]);
|
|
});
|
|
});
|