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([]); }); });