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>
616 lines
26 KiB
PHP
616 lines
26 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Adresse;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Buchungsvorlage;
|
||
use Finanzmanager\Models\Erinnerung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Kredit;
|
||
use Finanzmanager\Models\RegelmaessigeBuchung;
|
||
use Finanzmanager\Models\Sparziel;
|
||
use Finanzmanager\Models\Vertrag;
|
||
use Finanzmanager\Models\Zahlungsauftrag;
|
||
use Finanzmanager\Services\Faelligkeitslauf;
|
||
use Finanzmanager\Services\Finanzkalender;
|
||
use Finanzmanager\Services\QifParser;
|
||
use Finanzmanager\Services\QifVorlagenImport;
|
||
use Finanzmanager\Services\SepaXml;
|
||
|
||
/**
|
||
* Die Nebenmodule aus Phase 7: Vorlagen, regelmäßige Buchungen mit
|
||
* Fälligkeitslauf, Erinnerungen, Adressen mit IBAN-Prüfung, Verträge,
|
||
* Sparziele, Kredite mit Tilgungsplan und die Zahlungsaufträge.
|
||
*/
|
||
class NebenmoduleTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 18: Nebenmodule und Zahlungsverkehr';
|
||
|
||
private const HEUTE = '2026-08-11';
|
||
|
||
private int $giro = 0;
|
||
private int $sparkonto = 0;
|
||
private int $miete = 0;
|
||
private int $strom = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->vorlageBuchtMitAbweichendemBetrag();
|
||
$this->vorlageAusBuchung();
|
||
$this->qifVorlagenImport();
|
||
$this->regelmaessigeBuchungRuecktVor();
|
||
$this->faelligkeitslaufBuchtNurAutomatische();
|
||
$this->erinnerungenWerdenFaellig();
|
||
$this->ibanPruefungFaengtZahlendreher();
|
||
$this->vertragKenntSeineFrist();
|
||
$this->sparzielRechnetAusDemKonto();
|
||
$this->tilgungsplanTilgtBisNull();
|
||
$this->zahlungsauftragBuchtErstNachDerBank();
|
||
$this->sepaXmlIstVollstaendig();
|
||
$this->kalenderSammeltAlleQuellen();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Nebengiro', 'kontoart' => 'bank', 'anfangssaldo' => 5000,
|
||
'aktiv' => 1, 'iban' => 'DE02120300000000202051',
|
||
]);
|
||
$this->sparkonto = Konto::anlegen([
|
||
'name' => 'Nebensparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 1000, 'aktiv' => 1,
|
||
]);
|
||
|
||
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
|
||
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
|
||
}
|
||
|
||
private function vorlageBuchtMitAbweichendemBetrag(): void
|
||
{
|
||
$id = Buchungsvorlage::anlegen([
|
||
'name' => 'Stadtwerke Abschlag',
|
||
'konto_id' => $this->giro,
|
||
'betrag' => -100.00,
|
||
'empfaenger' => 'Stadtwerke',
|
||
'splits' => [
|
||
['kategorie_id' => $this->strom, 'betrag' => -70.00],
|
||
['kategorie_id' => $this->miete, 'betrag' => -30.00],
|
||
],
|
||
]);
|
||
|
||
// Zum Vorlagenbetrag: Die Splitzeilen bleiben, wie sie sind.
|
||
$buchungId = Buchungsvorlage::buchen($id, ['datum' => '2026-08-01']);
|
||
$splits = Buchung::splits($buchungId);
|
||
|
||
$this->assertEquals(2, count($splits), 'Zwei Splitzeilen übernommen');
|
||
$this->assertEquals(-70.0, (float)$splits[0]['betrag'], 'Erste Zeile unverändert');
|
||
|
||
// Mit abweichendem Betrag: Die Zeilen werden im Verhältnis mitgezogen,
|
||
// sonst passte die Splitsumme nicht mehr zum Buchungsbetrag.
|
||
$zweite = Buchungsvorlage::buchen($id, ['datum' => '2026-08-02', 'betrag' => -150.00]);
|
||
$splits = Buchung::splits($zweite);
|
||
|
||
$this->assertEquals(-105.0, (float)$splits[0]['betrag'], 'Erste Zeile anteilig erhöht');
|
||
$this->assertEquals(-45.0, (float)$splits[1]['betrag'], 'Zweite Zeile ebenso');
|
||
$this->assertEquals(
|
||
-150.0,
|
||
round((float)$splits[0]['betrag'] + (float)$splits[1]['betrag'], 2),
|
||
'Und zusammen ergeben sie den Buchungsbetrag'
|
||
);
|
||
|
||
// Krumme Verhältnisse: Die letzte Zeile bekommt den Rest.
|
||
$dritte = Buchungsvorlage::buchen($id, ['datum' => '2026-08-03', 'betrag' => -33.33]);
|
||
$splits = Buchung::splits($dritte);
|
||
$this->assertEquals(
|
||
-33.33,
|
||
round((float)$splits[0]['betrag'] + (float)$splits[1]['betrag'], 2),
|
||
'Auch bei krummen Beträgen stimmt die Summe auf den Cent'
|
||
);
|
||
|
||
$this->assertEquals(3, (int)Buchungsvorlage::find($id)['verwendet'], 'Der Zähler wurde geführt');
|
||
}
|
||
|
||
private function vorlageAusBuchung(): void
|
||
{
|
||
$buchungId = Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-07-15', 'betrag' => -49.90,
|
||
'empfaenger' => 'Fitnessstudio', 'kategorie_id' => $this->miete,
|
||
]);
|
||
|
||
$vorlageId = Buchungsvorlage::ausBuchung($buchungId, 'Fitnessstudio Beitrag');
|
||
$vorlage = Buchungsvorlage::find($vorlageId);
|
||
|
||
$this->assertEquals(-49.90, (float)$vorlage['betrag'], 'Der Betrag kam mit');
|
||
$this->assertEquals('Fitnessstudio', $vorlage['empfaenger'], 'Der Empfänger auch');
|
||
$this->assertEquals($this->miete, (int)$vorlage['kategorie_id'], 'Und die Kategorie');
|
||
}
|
||
|
||
private function qifVorlagenImport(): void
|
||
{
|
||
$qif = "!Option:MDY\r\n!Type:Memorized\r\n"
|
||
. "KP\r\nU-11.99\r\nT-11.99\r\nP00455 MCDONALDS\r\nMFilialbesuch\r\n"
|
||
. "L300 - Wohnen:Strom\r\n^\r\n"
|
||
. "KP\r\nU-1,000.00\r\nT-1,000.00\r\nP.Deutsche.Bank.AG\r\nMVISA Debitkartenumsatz\r\n"
|
||
. "L[Nebensparbuch]\r\n^\r\n";
|
||
|
||
$ergebnis = (new QifVorlagenImport(new QifParser($qif)))->ausfuehren();
|
||
|
||
$this->assertEquals(2, $ergebnis['neu'], 'Zwei Vorlagen übernommen');
|
||
|
||
$vorlagen = Buchungsvorlage::alle('MCDONALDS');
|
||
$this->assertEquals(1, count($vorlagen), 'Die erste ist wiederzufinden');
|
||
$this->assertEquals(-11.99, (float)$vorlagen[0]['betrag'], 'Mit ihrem Betrag');
|
||
$this->assertEquals($this->strom, (int)$vorlagen[0]['kategorie_id'], 'Und aufgelöster Kategorie');
|
||
|
||
// Ein Umbuchungsziel wird zum Gegenkonto, nicht zur Kategorie.
|
||
$bank = Buchungsvorlage::alle('Deutsche.Bank');
|
||
$this->assertEquals($this->sparkonto, (int)$bank[0]['gegenkonto_id'], 'L[Konto] wird zum Gegenkonto');
|
||
$this->assertNull($bank[0]['kategorie_id'], 'Und nicht zur Kategorie');
|
||
|
||
// Ein zweiter Lauf legt nichts doppelt an.
|
||
$zweiter = (new QifVorlagenImport(new QifParser($qif)))->ausfuehren();
|
||
$this->assertEquals(0, $zweiter['neu'], 'Der zweite Import legt nichts an');
|
||
$this->assertEquals(2, $zweiter['vorhanden'], 'Beide gelten als vorhanden');
|
||
}
|
||
|
||
private function regelmaessigeBuchungRuecktVor(): void
|
||
{
|
||
$this->assertEquals(
|
||
'2026-09-15',
|
||
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'monatlich'),
|
||
'Monatlich springt einen Monat weiter'
|
||
);
|
||
$this->assertEquals(
|
||
'2026-10-15',
|
||
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'monatlich', 2),
|
||
'Der Faktor überspringt Intervalle'
|
||
);
|
||
$this->assertEquals(
|
||
'2027-08-15',
|
||
RegelmaessigeBuchung::naechsterTermin('2026-08-15', 'jaehrlich'),
|
||
'Jährlich springt ein Jahr'
|
||
);
|
||
|
||
$id = RegelmaessigeBuchung::anlegen([
|
||
'name' => 'Testmiete', 'konto_id' => $this->giro, 'betrag' => -850,
|
||
'empfaenger' => 'Vermieter', 'kategorie_id' => $this->miete,
|
||
'intervall' => 'monatlich', 'beginn' => '2026-06-01', 'aktiv' => 1,
|
||
]);
|
||
|
||
$this->assertEquals(
|
||
'2026-06-01',
|
||
(string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'],
|
||
'Der erste Termin ist der Beginn'
|
||
);
|
||
|
||
$buchungId = RegelmaessigeBuchung::buchen($id);
|
||
$buchung = Buchung::find($buchungId);
|
||
|
||
$this->assertEquals('2026-06-01', (string)$buchung['datum'], 'Gebucht wird zum Termin');
|
||
$this->assertEquals(-850.0, (float)$buchung['betrag'], 'Mit dem Betrag der Reihe');
|
||
|
||
// Nach dem Buchen steht der nächste Termin in der Zukunft – auch wenn
|
||
// mehrere Termine zurücklagen, sonst käme derselbe immer wieder.
|
||
$termin = (string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'];
|
||
$this->assert($termin > date('Y-m-d'), 'Der nächste Termin liegt in der Zukunft');
|
||
|
||
// Überspringen bucht nicht, rückt aber vor.
|
||
$vorher = Buchung::anzahl();
|
||
RegelmaessigeBuchung::ueberspringen($id);
|
||
$this->assertEquals($vorher, Buchung::anzahl(), 'Überspringen bucht nichts');
|
||
$this->assert(
|
||
(string)RegelmaessigeBuchung::find($id)['naechste_faelligkeit'] > $termin,
|
||
'Aber der Termin rückt weiter'
|
||
);
|
||
|
||
// Eine Reihe mit Ende endet auch.
|
||
$befristet = RegelmaessigeBuchung::anlegen([
|
||
'name' => 'Befristete Rate', 'konto_id' => $this->giro, 'betrag' => -50,
|
||
'intervall' => 'monatlich', 'beginn' => '2026-07-01', 'ende' => '2026-07-31', 'aktiv' => 1,
|
||
]);
|
||
RegelmaessigeBuchung::buchen($befristet);
|
||
$this->assertEquals(
|
||
0,
|
||
(int)RegelmaessigeBuchung::find($befristet)['aktiv'],
|
||
'Nach dem letzten Termin schaltet sich die Reihe ab'
|
||
);
|
||
}
|
||
|
||
private function faelligkeitslaufBuchtNurAutomatische(): void
|
||
{
|
||
$automatisch = RegelmaessigeBuchung::anlegen([
|
||
'name' => 'Automatikbeitrag', 'konto_id' => $this->giro, 'betrag' => -20,
|
||
'intervall' => 'monatlich', 'beginn' => '2026-08-01',
|
||
'automatisch' => 1, 'aktiv' => 1,
|
||
]);
|
||
$manuell = RegelmaessigeBuchung::anlegen([
|
||
'name' => 'Stromabschlag', 'konto_id' => $this->giro, 'betrag' => -95,
|
||
'intervall' => 'monatlich', 'beginn' => '2026-08-01',
|
||
'automatisch' => 0, 'aktiv' => 1,
|
||
]);
|
||
|
||
$vorschau = Faelligkeitslauf::vorschau();
|
||
$this->assertGreaterThan(0, $vorschau['faellig'], 'Es ist etwas fällig');
|
||
|
||
$vorher = Buchung::anzahl();
|
||
$ergebnis = Faelligkeitslauf::ausfuehren();
|
||
|
||
$this->assertGreaterThan(0, $ergebnis['gebucht'], 'Die automatische Reihe wurde gebucht');
|
||
$this->assertGreaterThan($vorher - 1, Buchung::anzahl(), 'Es gibt mehr Buchungen');
|
||
|
||
$namen = array_column($ergebnis['wartend'], 'name');
|
||
$this->assert(in_array('Stromabschlag', $namen, true), 'Die manuelle Reihe wartet auf Bestätigung');
|
||
|
||
// Ihr Termin ist unverändert – sie wurde ja nicht gebucht.
|
||
$this->assertEquals(
|
||
'2026-08-01',
|
||
(string)RegelmaessigeBuchung::find($manuell)['naechste_faelligkeit'],
|
||
'Die wartende Reihe rückt nicht vor'
|
||
);
|
||
$this->assert(
|
||
(string)RegelmaessigeBuchung::find($automatisch)['naechste_faelligkeit'] > date('Y-m-d'),
|
||
'Die automatische schon'
|
||
);
|
||
}
|
||
|
||
private function erinnerungenWerdenFaellig(): void
|
||
{
|
||
$id = Erinnerung::anlegen('Versicherung kündigen', '2026-08-01');
|
||
|
||
$this->assertEquals(1, count(Erinnerung::faellige(self::HEUTE)), 'Eine Erinnerung ist fällig');
|
||
$this->assertEquals(
|
||
0,
|
||
count(Erinnerung::faellige('2026-07-01')),
|
||
'Vor ihrem Datum noch nicht'
|
||
);
|
||
|
||
Erinnerung::erledigen($id);
|
||
$this->assertEquals(0, count(Erinnerung::faellige(self::HEUTE)), 'Erledigte sind nicht mehr fällig');
|
||
$this->assertEquals(0, Erinnerung::anzahlOffen(), 'Und zählen nicht mehr als offen');
|
||
|
||
Erinnerung::erledigen($id, false);
|
||
$this->assertEquals(1, Erinnerung::anzahlOffen(), 'Zurückgeholt zählt sie wieder');
|
||
}
|
||
|
||
private function ibanPruefungFaengtZahlendreher(): void
|
||
{
|
||
$this->assert(Adresse::ibanIstGueltig('DE02120300000000202051'), 'Eine gültige IBAN wird angenommen');
|
||
$this->assert(Adresse::ibanIstGueltig('DE02 1203 0000 0000 2020 51'), 'Auch mit Leerzeichen');
|
||
$this->assert(!Adresse::ibanIstGueltig('DE02120300000000202052'), 'Eine falsche Prüfziffer fällt auf');
|
||
$this->assert(!Adresse::ibanIstGueltig('DE0212030000000020205'), 'Zu kurz ist auch falsch');
|
||
$this->assert(!Adresse::ibanIstGueltig('XX02120300000000202051'), 'Unbekanntes Land ohne Prüfziffer');
|
||
$this->assert(!Adresse::ibanIstGueltig(''), 'Leer ist nicht gültig');
|
||
|
||
$this->assertEquals(
|
||
'DE02 1203 0000 0000 2020 51',
|
||
Adresse::ibanFormatiert('DE02120300000000202051'),
|
||
'Formatiert in Vierergruppen'
|
||
);
|
||
|
||
$id = Adresse::anlegen([
|
||
'name' => 'Stadtwerke Musterstadt',
|
||
'iban' => 'DE02 1203 0000 0000 2020 51',
|
||
'ort' => 'Musterstadt',
|
||
]);
|
||
$this->assertEquals(
|
||
'DE02120300000000202051',
|
||
Adresse::find($id)['iban'],
|
||
'Gespeichert wird ohne Leerzeichen'
|
||
);
|
||
|
||
$fehler = false;
|
||
try {
|
||
Adresse::anlegen(['name' => 'Falsche IBAN', 'iban' => 'DE02120300000000202052']);
|
||
} catch (\InvalidArgumentException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Eine ungültige IBAN wird abgewiesen');
|
||
}
|
||
|
||
private function vertragKenntSeineFrist(): void
|
||
{
|
||
$id = Vertrag::anlegen([
|
||
'name' => 'Hausratversicherung',
|
||
'partner' => 'Versicherung AG',
|
||
'betrag' => 12.50,
|
||
'intervall' => 'monatlich',
|
||
'beginn' => '2025-01-01',
|
||
'ende' => '2026-09-30',
|
||
'kuendigungsfrist_tage' => 90,
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$vertrag = Vertrag::find($id);
|
||
|
||
$this->assertEquals(150.0, Vertrag::jahresbetrag($vertrag), 'Monatlich mal zwölf');
|
||
$this->assertEquals(
|
||
'2026-07-02',
|
||
Vertrag::kuendigenBis($vertrag),
|
||
'Kündigungstermin ist Ende minus Frist'
|
||
);
|
||
|
||
// Ohne Ende oder ohne Frist gibt es keinen Termin.
|
||
$this->assertNull(
|
||
Vertrag::kuendigenBis(['ende' => null, 'kuendigungsfrist_tage' => 90]),
|
||
'Ohne Vertragsende kein Kündigungstermin'
|
||
);
|
||
|
||
// Vierteljährlich gerechnet ergibt denselben Jahresbetrag bei viermal
|
||
// dem Betrag.
|
||
$this->assertEquals(
|
||
50.0,
|
||
Vertrag::jahresbetrag(['betrag' => 12.50, 'intervall' => 'vierteljaehrlich']),
|
||
'Vierteljährlich mal vier'
|
||
);
|
||
|
||
// Die Fristenliste greift nur innerhalb des Vorlaufs.
|
||
$fristen = Vertrag::fristenLaufenAb(365, '2026-06-01');
|
||
$this->assertEquals(1, count($fristen), 'Innerhalb eines Jahres läuft die Frist ab');
|
||
$this->assertEquals(
|
||
[],
|
||
Vertrag::fristenLaufenAb(10, '2026-06-01'),
|
||
'In den nächsten zehn Tagen nicht'
|
||
);
|
||
$this->assertEquals(
|
||
[],
|
||
Vertrag::fristenLaufenAb(365, '2026-08-01'),
|
||
'Und ein verpasster Termin taucht nicht mehr auf'
|
||
);
|
||
}
|
||
|
||
private function sparzielRechnetAusDemKonto(): void
|
||
{
|
||
$id = Sparziel::anlegen([
|
||
'name' => 'Neues Auto',
|
||
'konto_id' => $this->sparkonto,
|
||
'zielbetrag' => 5000,
|
||
'startbetrag' => 1000,
|
||
'zieldatum' => '2027-08-01',
|
||
]);
|
||
|
||
// Das Sparbuch trägt 1000 Anfangssaldo; der Startbetrag zieht sie ab.
|
||
$ziel = Sparziel::mitStand(Sparziel::find($id), self::HEUTE);
|
||
$this->assertEquals(0.0, $ziel['stand'], 'Zu Beginn ist nichts gespart');
|
||
|
||
Buchung::anlegen([
|
||
'konto_id' => $this->sparkonto, 'datum' => '2026-07-01', 'betrag' => 1500,
|
||
'empfaenger' => 'Sparrate',
|
||
]);
|
||
|
||
$ziel = Sparziel::mitStand(Sparziel::find($id), self::HEUTE);
|
||
$this->assertEquals(1500.0, $ziel['stand'], 'Der Stand kommt aus dem Konto');
|
||
$this->assertEquals(3500.0, $ziel['fehlt'], 'Es fehlen 3500');
|
||
$this->assertEquals(0.3, round($ziel['fortschritt'], 2), 'Das sind 30 Prozent');
|
||
$this->assertNotEmpty($ziel['noetige_rate'], 'Und es gibt eine nötige Monatsrate');
|
||
|
||
// Zwölf Monate bis zum Ziel: 3500 / 12.
|
||
$this->assertEquals(291.67, $ziel['noetige_rate'], 'Rate, um den Termin zu halten');
|
||
}
|
||
|
||
private function tilgungsplanTilgtBisNull(): void
|
||
{
|
||
$id = Kredit::anlegen([
|
||
'name' => 'Testdarlehen',
|
||
'darlehensbetrag' => 100000,
|
||
'zinssatz' => 3.0,
|
||
'rate' => 500,
|
||
'rhythmus' => 'monatlich',
|
||
'beginn' => '2026-01-01',
|
||
'zinsbindung_bis' => '2036-01-01',
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$plan = Kredit::tilgungsplan(Kredit::find($id));
|
||
|
||
$this->assert($plan['tilgt'], 'Der Kredit tilgt');
|
||
$this->assertGreaterThan(0, count($plan['zeilen']), 'Der Plan hat Zeilen');
|
||
|
||
$erste = $plan['zeilen'][0];
|
||
// 100000 × 3 % / 12 = 250 Zinsen, Rest tilgt.
|
||
$this->assertEquals(250.0, $erste['zinsen'], 'Erste Rate: 250 Zinsen');
|
||
$this->assertEquals(250.0, $erste['tilgung'], 'Und 250 Tilgung');
|
||
$this->assertEquals(99750.0, $erste['restschuld'], 'Restschuld sinkt entsprechend');
|
||
|
||
$letzte = $plan['zeilen'][count($plan['zeilen']) - 1];
|
||
$this->assertEquals(0.0, $letzte['restschuld'], 'Am Ende ist die Restschuld null');
|
||
$this->assert($letzte['zahlung'] <= 500.0, 'Die letzte Rate ist höchstens eine volle');
|
||
|
||
// Der Zinsanteil sinkt, der Tilgungsanteil wächst – das Wesen der Annuität.
|
||
$this->assert(
|
||
$plan['zeilen'][12]['zinsen'] < $erste['zinsen'],
|
||
'Nach einem Jahr sind die Zinsen niedriger'
|
||
);
|
||
$this->assert(
|
||
$plan['zeilen'][12]['tilgung'] > $erste['tilgung'],
|
||
'Und der Tilgungsanteil höher'
|
||
);
|
||
|
||
$this->assertNotEmpty($plan['restschuld_zinsbindung'], 'Die Restschuld zum Bindungsende steht dabei');
|
||
|
||
// Restschuld zu einem Stichtag.
|
||
$this->assertEquals(
|
||
99750.0,
|
||
Kredit::restschuld(Kredit::find($id), '2026-01-31'),
|
||
'Nach der ersten Rate'
|
||
);
|
||
$this->assertEquals(
|
||
100000.0,
|
||
Kredit::restschuld(Kredit::find($id), '2025-12-31'),
|
||
'Vor der ersten Rate steht das volle Darlehen'
|
||
);
|
||
|
||
// Eine Rate unter den Zinsen tilgt nie – das muss auffallen, statt in
|
||
// eine Endlosschleife zu laufen.
|
||
$schlecht = Kredit::anlegen([
|
||
'name' => 'Zu kleine Rate', 'darlehensbetrag' => 100000,
|
||
'zinssatz' => 5.0, 'rate' => 100, 'beginn' => '2026-01-01',
|
||
]);
|
||
$planB = Kredit::tilgungsplan(Kredit::find($schlecht));
|
||
$this->assert(!$planB['tilgt'], 'Eine zu kleine Rate tilgt nicht');
|
||
$this->assertEquals([], $planB['zeilen'], 'Und erzeugt keinen Plan');
|
||
|
||
// Sondertilgung verkürzt die Laufzeit.
|
||
$mitSonder = Kredit::anlegen([
|
||
'name' => 'Mit Sondertilgung', 'darlehensbetrag' => 100000,
|
||
'zinssatz' => 3.0, 'rate' => 500, 'beginn' => '2026-01-01',
|
||
'sondertilgung' => 5000,
|
||
]);
|
||
$planC = Kredit::tilgungsplan(Kredit::find($mitSonder));
|
||
$this->assert(
|
||
count($planC['zeilen']) < count($plan['zeilen']),
|
||
'Mit Sondertilgung ist der Kredit früher abbezahlt'
|
||
);
|
||
$this->assert($planC['zinsen'] < $plan['zinsen'], 'Und kostet weniger Zinsen');
|
||
}
|
||
|
||
private function zahlungsauftragBuchtErstNachDerBank(): void
|
||
{
|
||
$id = Zahlungsauftrag::anlegen([
|
||
'konto_id' => $this->giro,
|
||
'empfaenger' => 'Stadtwerke Musterstadt',
|
||
'iban' => 'DE02120300000000202051',
|
||
'betrag' => 84.20,
|
||
'verwendungszweck' => 'Abschlag Strom 08/2026',
|
||
'kategorie_id' => $this->strom,
|
||
]);
|
||
|
||
$auftrag = Zahlungsauftrag::find($id);
|
||
$this->assertEquals('entwurf', $auftrag['status'], 'Ein neuer Auftrag ist ein Entwurf');
|
||
$this->assertNull($auftrag['buchung_id'], 'Und hat noch keine Buchung');
|
||
|
||
$vorher = Buchung::anzahl();
|
||
Zahlungsauftrag::alsGesendetVermerken($id, 'Von der Bank angenommen.');
|
||
|
||
$auftrag = Zahlungsauftrag::find($id);
|
||
$this->assertEquals('gesendet', $auftrag['status'], 'Danach gilt er als gesendet');
|
||
$this->assertEquals($vorher + 1, Buchung::anzahl(), 'Erst jetzt entsteht die Buchung');
|
||
|
||
$buchung = Buchung::find((int)$auftrag['buchung_id']);
|
||
$this->assertEquals(-84.20, (float)$buchung['betrag'], 'Eine Überweisung geht ab');
|
||
$this->assertEquals($this->strom, (int)$buchung['kategorie_id'], 'Die Kategorie kam mit');
|
||
|
||
// Ein gesendeter Auftrag ist unveränderlich.
|
||
$fehler = false;
|
||
try {
|
||
Zahlungsauftrag::speichern($id, [
|
||
'konto_id' => $this->giro, 'empfaenger' => 'Anders',
|
||
'iban' => 'DE02120300000000202051', 'betrag' => 1,
|
||
]);
|
||
} catch (\RuntimeException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Ein gesendeter Auftrag lässt sich nicht ändern');
|
||
|
||
$fehler = false;
|
||
try {
|
||
Zahlungsauftrag::loeschen($id);
|
||
} catch (\RuntimeException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Und nicht löschen – er bleibt als Beleg');
|
||
|
||
// Ungültige Eingaben werden schon beim Erfassen abgewiesen.
|
||
foreach ([
|
||
['iban' => 'DE00000000000000000000', 'grund' => 'ungültige IBAN'],
|
||
['betrag' => 0, 'grund' => 'Betrag null'],
|
||
['ausfuehrung_am' => '2020-01-01', 'grund' => 'Termin in der Vergangenheit'],
|
||
] as $fall) {
|
||
$daten = [
|
||
'konto_id' => $this->giro,
|
||
'empfaenger' => 'Test',
|
||
'iban' => 'DE02120300000000202051',
|
||
'betrag' => 10.0,
|
||
];
|
||
unset($fall['grund']);
|
||
$daten = array_merge($daten, $fall);
|
||
|
||
$abgewiesen = false;
|
||
try {
|
||
Zahlungsauftrag::anlegen($daten);
|
||
} catch (\InvalidArgumentException) {
|
||
$abgewiesen = true;
|
||
}
|
||
$this->assert($abgewiesen, 'Abgewiesen: ' . json_encode($fall, JSON_UNESCAPED_UNICODE));
|
||
}
|
||
}
|
||
|
||
private function sepaXmlIstVollstaendig(): void
|
||
{
|
||
$xml = SepaXml::ueberweisung([
|
||
'absender_name' => 'Max Mustermann',
|
||
'absender_iban' => 'DE02120300000000202051',
|
||
'empfaenger_name' => 'Stadtwerke Größenwahn',
|
||
'empfaenger_iban' => 'DE02500105170137075030',
|
||
'betrag' => 84.20,
|
||
'verwendungszweck' => 'Abschlag Strom & Wasser 08/2026',
|
||
]);
|
||
|
||
$this->assert(str_contains($xml, 'pain.001.001.03'), 'Das erwartete Schema');
|
||
$this->assert(str_contains($xml, '<InstdAmt Ccy="EUR">84.20</InstdAmt>'), 'Betrag mit Währung');
|
||
$this->assert(str_contains($xml, 'DE02500105170137075030'), 'Empfänger-IBAN steht drin');
|
||
$this->assert(str_contains($xml, 'NOTPROVIDED'), 'Ohne BIC steht die Ersatzkennung');
|
||
|
||
// SEPA lässt keine Umlaute zu – sie werden umgeschrieben, nicht entfernt.
|
||
$this->assert(str_contains($xml, 'Stadtwerke Groessenwahn'), 'Umlaute werden umgeschrieben');
|
||
$this->assert(!str_contains($xml, 'ö'), 'Und stehen nirgends mehr im Dokument');
|
||
|
||
// Das kaufmännische Und gehört nicht zum SEPA-Zeichenvorrat und fällt
|
||
// weg – die Bank wiese den Auftrag sonst zurück, und zwar erst nach dem
|
||
// Absenden.
|
||
$this->assert(!str_contains($xml, '&'), 'Das kaufmännische Und fällt weg');
|
||
$this->assert(
|
||
str_contains($xml, 'Abschlag Strom Wasser 08/2026'),
|
||
'Der Rest des Verwendungszwecks bleibt erhalten'
|
||
);
|
||
$this->assertNotEmpty(simplexml_load_string($xml), 'Das Dokument ist wohlgeformtes XML');
|
||
|
||
$this->assertEquals('Test AG', SepaXml::text('Test AG', 70), 'Erlaubte Zeichen bleiben');
|
||
$this->assertEquals('Muellers Laden', SepaXml::text('Müllers Laden', 70), 'ü wird zu ue');
|
||
$this->assertEquals('Kurz', SepaXml::text('Kurz und knapp', 4), 'Und gekürzt wird auch');
|
||
|
||
$fehler = false;
|
||
try {
|
||
SepaXml::ueberweisung([
|
||
'absender_name' => 'A', 'absender_iban' => 'DE02120300000000202051',
|
||
'empfaenger_name' => 'B', 'empfaenger_iban' => 'DE02500105170137075030',
|
||
'betrag' => 0,
|
||
]);
|
||
} catch (\InvalidArgumentException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Ein Betrag von null wird abgewiesen');
|
||
}
|
||
|
||
private function kalenderSammeltAlleQuellen(): void
|
||
{
|
||
$termine = Finanzkalender::termine(90, self::HEUTE);
|
||
|
||
$arten = array_unique(array_column($termine, 'art'));
|
||
$this->assertGreaterThan(1, count($arten), 'Der Kalender führt mehrere Arten zusammen');
|
||
|
||
// Chronologisch sortiert.
|
||
$daten = array_column($termine, 'datum');
|
||
$sortiert = $daten;
|
||
sort($sortiert);
|
||
$this->assertEquals($sortiert, $daten, 'Die Termine stehen chronologisch');
|
||
|
||
foreach ($termine as $termin) {
|
||
$this->assertNotEmpty($termin['titel'], 'Jeder Termin hat einen Titel');
|
||
$this->assertNotEmpty($termin['pfad'], 'Und führt irgendwohin');
|
||
}
|
||
|
||
$monate = Finanzkalender::nachMonat(90, self::HEUTE);
|
||
$this->assertGreaterThan(0, count($monate), 'Nach Monaten gebündelt');
|
||
|
||
$gesamt = 0;
|
||
foreach ($monate as $monat) {
|
||
$this->assertNotEmpty($monat['titel'], 'Jeder Monat hat eine Überschrift');
|
||
$gesamt += count($monat['termine']);
|
||
}
|
||
$this->assertEquals(count($termine), $gesamt, 'Die Bündelung verliert keinen Termin');
|
||
}
|
||
}
|