Budgetplaner mit Monatswerten je Kategorie, Monatsabschluss mit Prüflauf, Snapshot und Buchungssperre. Dazu drei Vorarbeiten in der Oberfläche. Budgetplaner - Migration 004: budgets, budget_positionen, monatsabschluesse - Zeilen sind der Kategoriebaum, Spalten die Monate; die ganze Tabelle wird in einem Formular gespeichert, damit mehrere Änderungen zusammen ankommen - Zwei Ansichten: Verlauf (Jahressumme, abgelaufene Monate zusammengefasst, laufender Monat mit Budget/Ist/Saldo, danach Folgemonate) und alle Monate - Ausgaben ohne Vorzeichen eingeben, es kommt aus der Kategorieart. Saldo ist Ist minus Budget: positiv heißt besser als geplant, negativ ist die Überschreitung und wird rot ausgewiesen - Übergeordnete Kategorien summieren ihre Nachfahren und sind nicht beplanbar - Ein Monat ohne Position ist "nicht geplant" und damit etwas anderes als "mit 0,00 geplant" - Wert auf Folgemonate übertragen, Budget als Kopie ins neue Jahr Monatsabschluss - Prüflauf über nicht kategorisierte Buchungen, nicht abgeglichene Umsätze und Splitsummen-Differenzen; er hält niemanden auf, sondern zeigt den Stand - Snapshot der Kontosalden und Kategoriesummen als JSON, bleibt auch nach einer Wiedereröffnung stehen - Abgeschlossene Monate sperren Buchungen in beide Richtungen: weder hinein noch heraus verschieben. Abschaltbar über monatsabschluss_sperrt_buchungen Vorarbeiten - .knopf.primaer trug einen eigenen margin-top und stand dadurch in jeder Knopfleiste tiefer als seine Nachbarn - sichtbar an "Abbrechen" neben "Konto anlegen". Der Abstand liegt jetzt allein bei der Knopfleiste - Kontengruppen werden wie Konten und Kategorien auf einer eigenen Formularseite gepflegt statt inline; dort hängt auch die Zuordnung der Konten (ankreuzen statt jedes Konto einzeln zu öffnen) - Der QIF-Export enthält keine Kontengruppen: kein Feld im !Account-Eintrag, nichts in der QSD-Datei. Import-Oberfläche und Doku sagen das jetzt - Die Baum-Einrückung lief über ein style-Attribut und wurde von der Content-Security-Policy verworfen; sie steht jetzt als data-ebene im Markup. Aus demselben Grund blendete app.js Erfolgsmeldungen nie aus Tests: 11_BudgetTest, 12_MonatsabschlussTest (76 Prüfungen), E2E planung.spec.js. Einzelheiten in docs/planung.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
12 KiB
PHP
252 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Controllers\KontenController;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Kontengruppe;
|
||
|
||
class KontenTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 07: Konten und Kontengruppen';
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
|
||
$this->anlegenUndFinden();
|
||
$this->kontoarten();
|
||
$this->uebersichtGruppiertUndSummiert();
|
||
$this->betragsEingabe();
|
||
$this->ankreuzfelderAusDemFormular();
|
||
$this->kontenEinerGruppeZuordnen();
|
||
}
|
||
|
||
/**
|
||
* Die Kontengruppen stehen nicht im QIF-Export; sie werden nach dem Import
|
||
* angelegt und die Konten in einem Zug angekreuzt. Abgehakte Konten müssen
|
||
* dabei zurück nach „Nicht gruppiert“ fallen, Konten anderer Gruppen aber
|
||
* unberührt bleiben.
|
||
*/
|
||
private function kontenEinerGruppeZuordnen(): void
|
||
{
|
||
$anlage = Kontengruppe::anlegen('Anlage', '', 1);
|
||
$liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2);
|
||
|
||
$haus = Konto::anlegen(['name' => 'Whg Besigheim', 'kontoart' => 'vermoegen', 'aktiv' => 1]);
|
||
$auto = Konto::anlegen(['name' => 'Skoda Kamiq', 'kontoart' => 'vermoegen', 'aktiv' => 1]);
|
||
$giro = Konto::anlegen(['name' => 'VR Weil Girokonto', 'kontoart' => 'bank', 'aktiv' => 1]);
|
||
|
||
Kontengruppe::kontenZuordnen($liquide, [$giro]);
|
||
Kontengruppe::kontenZuordnen($anlage, [$haus, $auto]);
|
||
|
||
$this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Konto landet in der Gruppe');
|
||
$this->assertEquals($anlage, (int)Konto::find($auto)['kontengruppe_id'], 'Auch das zweite Konto');
|
||
$this->assertEquals(
|
||
$liquide,
|
||
(int)Konto::find($giro)['kontengruppe_id'],
|
||
'Konten anderer Gruppen bleiben, wo sie sind'
|
||
);
|
||
|
||
// Zweiter Durchgang ohne das Auto: es fällt zurück zu „Nicht gruppiert“.
|
||
Kontengruppe::kontenZuordnen($anlage, [$haus]);
|
||
$this->assertNull(Konto::find($auto)['kontengruppe_id'], 'Abgehaktes Konto steht wieder ohne Gruppe');
|
||
$this->assertEquals($anlage, (int)Konto::find($haus)['kontengruppe_id'], 'Das andere bleibt zugeordnet');
|
||
|
||
// Leere Auswahl leert die Gruppe, ohne sie zu löschen.
|
||
Kontengruppe::kontenZuordnen($anlage, []);
|
||
$this->assertNull(Konto::find($haus)['kontengruppe_id'], 'Leere Auswahl räumt die Gruppe');
|
||
$this->assertNotEmpty(Kontengruppe::find($anlage), 'Die Gruppe selbst bleibt bestehen');
|
||
|
||
$mitZahl = [];
|
||
foreach (Kontengruppe::alleMitKontenzahl() as $gruppe) {
|
||
$mitZahl[$gruppe['name']] = (int)$gruppe['anzahl_konten'];
|
||
}
|
||
$this->assertEquals(0, $mitZahl['Anlage'], 'Kontenzahl der geleerten Gruppe');
|
||
$this->assertEquals(1, $mitZahl['Liquide Mittel'], 'Kontenzahl der anderen Gruppe');
|
||
}
|
||
|
||
private function anlegenUndFinden(): void
|
||
{
|
||
$id = Konto::anlegen([
|
||
'name' => 'DKB Girokonto',
|
||
'kontoart' => 'bank',
|
||
'kontonummer' => '1010903027',
|
||
'anfangssaldo' => 1234.56,
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$konto = Konto::find($id);
|
||
$this->assertEquals('DKB Girokonto', $konto['name'], 'Konto wird angelegt');
|
||
$this->assertEquals('1234.56', $konto['anfangssaldo'], 'Anfangssaldo wird übernommen');
|
||
|
||
$doppelt = false;
|
||
try {
|
||
Konto::anlegen(['name' => 'DKB Girokonto']);
|
||
} catch (\Throwable) {
|
||
$doppelt = true;
|
||
}
|
||
$this->assert($doppelt, 'Kontonamen sind eindeutig');
|
||
|
||
$ohneName = false;
|
||
try {
|
||
Konto::anlegen(['name' => ' ']);
|
||
} catch (\Throwable) {
|
||
$ohneName = true;
|
||
}
|
||
$this->assert($ohneName, 'Ein Konto ohne Namen wird abgewiesen');
|
||
|
||
// Leerzeichen in der IBAN sind beim Abtippen normal und müssen weg.
|
||
Konto::speichern($id, [
|
||
'name' => 'DKB Girokonto',
|
||
'kontoart' => 'bank',
|
||
'iban' => 'de89 3704 0044 0532 0130 00',
|
||
'aktiv' => 1,
|
||
]);
|
||
$this->assertEquals(
|
||
'DE89370400440532013000',
|
||
Konto::find($id)['iban'],
|
||
'IBAN wird ohne Leerzeichen und in Großbuchstaben abgelegt'
|
||
);
|
||
|
||
Konto::loeschen($id);
|
||
$this->assertNull(Konto::find($id), 'Konto lässt sich löschen');
|
||
}
|
||
|
||
private function kontoarten(): void
|
||
{
|
||
$this->assertEquals('bank', Konto::artAusQif('Bank'), 'QIF-Typ Bank');
|
||
$this->assertEquals('kreditkarte', Konto::artAusQif('CCard'), 'QIF-Typ CCard');
|
||
$this->assertEquals('sparkonto', Konto::artAusQif('Oth T'), 'QIF-Typ Oth T');
|
||
$this->assertEquals('depot', Konto::artAusQif('Port'), 'QIF-Typ Port');
|
||
$this->assertEquals('depot', Konto::artAusQif('Invst'), 'QIF-Typ Invst');
|
||
$this->assertEquals('sonstiges', Konto::artAusQif('Unbekannt'), 'Unbekannter Typ fällt auf Sonstiges');
|
||
$this->assertEquals('Kreditkarte', Konto::bezeichnungDerArt('kreditkarte'), 'Bezeichnung auf Deutsch');
|
||
|
||
$unbekannt = false;
|
||
try {
|
||
Konto::anlegen(['name' => 'Testkonto', 'kontoart' => 'gibtesnicht']);
|
||
} catch (\Throwable) {
|
||
$unbekannt = true;
|
||
}
|
||
$this->assert($unbekannt, 'Unbekannte Kontoart wird abgewiesen');
|
||
}
|
||
|
||
private function uebersichtGruppiertUndSummiert(): void
|
||
{
|
||
$anlage = Kontengruppe::anlegen('Anlage', '', 1);
|
||
|
||
Konto::anlegen(['name' => 'Tagesgeld', 'kontoart' => 'sparkonto',
|
||
'kontengruppe_id' => $anlage, 'anfangssaldo' => 10000, 'aktiv' => 1]);
|
||
Konto::anlegen(['name' => 'Depot', 'kontoart' => 'depot',
|
||
'kontengruppe_id' => $anlage, 'anfangssaldo' => 5000, 'aktiv' => 1]);
|
||
Konto::anlegen(['name' => 'Girokonto', 'kontoart' => 'bank',
|
||
'anfangssaldo' => 1500.50, 'aktiv' => 1]);
|
||
Konto::anlegen(['name' => 'Kreditkarte', 'kontoart' => 'kreditkarte',
|
||
'anfangssaldo' => -320.25, 'aktiv' => 1]);
|
||
Konto::anlegen(['name' => 'Altes Konto', 'kontoart' => 'bank',
|
||
'anfangssaldo' => 999, 'aktiv' => 0]);
|
||
|
||
$uebersicht = Konto::uebersicht();
|
||
$summeErwartet = 10000 + 5000 + 1500.50 - 320.25;
|
||
|
||
$this->assertEquals($summeErwartet, $uebersicht['summe'], 'Gesamtsumme ohne inaktive Konten');
|
||
$this->assertEquals(2, count($uebersicht['gruppen']), 'Zwei Gruppen: Anlage und Nicht gruppiert');
|
||
|
||
$nachName = [];
|
||
foreach ($uebersicht['gruppen'] as $gruppe) {
|
||
$nachName[$gruppe['name']] = $gruppe;
|
||
}
|
||
|
||
$this->assertEquals(15000.0, $nachName['Anlage']['summe'] ?? 0.0, 'Summe der Gruppe Anlage');
|
||
$this->assertEquals(
|
||
2,
|
||
count($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] ?? []),
|
||
'Konten ohne Gruppe stehen unter „Nicht gruppiert“'
|
||
);
|
||
|
||
$mitInaktiven = Konto::uebersicht(false);
|
||
$this->assertEquals(
|
||
$summeErwartet + 999,
|
||
$mitInaktiven['summe'],
|
||
'Inaktive Konten zählen mit, wenn sie eingeblendet werden'
|
||
);
|
||
|
||
// Die Saldospalten existieren schon, gespeist aus dem Anfangssaldo.
|
||
$giro = null;
|
||
foreach ($nachName[Kontengruppe::OHNE_GRUPPE]['konten'] as $konto) {
|
||
if ($konto['name'] === 'Girokonto') {
|
||
$giro = $konto;
|
||
}
|
||
}
|
||
$this->assertEquals(1500.50, $giro['saldo_aktuell'], 'Aktueller Saldo entspricht dem Anfangssaldo');
|
||
$this->assertEquals(1500.50, $giro['saldo_erwartet'], 'Erwarteter Saldo entspricht dem Anfangssaldo');
|
||
$this->assertNull($giro['saldo_online'], 'Online-Saldo bleibt leer bis zum ersten Bankabruf');
|
||
|
||
// Wird die Gruppe gelöscht, bleiben die Konten erhalten.
|
||
Kontengruppe::loeschen($anlage);
|
||
$danach = Konto::uebersicht();
|
||
$this->assertEquals(1, count($danach['gruppen']), 'Nach dem Löschen bleibt nur „Nicht gruppiert“');
|
||
$this->assertEquals($summeErwartet, $danach['summe'], 'Die Summe ändert sich dadurch nicht');
|
||
}
|
||
|
||
private function betragsEingabe(): void
|
||
{
|
||
$this->assertEquals(1234.56, KontenController::zahl('1.234,56'), 'Deutsches Format mit Tausenderpunkt');
|
||
$this->assertEquals(-320.25, KontenController::zahl('-320,25'), 'Negativer Betrag');
|
||
$this->assertEquals(1234.56, KontenController::zahl('1234.56'), 'Punkt vor zwei Ziffern ist Dezimaltrenner');
|
||
$this->assertEquals(1500.0, KontenController::zahl('1.500'), 'Punkt vor drei Ziffern ist Tausendertrenner');
|
||
$this->assertEquals(1234567.0, KontenController::zahl('1.234.567'), 'Mehrere Punkte sind Tausendertrenner');
|
||
$this->assertEquals(0.5, KontenController::zahl('0.5'), 'Einzelne Nachkommastelle');
|
||
$this->assertEquals(0.0, KontenController::zahl(''), 'Leere Eingabe ergibt null');
|
||
$this->assertEquals(99.9, KontenController::zahl(' 99,90 € '), 'Währungszeichen und Leerzeichen stören nicht');
|
||
}
|
||
|
||
/**
|
||
* Ein nicht angehaktes Ankreuzfeld schickt der Browser gar nicht mit. Die
|
||
* Formularabbildung muss daraus ein ausdrückliches Nein machen, sonst lässt
|
||
* sich das Kennzeichen nie wieder abschalten – „Konto gespeichert“ steht
|
||
* dann da, geändert hat sich nichts.
|
||
*/
|
||
private function ankreuzfelderAusDemFormular(): void
|
||
{
|
||
$angehakt = KontenController::formulardaten([
|
||
'name' => 'Girokonto', 'kontoart' => 'bank', 'aktiv' => '1', 'in_summe' => '1',
|
||
]);
|
||
$this->assert($angehakt['aktiv'] === true, 'Angehaktes „aktiv“ kommt als Ja an');
|
||
$this->assert($angehakt['in_summe'] === true, 'Angehaktes Σ-Kennzeichen kommt als Ja an');
|
||
|
||
$leer = KontenController::formulardaten(['name' => 'Girokonto', 'kontoart' => 'bank']);
|
||
$this->assert($leer['aktiv'] === false, 'Fehlendes „aktiv“ heißt inaktiv');
|
||
$this->assert($leer['in_summe'] === false, 'Fehlendes Σ-Kennzeichen heißt: zählt nicht zur Summe');
|
||
|
||
// Und derselbe Weg durch das Model hindurch: Abhaken muss ankommen.
|
||
$id = Konto::anlegen(KontenController::formulardaten([
|
||
'name' => 'Auslagen Eltern', 'kontoart' => 'sonstiges',
|
||
'anfangssaldo' => '1.500,00', 'aktiv' => '1', 'in_summe' => '1',
|
||
]));
|
||
$this->assertEquals(1, (int)Konto::find($id)['in_summe'], 'Neues Konto zählt zunächst mit');
|
||
$this->assertEquals(1500.0, (float)Konto::find($id)['anfangssaldo'], 'Anfangssaldo aus dem Formular');
|
||
|
||
Konto::speichern($id, KontenController::formulardaten([
|
||
'name' => 'Auslagen Eltern', 'kontoart' => 'sonstiges',
|
||
'anfangssaldo' => '1.500,00', 'aktiv' => '1',
|
||
]));
|
||
$this->assertEquals(0, (int)Konto::find($id)['in_summe'], 'Σ-Kennzeichen lässt sich entfernen');
|
||
|
||
$uebersicht = Konto::uebersicht();
|
||
$gefunden = false;
|
||
foreach ($uebersicht['gruppen'] as $gruppe) {
|
||
foreach ($gruppe['konten'] as $konto) {
|
||
if ((int)$konto['id'] === $id) {
|
||
$gefunden = true;
|
||
}
|
||
}
|
||
}
|
||
$this->assert($gefunden, 'Das Konto bleibt in der Übersicht sichtbar');
|
||
|
||
// Der QIF-Import gibt das Feld nicht an – dort gilt weiter der Standard.
|
||
$importiert = Konto::anlegen(['name' => 'Aus dem Export', 'kontoart' => 'bank', 'aktiv' => 1]);
|
||
$this->assertEquals(1, (int)Konto::find($importiert)['in_summe'], 'Ohne Angabe zählt ein Konto mit');
|
||
}
|
||
}
|