# 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.