313 lines
13 KiB
PHP
313 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\Vermoegensentwicklung;
|
||
|
||
class VermoegensentwicklungTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 13: Vermögensentwicklung';
|
||
|
||
/** Ein 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 $kreditkarte = 0;
|
||
private int $auslagen = 0;
|
||
private int $kategorie = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->zwoelfMonateBisHeute();
|
||
$this->staendeWachsenMonatFuerMonat();
|
||
$this->umbuchungVerschiebtZwischenKonten();
|
||
$this->summenkennzeichenFiltertNicht();
|
||
$this->schalterFiltertStillgelegteKonten();
|
||
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
|
||
$this->ebenenBlaetternDurchDieUebrigenKonten();
|
||
$this->skalaRundetAufLesbareSchritte();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Vermögensgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||
]);
|
||
$this->sparkonto = Konto::anlegen([
|
||
'name' => 'Vermögenssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->kreditkarte = Konto::anlegen([
|
||
'name' => 'Vermögenskarte', 'kontoart' => 'kreditkarte', 'anfangssaldo' => -200, 'aktiv' => 1,
|
||
]);
|
||
// Durchlaufende Auslagen sind kein Vermögen und tragen kein Σ-Kennzeichen.
|
||
$this->auslagen = Konto::anlegen([
|
||
'name' => 'Vermögensauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 5000,
|
||
'aktiv' => 1, 'in_summe' => 0,
|
||
]);
|
||
|
||
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
|
||
}
|
||
|
||
private function reihe(bool $nurAktive = true, int $ebene = 0): array
|
||
{
|
||
return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene);
|
||
}
|
||
|
||
/** @return array<string,mixed>|null */
|
||
private function konto(array $reihe, int $id): ?array
|
||
{
|
||
foreach ($reihe['konten'] as $konto) {
|
||
if ($konto['id'] === $id) {
|
||
return $konto;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function zwoelfMonateBisHeute(): void
|
||
{
|
||
$monate = Vermoegensentwicklung::monate(12, self::HEUTE);
|
||
|
||
$this->assertEquals(12, count($monate), 'Zwölf Monate');
|
||
$this->assertEquals(2025, $monate[0]['jahr'], 'Der Rückblick beginnt im Vorjahr');
|
||
$this->assertEquals(9, $monate[0]['monat'], 'Zwölf Monate vor August 2026 ist September 2025');
|
||
$this->assertEquals(2026, $monate[11]['jahr'], 'Der letzte Balken ist der laufende Monat');
|
||
$this->assertEquals(8, $monate[11]['monat'], 'August 2026');
|
||
$this->assertEquals('2026-08-31', $monate[11]['bis'], 'Stichtag ist das Monatsende');
|
||
$this->assertEquals('2026-02-28', $monate[5]['bis'], 'Auch im Februar das echte Monatsende');
|
||
$this->assertEquals('Sep', $monate[0]['kuerzel'], 'Monatskürzel für die Achse');
|
||
}
|
||
|
||
/**
|
||
* Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen –
|
||
* und Buchungen vor dem Zeitraum stecken im Anfangsstand des ersten Balkens.
|
||
*/
|
||
private function staendeWachsenMonatFuerMonat(): void
|
||
{
|
||
// Lange vor dem Zeitraum: zählt in den ersten Balken hinein.
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2024-05-10', 'betrag' => 500,
|
||
'empfaenger' => 'Altbestand', 'kategorie_id' => $this->kategorie,
|
||
]);
|
||
// Im Zeitraum: hebt den Stand ab Dezember 2025.
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2025-12-05', 'betrag' => 300,
|
||
'empfaenger' => 'Zufluss', 'kategorie_id' => $this->kategorie,
|
||
]);
|
||
// Nach dem letzten Balken: bleibt außen vor.
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-11-01', 'betrag' => 9999,
|
||
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->kategorie,
|
||
]);
|
||
|
||
$reihe = $this->reihe();
|
||
$giro = $this->konto($reihe, $this->giro);
|
||
|
||
$this->assertNotEmpty($giro, 'Das Girokonto hat einen Balken');
|
||
$this->assertEquals(1500.0, $giro['werte'][0], 'September 2025: Anfangssaldo plus Altbestand');
|
||
$this->assertEquals(1500.0, $giro['werte'][2], 'November 2025 unverändert');
|
||
$this->assertEquals(1800.0, $giro['werte'][3], 'Dezember 2025 mit dem Zufluss');
|
||
$this->assertEquals(1800.0, $giro['werte'][11], 'Der Stand trägt bis in den letzten Monat');
|
||
|
||
// Die Kreditkarte steht im Minus – sie zieht die Summe herunter.
|
||
$karte = $this->konto($reihe, $this->kreditkarte);
|
||
$this->assertEquals(-200.0, $karte['werte'][11], 'Negativer Stand bleibt negativ');
|
||
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
|
||
|
||
// Giro 1800, Kreditkarte -200, Auslagen 5000, Sparbuch 0.
|
||
$this->assertEquals(6600.0, $reihe['summen'][11], 'Die Summe ist der Stapel aller Konten');
|
||
}
|
||
|
||
private function umbuchungVerschiebtZwischenKonten(): void
|
||
{
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-03-15', 'betrag' => -400,
|
||
'empfaenger' => 'Umbuchung aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
|
||
]);
|
||
|
||
$reihe = $this->reihe();
|
||
$giro = $this->konto($reihe, $this->giro);
|
||
$spar = $this->konto($reihe, $this->sparkonto);
|
||
|
||
$this->assertEquals(1800.0, $giro['werte'][5], 'Februar 2026 noch ohne Umbuchung');
|
||
$this->assertEquals(1400.0, $giro['werte'][6], 'März 2026: 400 weniger auf dem Giro');
|
||
$this->assertEquals(400.0, $spar['werte'][6], 'Dieselben 400 liegen auf dem Sparbuch');
|
||
$this->assertEquals(
|
||
$reihe['summen'][5],
|
||
$reihe['summen'][6],
|
||
'Eine Umbuchung ändert das Vermögen nicht'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Das Σ-Kennzeichen entscheidet hier nichts: Stillgelegte Konten tragen es
|
||
* meist nicht, und dann fiele beim Ausschalten des Schalters genau das weg,
|
||
* was man sehen will.
|
||
*/
|
||
private function summenkennzeichenFiltertNicht(): void
|
||
{
|
||
$reihe = $this->reihe();
|
||
|
||
$this->assertNotEmpty(
|
||
$this->konto($reihe, $this->auslagen),
|
||
'Ein aktives Konto ohne Σ-Kennzeichen zeichnet trotzdem einen Balken'
|
||
);
|
||
|
||
// Ein Konto, das im ganzen Zeitraum auf null steht, bläht nur die Legende auf.
|
||
$leer = Konto::anlegen([
|
||
'name' => 'Vermögensleerkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->assertNull($this->konto($this->reihe(), $leer), 'Ein durchgehend leeres Konto fehlt');
|
||
}
|
||
|
||
/**
|
||
* Der Schalter „nur aktive“ an der Kachel: eingeschaltet zählt nur, was noch
|
||
* geführt wird; ausgeschaltet auch das Vermögen auf stillgelegten Konten.
|
||
*/
|
||
private function schalterFiltertStillgelegteKonten(): void
|
||
{
|
||
$stillgelegt = Konto::anlegen([
|
||
'name' => 'Vermögensaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 2500, 'aktiv' => 0,
|
||
]);
|
||
|
||
$this->assertNull(
|
||
$this->konto($this->reihe(), $stillgelegt),
|
||
'Eingeschaltet bleibt das stillgelegte Konto draußen'
|
||
);
|
||
|
||
$alle = $this->reihe(false);
|
||
$this->assertNotEmpty($this->konto($alle, $stillgelegt), 'Ausgeschaltet ist es dabei');
|
||
$this->assertEquals(
|
||
2500.0,
|
||
$this->konto($alle, $stillgelegt)['werte'][11],
|
||
'Mit seinem Stand'
|
||
);
|
||
$this->assertEquals(
|
||
round($this->reihe()['summen'][11] + 2500.0, 2),
|
||
$alle['summen'][11],
|
||
'Und die Summe wächst um genau diesen Stand'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Zwölf Farbtöne hat die Palette; darüber hinaus behalten die größten Konten
|
||
* ihre Farbe, der Rest wird zu einem Stück zusammengefasst.
|
||
*/
|
||
private function mehrKontenAlsFarbenWerdenZusammengefasst(): void
|
||
{
|
||
for ($nummer = 1; $nummer <= 15; $nummer++) {
|
||
Konto::anlegen([
|
||
'name' => 'Vermögensklein ' . $nummer,
|
||
'kontoart' => 'bank',
|
||
'anfangssaldo' => $nummer,
|
||
'aktiv' => 1,
|
||
]);
|
||
}
|
||
|
||
$reihe = $this->reihe();
|
||
$konten = $reihe['konten'];
|
||
$letzte = $konten[count($konten) - 1];
|
||
|
||
$this->assertEquals(
|
||
Vermoegensentwicklung::SERIEN,
|
||
count($konten),
|
||
'Nicht mehr Balkenstücke als Farbtöne'
|
||
);
|
||
$this->assertEquals(0, $letzte['id'], 'Das letzte Stück steht für kein einzelnes Konto');
|
||
$this->assert(str_starts_with($letzte['name'], 'Übrige Konten ('), 'Es heißt „Übrige Konten“');
|
||
|
||
// Die größten behalten ihre eigene Farbe – das Sparbuch ist dabei.
|
||
$this->assertNotEmpty($this->konto($reihe, $this->sparkonto), 'Ein großes Konto bleibt eigenständig');
|
||
|
||
// Zusammengefasst wird, nicht weggelassen: Die Summe bleibt dieselbe.
|
||
$gestapelt = 0.0;
|
||
foreach ($konten as $konto) {
|
||
$gestapelt += $konto['werte'][11];
|
||
}
|
||
$this->assertEquals(
|
||
$reihe['summen'][11],
|
||
round($gestapelt, 2),
|
||
'Die Balkenstücke ergeben zusammen die Summe'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren
|
||
* Konten einzeln, bis der Rest in die Palette passt.
|
||
*/
|
||
private function ebenenBlaetternDurchDieUebrigenKonten(): void
|
||
{
|
||
$this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene');
|
||
$this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf');
|
||
$this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen');
|
||
$this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte');
|
||
|
||
// Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat.
|
||
$erste = $this->reihe(true, 0);
|
||
$zweite = $this->reihe(true, 1);
|
||
|
||
$this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene');
|
||
$this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene');
|
||
$this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar');
|
||
|
||
$ids = static fn(array $reihe): array => array_map(
|
||
static fn(array $konto): int => $konto['id'],
|
||
$reihe['konten']
|
||
);
|
||
|
||
$this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein');
|
||
$this->assertEquals(
|
||
[],
|
||
array_intersect(array_diff($ids($erste), [0]), $ids($zweite)),
|
||
'Kein Konto steht auf zwei Ebenen'
|
||
);
|
||
|
||
// Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an.
|
||
$letzte = $this->reihe(true, $erste['ebenen'] - 1);
|
||
$this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten');
|
||
|
||
// Zusammen ergeben alle Ebenen wieder den ganzen Bestand.
|
||
$gesehen = [];
|
||
for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) {
|
||
$gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0]));
|
||
}
|
||
$this->assertEquals(
|
||
count($gesehen),
|
||
count(array_unique($gesehen)),
|
||
'Über alle Ebenen hinweg taucht kein Konto doppelt auf'
|
||
);
|
||
$this->assertGreaterThan(
|
||
Vermoegensentwicklung::SERIEN,
|
||
count($gesehen),
|
||
'Und in Summe sind es mehr Konten, als eine Ebene fasst'
|
||
);
|
||
|
||
// Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück.
|
||
$zuTief = $this->reihe(true, 99);
|
||
$this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten');
|
||
$this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten');
|
||
}
|
||
|
||
private function skalaRundetAufLesbareSchritte(): void
|
||
{
|
||
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
|
||
$this->assertEquals(0.0, $skala['min'], 'Die Null gehört immer dazu');
|
||
$this->assertGreaterThan(4869.0, $skala['max'], 'Der größte Wert passt hinein');
|
||
$this->assertEquals(0.0, fmod($skala['max'], $skala['schritt']), 'Das Maximum liegt auf einem Schritt');
|
||
|
||
$mitMinus = Vermoegensentwicklung::skala([-820.0, 2500.0]);
|
||
$this->assert($mitMinus['min'] <= -820.0, 'Auch der negative Ausschlag passt hinein');
|
||
$this->assert($mitMinus['max'] >= 2500.0, 'Und der positive');
|
||
|
||
$leer = Vermoegensentwicklung::skala([]);
|
||
$this->assertGreaterThan(0.0, $leer['schritt'], 'Ohne Werte bleibt die Skala brauchbar');
|
||
}
|
||
}
|