Finanzmanager/tests/18_NebenmoduleTest.php
fds2610-bot f388d04c05 Phasen 4 bis 7: Cockpit, Online-Banking, Wertpapiere, Nebenmodule
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>
2026-08-12 06:59:24 +02:00

616 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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, '&amp;'), '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');
}
}