feat: add theoretical balance history to team overview stats

Reconstructs each active player's balance per month (same backward
technique as the existing cash-balance history) so the overview stats
endpoint can report what the team balance would be if all currently
open dues had already been paid.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-03 10:44:22 +02:00
parent 5f619d649c
commit 1b6ce57fbf
2 changed files with 186 additions and 2 deletions

View File

@@ -0,0 +1,127 @@
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);
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
expect(beforeFine?.theoreticalBalance).toBe(50);
expect(now?.theoreticalBalance).toBe(70);
});
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([]);
});
});