Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
10 KiB
PHP
252 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\Auswertung;
|
||
use Finanzmanager\Services\Cockpit;
|
||
|
||
class CockpitTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 15: Cockpit-Kacheln';
|
||
|
||
/** Fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
|
||
private const HEUTE = '2026-08-11';
|
||
|
||
private int $giro = 0;
|
||
private int $sparkonto = 0;
|
||
private int $gehalt = 0;
|
||
private int $wohnen = 0;
|
||
private int $miete = 0;
|
||
private int $strom = 0;
|
||
private int $freizeit = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
$this->buchungen();
|
||
|
||
$this->kachelauswahlMerktSich();
|
||
$this->monatsreiheTrenntEinnahmenUndAusgaben();
|
||
$this->monatsreiheLaesstUmbuchungenAus();
|
||
$this->verteilungFasstAufDerWurzelZusammen();
|
||
$this->verteilungKuerztAufDieGrenze();
|
||
$this->offeneVorgaengeZaehlenWasWartet();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Cockpitgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||
]);
|
||
$this->sparkonto = Konto::anlegen([
|
||
'name' => 'Cockpitsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
|
||
$this->gehalt = Kategorie::findenOderAnlegenNachPfad('100 - Einkommen:Gehalt', 'einnahme');
|
||
$this->wohnen = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen', 'ausgabe');
|
||
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
|
||
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
|
||
$this->freizeit = Kategorie::findenOderAnlegenNachPfad('500 - Freizeit', 'ausgabe');
|
||
}
|
||
|
||
private function buchungen(): void
|
||
{
|
||
// Zwei Monate des laufenden Jahres, beide im Zwölfmonatsfenster.
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-07-01', 'betrag' => 3000,
|
||
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
|
||
]);
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-07-03', 'betrag' => -900,
|
||
'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete, 'abgeglichen' => 1,
|
||
]);
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -100,
|
||
'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom, 'abgeglichen' => 1,
|
||
]);
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => 3000,
|
||
'empfaenger' => 'Arbeitgeber', 'kategorie_id' => $this->gehalt, 'abgeglichen' => 1,
|
||
]);
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-08-05', 'betrag' => -200,
|
||
'empfaenger' => 'Kino', 'kategorie_id' => $this->freizeit, 'abgeglichen' => 1,
|
||
]);
|
||
}
|
||
|
||
private function kachelauswahlMerktSich(): void
|
||
{
|
||
$this->assertEquals(
|
||
array_keys(Cockpit::KACHELN),
|
||
Cockpit::sichtbar(),
|
||
'Ohne gespeicherte Auswahl sind alle Kacheln sichtbar'
|
||
);
|
||
|
||
Cockpit::setzeSichtbar(['ausgaben', 'vermoegen', 'gibtesnicht']);
|
||
|
||
$this->assertEquals(
|
||
['vermoegen', 'ausgaben'],
|
||
Cockpit::sichtbar(),
|
||
'Gespeichert wird in Katalogreihenfolge, Unbekanntes fällt weg'
|
||
);
|
||
$this->assert(Cockpit::zeigt('vermoegen'), 'Die gewählte Kachel wird gezeigt');
|
||
$this->assert(!Cockpit::zeigt('kontostaende'), 'Die abgewählte nicht');
|
||
|
||
// Alles abwählen muss möglich bleiben – sonst käme man aus einer leeren
|
||
// Auswahl nie heraus, weil sie als „nie gespeichert“ gälte.
|
||
Cockpit::setzeSichtbar([]);
|
||
$this->assertEquals([], Cockpit::sichtbar(), 'Auch die leere Auswahl bleibt leer');
|
||
|
||
Cockpit::setzeSichtbar(array_keys(Cockpit::KACHELN));
|
||
}
|
||
|
||
private function monatsreiheTrenntEinnahmenUndAusgaben(): void
|
||
{
|
||
$reihe = Cockpit::monatsreihe(self::HEUTE);
|
||
|
||
$this->assertEquals(12, count($reihe['monate']), 'Zwölf Monatsspalten');
|
||
$this->assertEquals('2026-08', $reihe['monate'][11]['schluessel'], 'Die letzte Spalte ist der laufende Monat');
|
||
$this->assertEquals('2025-09', $reihe['monate'][0]['schluessel'], 'Die erste liegt elf Monate davor');
|
||
|
||
$this->assertEquals(3000.0, $reihe['einnahmen'][10], 'Juli: das Gehalt');
|
||
$this->assertEquals(1000.0, $reihe['ausgaben'][10], 'Juli: Miete und Strom, vorzeichenlos');
|
||
$this->assertEquals(2000.0, $reihe['salden'][10], 'Juli: Saldo ist Einnahmen minus Ausgaben');
|
||
$this->assertEquals(2800.0, $reihe['salden'][11], 'August: 3000 minus 200');
|
||
|
||
$this->assertEquals(6000.0, $reihe['summe_einnahmen'], 'Summe der Einnahmen über alle Monate');
|
||
$this->assertEquals(1200.0, $reihe['summe_ausgaben'], 'Summe der Ausgaben über alle Monate');
|
||
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
|
||
}
|
||
|
||
/** Wie überall in den Auswertungen: Umbuchungen sind weder Einnahme noch Ausgabe. */
|
||
private function monatsreiheLaesstUmbuchungenAus(): void
|
||
{
|
||
$vorher = Cockpit::monatsreihe(self::HEUTE);
|
||
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-08-06', 'betrag' => -500,
|
||
'empfaenger' => 'Aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
|
||
]);
|
||
|
||
$nachher = Cockpit::monatsreihe(self::HEUTE);
|
||
|
||
$this->assertEquals(
|
||
$vorher['summe_ausgaben'],
|
||
$nachher['summe_ausgaben'],
|
||
'Die Umbuchung taucht in den Ausgaben nicht auf'
|
||
);
|
||
$this->assertEquals(
|
||
$vorher['salden'][11],
|
||
$nachher['salden'][11],
|
||
'Und ändert den Saldo des Monats nicht'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Die Kachel zeigt oberste Kategorien: „300 - Wohnen“ statt Miete und Strom
|
||
* einzeln – sonst hätte der Ring so viele Segmente wie es Kategorien gibt.
|
||
*/
|
||
private function verteilungFasstAufDerWurzelZusammen(): void
|
||
{
|
||
$verteilung = Cockpit::ausgaben(self::HEUTE);
|
||
|
||
$namen = array_column($verteilung['zeilen'], 'betrag', 'name');
|
||
|
||
$this->assertEquals(1000.0, $namen['300 - Wohnen'] ?? 0.0, 'Miete und Strom stehen unter ihrer Wurzel');
|
||
$this->assertEquals(200.0, $namen['500 - Freizeit'] ?? 0.0, 'Freizeit hat keine Kinder und steht für sich');
|
||
$this->assert(!isset($namen['300 - Wohnen:Miete']), 'Unterkategorien haben kein eigenes Segment');
|
||
|
||
$this->assertEquals(1200.0, $verteilung['summe'], 'Die Summe sind alle Ausgaben des Jahres');
|
||
$this->assertEquals('ausgabe', $verteilung['art'], 'Gefragt war nach Ausgaben');
|
||
$this->assert(!$verteilung['leer'], 'Die Kachel hat etwas zu zeigen');
|
||
|
||
$anteile = 0.0;
|
||
foreach ($verteilung['zeilen'] as $zeile) {
|
||
$this->assertGreaterThan(0.0, $zeile['betrag'], 'Jedes Segment trägt einen positiven Betrag');
|
||
$anteile += $zeile['anteil'];
|
||
}
|
||
$this->assertEquals(1.0, round($anteile, 6), 'Die Anteile ergeben zusammen den ganzen Ring');
|
||
|
||
// Einnahmen gehen genauso, nur andersherum.
|
||
$einnahmen = Auswertung::verteilung(
|
||
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
|
||
'einnahme',
|
||
8,
|
||
self::HEUTE
|
||
);
|
||
$this->assertEquals(6000.0, $einnahmen['summe'], 'Die Einnahmenseite zählt das Gehalt');
|
||
}
|
||
|
||
private function verteilungKuerztAufDieGrenze(): void
|
||
{
|
||
for ($nummer = 1; $nummer <= 12; $nummer++) {
|
||
$kategorie = Kategorie::findenOderAnlegenNachPfad('9' . $nummer . ' - Klein ' . $nummer, 'ausgabe');
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-06-10', 'betrag' => -$nummer,
|
||
'empfaenger' => 'Kleinbetrag ' . $nummer, 'kategorie_id' => $kategorie,
|
||
]);
|
||
}
|
||
|
||
$verteilung = Auswertung::verteilung(
|
||
['typ' => 'summenbericht', 'zeitraum' => 'aktuelles_jahr'],
|
||
'ausgabe',
|
||
5,
|
||
self::HEUTE
|
||
);
|
||
|
||
$this->assertEquals(5, count($verteilung['zeilen']), 'Nicht mehr Segmente als die Grenze erlaubt');
|
||
|
||
$letzte = $verteilung['zeilen'][4];
|
||
$this->assert(str_starts_with($letzte['name'], 'Übrige ('), 'Das letzte Segment sammelt den Rest');
|
||
|
||
// Gekürzt wird die Darstellung, nicht die Summe.
|
||
$gezeichnet = array_sum(array_column($verteilung['zeilen'], 'betrag'));
|
||
$this->assertEquals(
|
||
$verteilung['summe'],
|
||
round($gezeichnet, 2),
|
||
'Die Segmente ergeben zusammen die Gesamtsumme'
|
||
);
|
||
}
|
||
|
||
private function offeneVorgaengeZaehlenWasWartet(): void
|
||
{
|
||
$schluessel = static fn(array $vorgaenge): array => array_column($vorgaenge, 'anzahl', 'schluessel');
|
||
|
||
$vorher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
|
||
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-08-07', 'betrag' => -42,
|
||
'empfaenger' => 'Unbekannt',
|
||
]);
|
||
|
||
$nachher = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
|
||
|
||
$this->assertEquals(
|
||
($vorher['ohne_kategorie'] ?? 0) + 1,
|
||
$nachher['ohne_kategorie'] ?? 0,
|
||
'Eine Buchung ohne Kategorie erscheint als offener Vorgang'
|
||
);
|
||
$this->assertGreaterThan(
|
||
0,
|
||
$nachher['nicht_abgeglichen'] ?? 0,
|
||
'Nicht abgeglichene Buchungen werden mitgezählt'
|
||
);
|
||
|
||
// Umbuchungen brauchen keine Kategorie – sie sind keine offene Arbeit.
|
||
$mitUmbuchung = $schluessel(Cockpit::offeneVorgaenge(self::HEUTE));
|
||
$this->assertEquals(
|
||
$nachher['ohne_kategorie'] ?? 0,
|
||
$mitUmbuchung['ohne_kategorie'] ?? 0,
|
||
'Die Umbuchung von vorhin zählt nicht als unkategorisiert'
|
||
);
|
||
|
||
foreach (Cockpit::offeneVorgaenge(self::HEUTE) as $vorgang) {
|
||
$this->assertNotEmpty($vorgang['pfad'], 'Jeder Vorgang führt irgendwohin');
|
||
$this->assertNotEmpty($vorgang['text'], 'Und trägt einen Text');
|
||
}
|
||
}
|
||
}
|