diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts new file mode 100644 index 0000000..9800dbb --- /dev/null +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -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([]); + }); +}); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index f80a4c1..ed619d7 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -13,6 +13,7 @@ import { CreateTeamDTO } from './dto/create-team.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { Team } from './entities/team.entity'; import { TeamAccessService } from './team-access.service'; +import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service'; @Injectable() export class TeamsService { @@ -232,7 +233,7 @@ export class TeamsService { teamId: string | number, actorUserId: string | number, ): Promise<{ - balanceHistory: { month: string; balance: number }[]; + balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]; monthlyFlow: { month: string; income: number; expense: number }[]; topOutstanding: { playerId: number; playerName: string; balance: number }[]; }> { @@ -330,7 +331,13 @@ export class TeamsService { return { month, income: this.round(income), expense: this.round(expense) }; }); - return { balanceHistory, monthlyFlow, topOutstanding }; + const outstandingHistory = this.reconstructOutstandingHistory(months, players); + const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({ + ...point, + theoreticalBalance: this.round(point.balance - outstandingHistory[index]), + })); + + return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding }; } private getLast12Months(): string[] { @@ -347,6 +354,56 @@ export class TeamsService { return movement.type === 'expense' ? -movement.amount : movement.amount; } + private reconstructOutstandingHistory(months: string[], players: Player[]): number[] { + const activePlayers = players.filter((p) => p.active); + const totals = months.map(() => 0); + + for (const player of activePlayers) { + const realTransactions = (player.transactions ?? []).filter( + (t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX), + ); + const playerHistory = this.reconstructPlayerBalanceHistory( + months, + Number(player.balance), + realTransactions, + ); + playerHistory.forEach((balance, index) => { + totals[index] += balance; + }); + } + + return totals; + } + + private reconstructPlayerBalanceHistory( + months: string[], + currentBalance: number, + transactions: Transaction[], + ): number[] { + const descendingMovements = transactions + .map((t) => ({ + date: t.date, + amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount), + })) + .sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0)); + + let futureSum = 0; + let movementIndex = 0; + return [...months] + .reverse() + .map((month) => { + while ( + movementIndex < descendingMovements.length && + descendingMovements[movementIndex].date.slice(0, 7) > month + ) { + futureSum += descendingMovements[movementIndex].amount; + movementIndex++; + } + return currentBalance - futureSum; + }) + .reverse(); + } + private round(value: number): number { return Math.round(value * 100) / 100; }