diff --git a/docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md b/docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md new file mode 100644 index 0000000..49b9b17 --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md @@ -0,0 +1,431 @@ +# Theoretischer Kassenstand Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eine zweite, gestrichelte Linie im bestehenden "Kassenstand-Verlauf"-Chart auf der Team-Übersicht zeigt pro Monat den theoretischen Kassenstand (Ist-Kassenstand + zu diesem Zeitpunkt offene, noch unbezahlte Beiträge). + +**Architecture:** Backend (`teams.service.ts#getOverviewStats`) rekonstruiert zusätzlich zur bestehenden Team-Kassenstand-Historie eine Spieler-Saldo-Historie pro aktivem Spieler (gleiches Rückwärts-Reconstruction-Muster wie beim Team-Kassenstand, nur mit allen Buchungstypen statt nur `payment`), summiert sie pro Monat zu "offene Beiträge" und addiert das Ergebnis als `theoreticalBalance`-Feld in jeden bestehenden `balanceHistory`-Punkt. Frontend übernimmt das neue Feld unverändert strukturell als zweite Chart.js-Datenserie im bestehenden Liniendiagramm. + +**Tech Stack:** NestJS/TypeORM (Backend, `myteamwallet_backend`), Angular 21 mit Chart.js (Frontend, `myteamwallet_frontend_modern`), Jest (Backend-Tests), Vitest (Frontend-Tests). + +## Global Constraints + +- Referenz-Spec: `docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md` (und Basis-Feature `docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`). +- `theoreticalBalance` wird als zusätzliches Feld in die bestehenden `balanceHistory`-Punkte eingebettet, kein separates Array. +- Historische Rekonstruktion nutzt die *heutige* Menge aktiver Spieler (kein historisches Mitgliedschafts-Tracking) — bewusste Näherung, identisch zur bestehenden `balanceHistory`-Logik. +- Buchungen mit Notiz-Präfix `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (aus `team-members.service.ts`) werden aus der Rekonstruktion ausgeschlossen. +- Vorzeichen-Regel für Spieler-Buchungen: `type.id > 10` (Strafe/Umlage/Gebühr) mindert den Saldo, alle anderen Typen erhöhen ihn — exakt wie in `TeamMembersService.recomputeBalance` und `Transaction.setBalance()`. +- Zweite Chart-Linie: gestrichelt (`borderDash: [6, 4]`), Farbe `#1d70b8`, Label „Theoretisch (inkl. offene Beiträge)". Bestehende Ist-Linie bleibt `#4f8f46`, durchgezogen. +- `balanceChartOptions.plugins.legend.display` wechselt von `false` auf sichtbar (`position: 'bottom'`, wie beim bestehenden Flow-Chart). +- Gating unverändert: hat das Team gar keine Kassenbewegung (`movements.length === 0`), bleibt `balanceHistory: []` (kein Chart, wie heute). +- Bestehende Felder `monthlyFlow` und `topOutstanding` bleiben strukturell unverändert. + +--- + +### Task 1: Backend — `theoreticalBalance` in `getOverviewStats` berechnen + +**Files:** +- Modify: `myteamwallet_backend/src/teams/teams.service.ts:1-15` (Import), `:231-334` (`getOverviewStats` + neue private Hilfsmethoden) +- Create: `myteamwallet_backend/src/teams/teams.service.spec.ts` (existiert noch nicht) + +**Interfaces:** +- Consumes: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX` (exportiert aus `myteamwallet_backend/src/teams/team-members.service.ts`), `Transaction`-Entity (bereits importiert in `teams.service.ts`), `Player`-Entity (bereits importiert). +- Produces: `getOverviewStats(...)` liefert `balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]` — dieses Feld konsumiert Task 2 im Frontend (`BalanceHistoryPoint.theoreticalBalance`). + +- [ ] **Step 1: Neue Testdatei mit fehlschlagenden Tests schreiben** + +Erstelle `myteamwallet_backend/src/teams/teams.service.spec.ts`: + +```ts +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([]); + }); +}); +``` + +- [ ] **Step 2: Tests ausführen und Fehlschlag bestätigen** + +Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts` +Expected: FAIL — `result.balanceHistory` Punkte haben noch kein `theoreticalBalance`-Feld (`undefined` statt der erwarteten Zahlen), erste zwei Tests schlagen fehl. Dritter Test (Leerfall) sollte bereits PASS sein (unverändertes Verhalten) — das bestätigt, dass der Testaufbau korrekt gegen die bisherige Implementierung läuft. + +- [ ] **Step 3: `DEACTIVATION_ADJUSTMENT_NOTE_PREFIX`-Import ergänzen** + +In `myteamwallet_backend/src/teams/teams.service.ts`, nach der bestehenden Import-Zeile für `TeamAccessService` (Zeile ~15) ergänzen: + +```ts +import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service'; +``` + +- [ ] **Step 4: Rückgabetyp von `getOverviewStats` erweitern** + +In `teams.service.ts`, die Signatur von `getOverviewStats` (aktuell Zeile 231-238) ändern: + +```ts + async getOverviewStats( + teamId: string | number, + actorUserId: string | number, + ): Promise<{ + balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]; + monthlyFlow: { month: string; income: number; expense: number }[]; + topOutstanding: { playerId: number; playerName: string; balance: number }[]; + }> { +``` + +- [ ] **Step 5: Berechnung von `theoreticalBalance` einfügen** + +In `teams.service.ts`, direkt vor dem finalen `return { balanceHistory, monthlyFlow, topOutstanding };` am Ende von `getOverviewStats` (aktuell Zeile 333) einfügen: + +```ts + 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 }; + } +``` + +und die alte letzte Zeile ` return { balanceHistory, monthlyFlow, topOutstanding };` entfernen (sie wird durch den obigen Block ersetzt). + +- [ ] **Step 6: Private Hilfsmethoden ergänzen** + +In `teams.service.ts`, nach der bestehenden privaten Methode `signedFlowAmount` (aktuell Zeile 346-348) einfügen: + +```ts + 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(); + } +``` + +`Transaction` und `Player` sind in `teams.service.ts` bereits importiert (Zeile 4 bzw. 10 im bestehenden Import-Block) — keine weiteren Imports nötig. + +- [ ] **Step 7: Tests ausführen und Erfolg bestätigen** + +Run: `cd myteamwallet_backend && npx jest src/teams/teams.service.spec.ts` +Expected: PASS — alle drei Tests grün. + +- [ ] **Step 8: Vollständige Backend-Suite und Build laufen lassen** + +Run: `cd myteamwallet_backend && npm test -- --silent && npm run build` +Expected: alle bestehenden Tests weiterhin PASS (insbesondere keine Regression in anderen `teams`-Tests), Build ohne TypeScript-Fehler. + +- [ ] **Step 9: Commit** + +```bash +cd myteamwallet_backend +git add src/teams/teams.service.ts src/teams/teams.service.spec.ts +git commit -m "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." +``` + +--- + +### Task 2: Frontend — zweite Chart-Linie im Kassenstand-Verlauf + +**Files:** +- Modify: `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts` +- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts` +- Modify: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts` + +**Interfaces:** +- Consumes: `theoreticalBalance` Feld aus Task 1 (`BalanceHistoryPoint.theoreticalBalance: number`, via `GET /teams/:id/overview/stats`). +- Produces: keine neuen öffentlichen Interfaces — reine Chart-Darstellungs-Änderung innerhalb `Overview`. + +- [ ] **Step 1: Modell erweitern** + +In `myteamwallet_frontend_modern/src/app/models/team-stats.model.ts`, `BalanceHistoryPoint` ändern: + +```ts +export interface BalanceHistoryPoint { + month: string; + balance: number; + theoreticalBalance: number; +} +``` + +- [ ] **Step 2: Fehlschlagenden Test schreiben** + +In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts`, `sampleStats` (aktuell Zeile 22-32) um `theoreticalBalance` ergänzen: + +```ts +const sampleStats: TeamOverviewStats = { + balanceHistory: [ + { month: '2026-06', balance: 100, theoreticalBalance: 100 }, + { month: '2026-07', balance: 125, theoreticalBalance: 150 }, + ], + monthlyFlow: [ + { month: '2026-06', income: 50, expense: 10 }, + { month: '2026-07', income: 40, expense: 15 }, + ], + topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }], +}; +``` + +Im Test `'passes the loaded stats to each ChartCanvas once the request resolves'` (aktuell Zeile 181-200), nach der bestehenden Assertion `expect(balanceChart.data.labels).toHaveLength(2);` ergänzen: + +```ts + expect(balanceChart.data.datasets).toHaveLength(2); + expect(balanceChart.data.datasets[0].data).toEqual([100, 125]); + expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)'); + expect(balanceChart.data.datasets[1].data).toEqual([100, 150]); +``` + +- [ ] **Step 3: Test ausführen und Fehlschlag bestätigen** + +Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'` +Expected: FAIL — `balanceChart.data.datasets` hat noch Länge 1, die neuen Assertions schlagen fehl (bzw. TypeScript-Compile-Fehler, weil `sampleStats` noch nicht zum erweiterten `BalanceHistoryPoint`-Typ passt, falls Step 1 vor Step 2 gemacht wurde — in diesem Fall zunächst nur diesen Test isoliert betrachten). + +- [ ] **Step 4: Zweite Datenserie und Legende in `overview.ts` ergänzen** + +In `myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts`, die Farbkonstanten (aktuell Zeile 23-25) um die neue Farbe ergänzen: + +```ts +const BALANCE_COLOR = '#4f8f46'; +const THEORETICAL_BALANCE_COLOR = '#1d70b8'; +const INCOME_COLOR = '#4f8f46'; +const EXPENSE_COLOR = '#c1121f'; +``` + +`balanceChartData` (aktuell Zeile 65-80) um die zweite Datenserie erweitern: + +```ts + protected readonly balanceChartData = computed(() => { + const points = this.balanceHistory(); + return { + labels: points.map((point) => formatMonthLabel(point.month)), + datasets: [ + { + label: 'Kassenstand', + data: points.map((point) => point.balance), + borderColor: BALANCE_COLOR, + backgroundColor: BALANCE_COLOR, + tension: 0.3, + fill: false, + }, + { + label: 'Theoretisch (inkl. offene Beiträge)', + data: points.map((point) => point.theoreticalBalance), + borderColor: THEORETICAL_BALANCE_COLOR, + backgroundColor: THEORETICAL_BALANCE_COLOR, + borderDash: [6, 4], + tension: 0.3, + fill: false, + }, + ], + }; + }); +``` + +`balanceChartOptions` (aktuell Zeile 115-119) die Legende einblenden: + +```ts + protected readonly balanceChartOptions: ChartOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom' } }, + }; +``` + +- [ ] **Step 5: Test ausführen und Erfolg bestätigen** + +Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false --include='**/overview.spec.ts'` +Expected: PASS — alle Tests in `overview.spec.ts` grün, inklusive der neuen Dataset-Assertions. + +- [ ] **Step 6: Vollständige Frontend-Suite laufen lassen** + +Run: `cd myteamwallet_frontend_modern && npx ng test --watch=false` +Expected: alle Tests PASS (keine Regression in anderen Specs durch die geänderte `BalanceHistoryPoint`-Typform). + +- [ ] **Step 7: Commit** + +```bash +cd myteamwallet_frontend_modern +git add src/app/models/team-stats.model.ts src/app/features/team/overview/overview.ts src/app/features/team/overview/overview.spec.ts +git commit -m "feat: show theoretical balance line in the cash-balance chart + +Adds a second, dashed line to the existing balance-history chart that +includes currently open player dues, so managers can see at a glance +how far the actual cash balance lags behind what has been pledged." +``` + +--- + +## Self-Review Notes + +- **Spec-Abdeckung:** Backend-Berechnung (Task 1, Steps 4-6), Response-Form-Änderung (Task 1, Step 4), Frontend-Chart/Legende/Farben (Task 2, Step 4), Testing-Anforderungen aus der Spec (historische Rekonstruktion, Ausgleichsbuchungs-Ausschluss, Leerfall, Frontend-Dataset-Assertions) sind je in eigenen Test-Steps abgedeckt. Manuelle Verifikation aus der Spec ist bewusst nicht als Plan-Task modelliert — bei Bedarf nach Abschluss beider Tasks manuell im Browser gegen ein Team mit unbezahlter Strafe prüfen. +- **Typkonsistenz:** `theoreticalBalance: number` konsistent in Backend-Rückgabetyp (Task 1, Step 4), Frontend-Modell (Task 2, Step 1) und allen Test-Fixtures verwendet. +- **Scope:** Einzelne, in sich geschlossene Erweiterung eines bereits bestehenden Features — keine weitere Zerlegung nötig. 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..9219989 --- /dev/null +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -0,0 +1,207 @@ +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([]); + }); +}); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index f80a4c1..22027fc 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 }[]; }> { @@ -298,26 +299,16 @@ export class TeamsService { // reconstruct earlier month-end balances — this guarantees the most // recent point always equals team.balance by construction, regardless // of undocumented history. - const descendingMovements = [...movements].sort((a, b) => - a.date > b.date ? -1 : a.date < b.date ? 1 : 0, - ); - + const signedMovements = movements.map((m) => ({ + date: m.date, + amount: this.signedFlowAmount(m), + })); const currentBalance = Number(team.balance); - let futureSum = 0; - let movementIndex = 0; - const balanceHistory = [...months] - .reverse() - .map((month) => { - while ( - movementIndex < descendingMovements.length && - descendingMovements[movementIndex].date.slice(0, 7) > month - ) { - futureSum += this.signedFlowAmount(descendingMovements[movementIndex]); - movementIndex++; - } - return { month, balance: this.round(currentBalance - futureSum) }; - }) - .reverse(); + const rawBalances = this.reconstructBackward(months, currentBalance, signedMovements); + const balanceHistory = months.map((month, index) => ({ + month, + balance: this.round(rawBalances[index]), + })); const monthlyFlow = months.map((month) => { const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month); @@ -330,7 +321,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 +344,65 @@ export class TeamsService { return movement.type === 'expense' ? -movement.amount : movement.amount; } + private reconstructBackward( + months: string[], + currentValue: number, + signedMovements: { date: string; amount: number }[], + ): number[] { + const descending = [...signedMovements].sort((a, b) => + a.date > b.date ? -1 : a.date < b.date ? 1 : 0, + ); + let futureSum = 0; + let index = 0; + return [...months] + .reverse() + .map((month) => { + while (index < descending.length && descending[index].date.slice(0, 7) > month) { + futureSum += descending[index].amount; + index++; + } + return currentValue - futureSum; + }) + .reverse(); + } + + 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; + }); + } + + // House convention (see getOverview()'s team.outstanding = out * -1): "outstanding" + // is a positive number when players owe money, negative when they're in credit. + return totals.map((total) => total * -1); + } + + private reconstructPlayerBalanceHistory( + months: string[], + currentBalance: number, + transactions: Transaction[], + ): number[] { + const signedMovements = transactions.map((t) => { + const rawAmount = Number(t.amount); + const amount = t.type && t.type.id > 10 && rawAmount > 0 ? -rawAmount : rawAmount; + return { date: t.date, amount }; + }); + + return this.reconstructBackward(months, currentBalance, signedMovements); + } + private round(value: number): number { return Math.round(value * 100) / 100; } diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts index 15f4133..274f710 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts @@ -21,7 +21,7 @@ describe('TeamStatsApi', () => { it('loads the overview stats for a team', () => { const stats: TeamOverviewStats = { - balanceHistory: [{ month: '2026-07', balance: 125 }], + balanceHistory: [{ month: '2026-07', balance: 125, theoreticalBalance: 150 }], monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }], topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }], }; diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts index 445fcf0..b81431e 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts @@ -21,8 +21,8 @@ vi.mock('chart.js', () => ({ Chart: MockChart, registerables: [] })); const sampleStats: TeamOverviewStats = { balanceHistory: [ - { month: '2026-06', balance: 100 }, - { month: '2026-07', balance: 125 }, + { month: '2026-06', balance: 100, theoreticalBalance: 100 }, + { month: '2026-07', balance: 125, theoreticalBalance: 150 }, ], monthlyFlow: [ { month: '2026-06', income: 50, expense: 10 }, @@ -193,6 +193,11 @@ describe('Overview', () => { ); expect(balanceChart.type).toBe('line'); expect(balanceChart.data.labels).toHaveLength(2); + expect(balanceChart.data.datasets).toHaveLength(2); + expect(balanceChart.data.datasets[0].data).toEqual([100, 125]); + expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)'); + expect(balanceChart.data.datasets[1].data).toEqual([100, 150]); + expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom'); expect(flowChart.type).toBe('bar'); expect(flowChart.data.datasets).toHaveLength(2); expect(outstandingChart.type).toBe('bar'); diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts index ebf6f1b..ef48edb 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts @@ -21,6 +21,7 @@ import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/oper registerLocaleData(localeDe); const BALANCE_COLOR = '#4f8f46'; +const THEORETICAL_BALANCE_COLOR = '#1d70b8'; const INCOME_COLOR = '#4f8f46'; const EXPENSE_COLOR = '#c1121f'; @@ -75,6 +76,15 @@ export class Overview { tension: 0.3, fill: false, }, + { + label: 'Theoretisch (inkl. offene Beiträge)', + data: points.map((point) => point.theoreticalBalance), + borderColor: THEORETICAL_BALANCE_COLOR, + backgroundColor: THEORETICAL_BALANCE_COLOR, + borderDash: [6, 4], + tension: 0.3, + fill: false, + }, ], }; }); @@ -115,7 +125,7 @@ export class Overview { protected readonly balanceChartOptions: ChartOptions = { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, + plugins: { legend: { position: 'bottom' } }, }; protected readonly flowChartOptions: ChartOptions = { diff --git a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts index c6694af..d49075c 100644 --- a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts +++ b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts @@ -1,6 +1,7 @@ export interface BalanceHistoryPoint { month: string; balance: number; + theoreticalBalance: number; } export interface MonthlyFlowPoint {