Files
teamwallet/docs/superpowers/plans/2026-08-03-theoretischer-kassenstand.md
Bastian Wagner 5f619d649c 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 10:31:53 +02:00

18 KiB

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:

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:

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:

  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:

    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:

  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
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:

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:

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:

    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:

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:

  protected readonly balanceChartData = computed<ChartData>(() => {
    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:

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