From 5f619d649ccf1570a8ef15758702be4e7a3c4e6c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 10:31:53 +0200 Subject: [PATCH 1/5] docs: add spec and plan for theoretical cash balance line Recreated from the main checkout, where these were committed to local master but not yet pushed and thus missing from this fresh worktree. --- .../2026-08-03-theoretischer-kassenstand.md | 431 ++++++++++++++++++ ...-08-03-theoretischer-kassenstand-design.md | 111 +++++ 2 files changed, 542 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md create mode 100644 docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md 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/docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md b/docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md new file mode 100644 index 0000000..f49a682 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-theoretischer-kassenstand-design.md @@ -0,0 +1,111 @@ +# Theoretischer Kassenstand (Ist + offene Beiträge) im Kassenstand-Verlauf + +Status: approved +Datum: 2026-08-03 + +## Kontext + +Ergänzung zum bestehenden Kassenstand-Verlauf-Chart aus +`docs/superpowers/specs/2026-08-01-kasse-kpi-charts-design.md`. Der Chart auf der Team-Übersicht +(`features/team/overview/overview.ts` + `.html`) zeigt aktuell eine Linie „Kassenstand" über die +letzten 12 Monate, gespeist aus `GET /teams/:id/overview/stats` bzw. +`teams.service.ts#getOverviewStats`. + +Auf derselben Übersicht existiert bereits eine zweite Kennzahl „Offene Beiträge" (aus +`teams.service.ts#getOverview`, Zeile 46-53): `-(Summe der `balance` aller aktiven Spieler)`. Sie +zeigt nur den heutigen Wert, keinen Verlauf. + +Ziel: eine zweite Linie im bestehenden Chart, die pro Monat den theoretischen Kassenstand zeigt — +also „was wäre in der Kasse, wenn alle offenen Beiträge bereits bezahlt worden wären" —, um Trainer/ +Kassenwarte auf einen Blick erkennen zu lassen, wie stark der Ist-Stand vom Soll-Stand abweicht. + +## Entscheidungen aus dem Brainstorming + +- **Historie statt Snapshot**: Die zweite Linie zeigt für jeden Monat die zu diesem Zeitpunkt + tatsächlich offenen Beiträge, nicht den heutigen Wert konstant über alle 12 Punkte addiert. +- **Näherung wie beim bestehenden Kassenstand-Verlauf**: Es wird mit der *heutigen* Menge aktiver + Spieler gerechnet, kein historisches Tracking von Mitgliedschaft/Aktiv-Status. Das ist dieselbe + Vereinfachung, die `balanceHistory` bereits für den Kassenstand selbst nutzt (siehe + `getOverviewStats`-Kommentar zu `team.balance` als Anker). +- **Datenform**: `theoreticalBalance` wird als zusätzliches Feld direkt in jeden bestehenden + `balanceHistory`-Punkt eingebettet (`{ month, balance, theoreticalBalance }`), kein separates + Array — additive, nicht-brechende Erweiterung der bestehenden Response. + +## Architektur / Komponenten + +### 1. Backend: `teams.service.ts#getOverviewStats` + +Neue private Hilfsberechnung, analog zur bestehenden Rückwärts-Rekonstruktion von `balanceHistory` +(Zeile ~290-320), aber auf Spieler-Ebene statt Team-Ebene: + +- Datenquelle: `team.players` (bereits geladen über `relations: ['players', 'players.transactions', + 'transactions']`), gefiltert auf `player.active` — dieselbe Teilmenge, die `getOverview` für die + heutige „Offene Beiträge"-Kachel verwendet. +- Für jeden aktiven Spieler: dessen `transactions` (bereits geladen), **ausgenommen** Zeilen mit + `note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX)` (Import aus + `team-members.service.ts`) — dieselbe Ausschlussregel wie in + `TeamMembersService.recomputeBalance`, damit synthetische Ausgleichsbuchungen die Historie nicht + verfälschen. +- Vorzeichen je Buchung: `type.id > 10` (Strafe/Umlage/Gebühr, IDs 11-13) mindert den Spieler-Saldo, + alle anderen Typen (`payment`, `credit`) erhöhen ihn — identische Regel wie in + `TeamMembersService.recomputeBalance` (Zeile ~127-133) und `TransactionsService.reverse()` + (`type.id > 10`-Check). +- Rekonstruktion: ausgehend von `player.balance` (aktueller, autoritativer Wert) rückwärts durch die + nach Datum absteigend sortierten Buchungen laufen und pro Monat der letzten 12 Monate den + rekonstruierten Saldo am Monatsende ermitteln — strukturell identisch zum bestehenden + `descendingMovements`/`futureSum`-Muster für `balanceHistory`, nur pro Spieler statt einmal fürs + Team. +- Pro Monat: `outstandingAtMonth = -Σ(rekonstruierter Saldo aktiver Spieler)`, + `theoreticalBalance = balanceHistory[monat].balance + outstandingAtMonth`. +- Rückgabeform ändert sich zu: + ```ts + balanceHistory: { month: string; balance: number; theoreticalBalance: number }[] + ``` + `monthlyFlow` und `topOutstanding` bleiben unverändert. +- Gating unverändert: Ist `movements.length === 0` (keine Kassenbewegung je), bleibt + `balanceHistory: []` wie heute — ein Team mit ausschließlich unbezahlten Strafen, aber ganz ohne + Zahlungsbewegung, zeigt weiterhin keinen Chart (Out of Scope, siehe unten). + +### 2. Frontend: `overview.ts` / Chart-Konfiguration + +- `models/team-stats.model.ts`: `BalanceHistoryPoint` um `theoreticalBalance: number` erweitern. +- `balanceChartData` (computed) bekommt eine zweite Dataset-Eintrag: + - Label: „Theoretisch (inkl. offene Beiträge)" + - `data: points.map((p) => p.theoreticalBalance)` + - Gestrichelt (`borderDash: [6, 4]`), eigene Farbe `#1d70b8` (Blauton, klar unterscheidbar vom + Grün `#4f8f46` der Ist-Linie), `fill: false`. +- `balanceChartOptions`: `plugins.legend.display` von `false` auf `true` (bzw. `position: 'bottom'` + wie beim Flow-Chart), da jetzt zwei Linien unterschieden werden müssen. +- Keine Änderung an `ChartCanvas` (shared component) nötig — reine Config-/Daten-Änderung. + +## Fehlerbehandlung + +Unverändert zum bestehenden Muster: Fehler beim Laden der Stats führen zum bestehenden stillen +Empty-State der Chart-Karte. Kein neuer Fehlerfall durch diese Erweiterung. + +## Testing + +- Backend (`teams.service.spec.ts`, Erweiterung des bestehenden `getOverviewStats`-Testblocks): + - Sanity-Check: `balanceHistory.at(-1).theoreticalBalance === team.balance + aktuelle Summe + offener Beiträge` (heutiger Wert, wie von `getOverview` berechnet). + - Historische Rekonstruktion: Testfall mit einer Strafe (`fine`) in einem früheren Monat, die erst + im aktuellen Monat bezahlt wurde — `theoreticalBalance` im früheren Monat muss die damals + offene Strafe enthalten, `balance` (Ist) nicht. + - Deaktivierungs-Ausgleichsbuchungen werden aus der Rekonstruktion ausgeschlossen (Testfall mit + einem zwischenzeitlich deaktivierten und wieder aktivierten Spieler). + - Leerfall (`movements.length === 0`) liefert weiterhin `balanceHistory: []`. +- Frontend (`overview.spec.ts`): Erweiterung des bestehenden Chart-Daten-Tests um Assertion, dass + `balanceChartData()` zwei Datasets enthält und die zweite Serie aus `theoreticalBalance` gespeist + wird. +- Manuelle Verifikation: Team mit einer unbezahlten Strafe/Umlage lokal aufrufen, prüfen dass die + theoretische Linie sichtbar über der Ist-Linie liegt und bei vollständiger Bezahlung beide Linien + zusammenlaufen. + +## Out of Scope + +- Historisches Tracking von Mitgliedschaft/Aktiv-Status (Näherung mit heutiger aktiver + Spieler-Menge, siehe oben). +- Teams mit ausschließlich unbezahlten Strafen/Umlagen/Gebühren, aber ganz ohne Kassenbewegung — + zeigen weiterhin keinen Chart (bestehende Einschränkung aus dem Basis-Feature, nicht neu + eingeführt). +- Zeitraum-Umschalter (weiterhin feste letzte 12 Monate, wie im Basis-Feature festgelegt). From 1b6ce57fbfbc2fcfb925ad9d8f2f01b441082b0a Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 10:44:22 +0200 Subject: [PATCH 2/5] 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 --- .../src/teams/teams.service.spec.ts | 127 ++++++++++++++++++ .../src/teams/teams.service.ts | 61 ++++++++- 2 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 myteamwallet_backend/src/teams/teams.service.spec.ts 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; } From d8f13d674d1deb3a133ad1ae83adf05760e11b9a Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 10:58:48 +0200 Subject: [PATCH 3/5] refactor: extract shared backward-reconstruction logic Extract the month-by-month backward-walk algorithm (used for both team-level cash balance and player-level debt history) into a single shared private helper `reconstructBackward()`. This eliminates code duplication while preserving behavior: - Team-level balanceHistory: maps movements through signedFlowAmount(), rounds each point, calls the shared helper - Player-level balance history: maps transactions using type.id rule, skips rounding (only rounds at final merge to avoid compounding errors), calls the shared helper Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.service.ts | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index ed619d7..7d1e560 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -299,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); @@ -354,6 +344,28 @@ 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); @@ -380,28 +392,12 @@ export class TeamsService { 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)); + const signedMovements = transactions.map((t) => ({ + date: t.date, + amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount), + })); - 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(); + return this.reconstructBackward(months, currentBalance, signedMovements); } private round(value: number): number { From ebfaac7590ca592cd68ab17ee413bd995a495a69 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 11:05:11 +0200 Subject: [PATCH 4/5] 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. Co-Authored-By: Claude Sonnet 5 --- .../src/app/core/team/team-stats-api.spec.ts | 2 +- .../src/app/features/team/overview/overview.spec.ts | 8 ++++++-- .../src/app/features/team/overview/overview.ts | 12 +++++++++++- .../src/app/models/team-stats.model.ts | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) 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..2bf39f4 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,10 @@ 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(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 { From 3cb8cd9a4abe925f7044705d38c3c793e913132b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 11:24:34 +0200 Subject: [PATCH 5/5] fix: strengthen deactivation-exclusion test and align sign rule with canonical logic - teams.service.spec.ts: pick a checkpoint older than the adjustment's own month so the exclusion test actually fails without the exclusion filter - teams.service.ts: only negate fine/levy/fee amounts when positive, matching TeamMembersService.recomputeBalance and Transaction.setBalance() exactly, instead of negating unconditionally - teams.service.ts: outstanding-history helper now returns a positive value when players owe money, matching the house convention already established by getOverview()'s team.outstanding - overview.spec.ts: assert the balance chart's legend becomes visible - teams.service.spec.ts: add coverage for inactive-player exclusion and a positive-balance (prepaid credit) case Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.service.spec.ts | 88 ++++++++++++++++++- .../src/teams/teams.service.ts | 15 ++-- .../features/team/overview/overview.spec.ts | 1 + 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 9800dbb..9219989 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -103,15 +103,95 @@ describe('TeamsService#getOverviewStats theoretical balance', () => { const result = await service.getOverviewStats(9, 42); - const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4)); + // 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)); - // 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); + // 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, diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 7d1e560..22027fc 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -324,7 +324,7 @@ export class TeamsService { const outstandingHistory = this.reconstructOutstandingHistory(months, players); const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({ ...point, - theoreticalBalance: this.round(point.balance - outstandingHistory[index]), + theoreticalBalance: this.round(point.balance + outstandingHistory[index]), })); return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding }; @@ -384,7 +384,9 @@ export class TeamsService { }); } - return totals; + // 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( @@ -392,10 +394,11 @@ export class TeamsService { currentBalance: number, transactions: Transaction[], ): number[] { - const signedMovements = transactions.map((t) => ({ - date: t.date, - amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount), - })); + 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); } 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 2bf39f4..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 @@ -197,6 +197,7 @@ describe('Overview', () => { 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');