Bisher entschied das erste Verfahren, das die Bank meldet – wer chipTAN nutzt, kam über eine App-Freigabe nicht weiter. Die Wahl steht jetzt auf einer eigenen Seite am Zugang. Abgefragt statt eingetippt: Die Kennungen sind bankspezifisch, und getTanModes() kommt ohne Anmeldung aus, verlangt also selbst keine TAN. Behoben: Das Speichern des Zugangsformulars setzte Verfahren und TAN-Medium zurück, weil beide Felder darin nicht vorkommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
641 lines
27 KiB
PHP
641 lines
27 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\AbgleichRegel;
|
||
use Finanzmanager\Models\Bankabruf;
|
||
use Finanzmanager\Models\Bankumsatz;
|
||
use Finanzmanager\Models\Bankzugang;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Einstellung;
|
||
use Finanzmanager\Models\Kategorie;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Services\Crypto;
|
||
use Finanzmanager\Services\FinTsVerfahren;
|
||
|
||
/**
|
||
* Online-Banking ohne Bank: Geprüft wird alles, was um den FinTS-Dialog herum
|
||
* liegt – Verschlüsselung, Zeiträume, Dublettenerkennung, Regeln und der
|
||
* Abgleich. Der Dialog selbst braucht eine echte Bankverbindung und lässt sich
|
||
* hier nicht nachstellen.
|
||
*/
|
||
class BankingTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 16: Online-Banking und Abgleich';
|
||
|
||
private int $giro = 0;
|
||
private int $sparkonto = 0;
|
||
private int $strom = 0;
|
||
private int $miete = 0;
|
||
private int $zugang = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->verschluesselungSchuetztDieZugangsdaten();
|
||
$this->zugangHaeltPinBeimSpeichern();
|
||
$this->tanVerfahrenLaesstSichWaehlen();
|
||
$this->tanVerfahrenUeberstehtDasSpeichern();
|
||
$this->zeitraumRichtetSichNachDemLetztenAbruf();
|
||
$this->umsaetzeKommenNurEinmalHerein();
|
||
$this->regelnGreifenNachPrioritaet();
|
||
$this->regelnRechnenSplitsAus();
|
||
$this->ungueltigesMusterWirdAbgewiesen();
|
||
$this->abgleichFindetVorhandeneBuchung();
|
||
$this->uebernehmenErzeugtEineBuchung();
|
||
$this->verwerfenUndZuruecksetzen();
|
||
$this->abrufprotokollHaeltDenVerlauf();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->giro = Konto::anlegen([
|
||
'name' => 'Bankinggiro', 'kontoart' => 'bank', 'anfangssaldo' => 500,
|
||
'aktiv' => 1, 'iban' => 'DE02120300000000202051',
|
||
]);
|
||
$this->sparkonto = Konto::anlegen([
|
||
'name' => 'Bankingsparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
|
||
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom', 'ausgabe');
|
||
$this->miete = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Miete', 'ausgabe');
|
||
|
||
$this->zugang = Bankzugang::anlegen([
|
||
'name' => 'Testbank',
|
||
'bankname' => 'Testbank AG',
|
||
'blz' => '12030000',
|
||
'fints_url' => 'https://banking.example.org/fints',
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => 'geheim1234',
|
||
'abrufzeitraum' => 'letzter_abruf',
|
||
'aktiv' => 1,
|
||
]);
|
||
}
|
||
|
||
/** @param array<string,mixed> $daten */
|
||
private function umsatzAnlegen(array $daten): ?int
|
||
{
|
||
return Bankumsatz::anlegen([
|
||
'zugang_id' => $this->zugang,
|
||
// array_key_exists, nicht ??: Ein ausdrückliches null steht hier für
|
||
// „kein Konto zugeordnet“ und darf nicht auf das Giro zurückfallen.
|
||
'konto_id' => array_key_exists('konto_id', $daten) ? $daten['konto_id'] : $this->giro,
|
||
'extern_id' => $daten['extern_id'] ?? Bankumsatz::externId(
|
||
'DE02120300000000202051',
|
||
(string)$daten['buchungstag'],
|
||
(float)$daten['betrag'],
|
||
(string)($daten['verwendungszweck'] ?? ''),
|
||
(string)($daten['empfaenger'] ?? '')
|
||
),
|
||
'buchungstag' => $daten['buchungstag'],
|
||
'wertstellung' => $daten['buchungstag'],
|
||
'betrag' => $daten['betrag'],
|
||
'empfaenger' => $daten['empfaenger'] ?? '',
|
||
'verwendungszweck' => $daten['verwendungszweck'] ?? '',
|
||
'buchungsart' => 'LASTSCHRIFT',
|
||
]);
|
||
}
|
||
|
||
private function verschluesselungSchuetztDieZugangsdaten(): void
|
||
{
|
||
if (!Crypto::einsatzbereit()) {
|
||
$this->assert(false, 'APP_KEY fehlt – ohne ihn lässt sich die Verschlüsselung nicht prüfen');
|
||
return;
|
||
}
|
||
|
||
$geheim = Crypto::verschluessle('meine-pin-1234');
|
||
|
||
$this->assert(Crypto::istVerschluesselt($geheim), 'Der Geheimtext trägt seine Kennung');
|
||
$this->assert(!str_contains($geheim, 'meine-pin'), 'Die PIN steht nicht im Klartext darin');
|
||
$this->assertEquals('meine-pin-1234', Crypto::entschluessle($geheim), 'Und kommt unverändert zurück');
|
||
|
||
// Zweimal dasselbe verschlüsselt ergibt zwei verschiedene Geheimtexte –
|
||
// jeder Durchgang zieht einen neuen Initialisierungsvektor.
|
||
$this->assert(
|
||
Crypto::verschluessle('meine-pin-1234') !== $geheim,
|
||
'Gleicher Klartext, anderer Geheimtext'
|
||
);
|
||
|
||
$this->assertEquals('', Crypto::verschluessle(''), 'Leeres bleibt leer');
|
||
$this->assertEquals(
|
||
'klartext-aus-altbestand',
|
||
Crypto::entschluessle('klartext-aus-altbestand'),
|
||
'Werte ohne Kennung werden durchgereicht'
|
||
);
|
||
|
||
$doppelt = Crypto::verschluessle($geheim);
|
||
$this->assertEquals($geheim, $doppelt, 'Zweimal verschlüsseln verschachtelt nicht');
|
||
}
|
||
|
||
private function zugangHaeltPinBeimSpeichern(): void
|
||
{
|
||
$roh = Bankzugang::find($this->zugang);
|
||
$this->assert(
|
||
!str_contains((string)$roh['pin'], 'geheim1234'),
|
||
'In der Datenbank steht die PIN nur verschlüsselt'
|
||
);
|
||
|
||
$mitPin = Bankzugang::zugangsdaten($this->zugang);
|
||
$this->assertEquals('geheim1234', $mitPin['pin'], 'Der Abrufdienst bekommt sie im Klartext');
|
||
|
||
// Ein leeres PIN-Feld heißt „unverändert“ – sonst verlöre jedes
|
||
// Speichern des Formulars die Zugangsdaten.
|
||
Bankzugang::speichern($this->zugang, [
|
||
'name' => 'Testbank',
|
||
'blz' => '12030000',
|
||
'fints_url' => 'https://banking.example.org/fints',
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => '',
|
||
'abrufzeitraum' => 'sieben_tage',
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$nachher = Bankzugang::zugangsdaten($this->zugang);
|
||
$this->assertEquals('geheim1234', $nachher['pin'], 'Leeres Feld behält die alte PIN');
|
||
$this->assertEquals('sieben_tage', $nachher['abrufzeitraum'], 'Der Zeitraum wurde übernommen');
|
||
|
||
$fehler = false;
|
||
try {
|
||
Bankzugang::speichern($this->zugang, [
|
||
'name' => 'Testbank', 'fints_url' => 'http://unverschluesselt.example.org',
|
||
]);
|
||
} catch (\InvalidArgumentException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Eine FinTS-Adresse ohne https wird abgewiesen');
|
||
|
||
Bankzugang::speichern($this->zugang, [
|
||
'name' => 'Testbank',
|
||
'blz' => '12030000',
|
||
'fints_url' => 'https://banking.example.org/fints',
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => '',
|
||
'abrufzeitraum' => 'letzter_abruf',
|
||
'aktiv' => 1,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Die Wahl des TAN-Verfahrens. Die Abfrage bei der Bank lässt sich hier
|
||
* nicht nachstellen – geprüft wird, was mit ihrem Ergebnis geschieht.
|
||
*/
|
||
private function tanVerfahrenLaesstSichWaehlen(): void
|
||
{
|
||
// So sieht eine abgefragte Liste aus.
|
||
$angebot = [
|
||
['id' => 913, 'name' => 'chipTAN QR', 'entkoppelt' => false,
|
||
'medium_noetig' => true, 'medien' => ['Karte 1', 'Karte 2'], 'hinweis' => ''],
|
||
['id' => 921, 'name' => 'pushTAN 2.0', 'entkoppelt' => true,
|
||
'medium_noetig' => false, 'medien' => [], 'hinweis' => ''],
|
||
];
|
||
|
||
FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 2', $angebot);
|
||
|
||
$zugang = Bankzugang::zugangsdaten($this->zugang);
|
||
$this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Die Kennung ist gespeichert');
|
||
$this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Der Name steht daneben');
|
||
$this->assertEquals('Karte 2', $zugang['tan_medium'], 'Das gewählte Medium ist gespeichert');
|
||
|
||
$roh = Bankzugang::find($this->zugang);
|
||
$this->assert(
|
||
!str_contains((string)$roh['tan_medium'], 'Karte 2'),
|
||
'Das TAN-Medium steht verschlüsselt in der Datenbank'
|
||
);
|
||
|
||
// Ein Verfahren, das die Bank nicht angeboten hat, wäre beim nächsten
|
||
// Abruf wirkungslos – also gleich abweisen.
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 942, '', $angebot)),
|
||
'Eine unbekannte Verfahrenskennung wird abgewiesen'
|
||
);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, '', $angebot)),
|
||
'Ein Verfahren mit Medienzwang braucht ein Medium'
|
||
);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 9', $angebot)),
|
||
'Ein Medium, das die Bank nicht genannt hat, wird abgewiesen'
|
||
);
|
||
|
||
// Braucht das Verfahren kein Medium, wird ein mitgeschicktes verworfen:
|
||
// Manche Banken lehnen den Dialog ab, wenn eins dabei ist.
|
||
FinTsVerfahren::uebernehmen($this->zugang, 921, 'Karte 2', $angebot);
|
||
$zugang = Bankzugang::zugangsdaten($this->zugang);
|
||
$this->assertEquals(921, (int)$zugang['tan_verfahren_id'], 'Das entkoppelte Verfahren ist gewählt');
|
||
$this->assertEquals('', $zugang['tan_medium'], 'Ohne Medienzwang bleibt das Medium leer');
|
||
|
||
// Ohne vollständige Zugangsdaten wird die Bank gar nicht erst gefragt.
|
||
$ergebnis = FinTsVerfahren::abfragen(0);
|
||
$this->assertEquals('fehler', $ergebnis['status'], 'Ein unbekannter Zugang liefert einen Fehler');
|
||
$this->assertEquals([], $ergebnis['verfahren'], 'Und keine Liste');
|
||
|
||
FinTsVerfahren::uebernehmen($this->zugang, 913, 'Karte 1', $angebot);
|
||
}
|
||
|
||
/**
|
||
* Das Verfahren steht nicht im Zugangsformular. Ohne Vorbehalt setzte jedes
|
||
* Speichern die Wahl zurück – und der nächste Abruf verlangte wieder das
|
||
* falsche Verfahren.
|
||
*/
|
||
private function tanVerfahrenUeberstehtDasSpeichern(): void
|
||
{
|
||
Bankzugang::speichern($this->zugang, [
|
||
'name' => 'Testbank',
|
||
'blz' => '12030000',
|
||
'fints_url' => 'https://banking.example.org/fints',
|
||
'benutzerkennung' => 'testkunde',
|
||
'pin' => '',
|
||
'abrufzeitraum' => 'letzter_abruf',
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$zugang = Bankzugang::zugangsdaten($this->zugang);
|
||
$this->assertEquals(913, (int)$zugang['tan_verfahren_id'], 'Das Speichern lässt die Kennung stehen');
|
||
$this->assertEquals('chipTAN QR', $zugang['tan_verfahren_name'], 'Und den Namen');
|
||
$this->assertEquals('Karte 1', $zugang['tan_medium'], 'Und das Medium');
|
||
}
|
||
|
||
/** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */
|
||
private function wirftFehler(callable $aufruf): bool
|
||
{
|
||
try {
|
||
$aufruf();
|
||
} catch (\Throwable) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function zeitraumRichtetSichNachDemLetztenAbruf(): void
|
||
{
|
||
$heute = new \DateTimeImmutable('2026-08-11');
|
||
|
||
[$von] = Bankzugang::startdatum('sieben_tage', null, $heute);
|
||
$this->assertEquals('2026-08-04', $von->format('Y-m-d'), 'Sieben Tage zurück');
|
||
|
||
[$von] = Bankzugang::startdatum('dreissig_tage', null, $heute);
|
||
$this->assertEquals('2026-07-12', $von->format('Y-m-d'), 'Dreißig Tage zurück');
|
||
|
||
[$von, $text] = Bankzugang::startdatum('letzter_abruf', '2026-08-09 22:15:00', $heute);
|
||
$this->assertEquals('2026-08-09', $von->format('Y-m-d'), 'Ab dem Tag des letzten Abrufs');
|
||
$this->assert(str_contains($text, '09.08.2026'), 'Und sagt es auch');
|
||
|
||
// Ohne vorherigen Abruf muss ein sinnvoller Anfang her, sonst käme
|
||
// entweder nichts oder alles.
|
||
[$von] = Bankzugang::startdatum('letzter_abruf', null, $heute);
|
||
$this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Beim ersten Mal ein Jahr zurück');
|
||
|
||
[$von] = Bankzugang::startdatum('quatsch', null, $heute);
|
||
$this->assertEquals('2025-08-11', $von->format('Y-m-d'), 'Unbekannte Vorgabe fällt zurück');
|
||
}
|
||
|
||
private function umsaetzeKommenNurEinmalHerein(): void
|
||
{
|
||
$erste = $this->umsatzAnlegen([
|
||
'buchungstag' => '2026-08-03', 'betrag' => -84.20,
|
||
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
|
||
]);
|
||
|
||
$this->assertNotEmpty($erste, 'Der Umsatz wurde angelegt');
|
||
|
||
$zweite = $this->umsatzAnlegen([
|
||
'buchungstag' => '2026-08-03', 'betrag' => -84.20,
|
||
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
|
||
]);
|
||
|
||
$this->assertNull($zweite, 'Derselbe Umsatz kommt beim nächsten Abruf nicht noch einmal herein');
|
||
|
||
// Ein anderer Betrag ist ein anderer Umsatz, auch am selben Tag.
|
||
$dritte = $this->umsatzAnlegen([
|
||
'buchungstag' => '2026-08-03', 'betrag' => -12.00,
|
||
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Abschlag Strom 08/2026',
|
||
]);
|
||
$this->assertNotEmpty($dritte, 'Anderer Betrag, anderer Umsatz');
|
||
|
||
$this->assertEquals(2, Bankumsatz::anzahlOffen(), 'Zwei offene Umsätze');
|
||
}
|
||
|
||
private function regelnGreifenNachPrioritaet(): void
|
||
{
|
||
// Die unspezifische Regel hat die schlechtere Priorität und darf der
|
||
// genaueren nicht dazwischenkommen.
|
||
AbgleichRegel::anlegen([
|
||
'name' => 'Wohnen allgemein', 'feld' => 'beides', 'vergleich' => 'enthaelt',
|
||
'muster' => 'stadtwerke', 'kategorie_id' => $this->miete, 'prioritaet' => 200, 'aktiv' => 1,
|
||
]);
|
||
AbgleichRegel::anlegen([
|
||
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
|
||
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
|
||
]);
|
||
|
||
$umsatz = [
|
||
'empfaenger' => 'Stadtwerke Musterstadt',
|
||
'verwendungszweck' => 'Abschlag Strom 08/2026',
|
||
'betrag' => -84.20,
|
||
];
|
||
|
||
$regel = AbgleichRegel::passendeRegel($umsatz);
|
||
$this->assertNotEmpty($regel, 'Eine Regel passt');
|
||
$this->assertEquals('Strom', $regel['name'], 'Die mit der besseren Priorität gewinnt');
|
||
|
||
// Groß- und Kleinschreibung darf nicht entscheiden: Banken schreiben
|
||
// denselben Empfänger mal so, mal so.
|
||
$this->assertNotEmpty(
|
||
AbgleichRegel::passendeRegel(['empfaenger' => 'STADTWERKE MUSTERSTADT', 'verwendungszweck' => '', 'betrag' => -1]),
|
||
'Versalien treffen genauso'
|
||
);
|
||
|
||
// Das Feld schränkt ein: „nur Empfänger“ sieht den Verwendungszweck nicht.
|
||
$nurEmpfaenger = AbgleichRegel::anlegen([
|
||
'name' => 'Nur Empfänger', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt',
|
||
'muster' => 'Abschlag', 'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1,
|
||
]);
|
||
$this->assertEquals(
|
||
'Strom',
|
||
AbgleichRegel::passendeRegel($umsatz)['name'],
|
||
'Ein Muster im falschen Feld greift nicht'
|
||
);
|
||
AbgleichRegel::loeschen($nurEmpfaenger);
|
||
|
||
// Betragsgrenzen rechnen mit Vorzeichen.
|
||
$grenze = AbgleichRegel::anlegen([
|
||
'name' => 'Kleine Beträge', 'feld' => 'beides', 'vergleich' => 'enthaelt',
|
||
'muster' => 'stadtwerke', 'betrag_von' => -20, 'betrag_bis' => 0,
|
||
'kategorie_id' => $this->miete, 'prioritaet' => 1, 'aktiv' => 1,
|
||
]);
|
||
$this->assertEquals(
|
||
'Strom',
|
||
AbgleichRegel::passendeRegel($umsatz)['name'],
|
||
'−84,20 liegt außerhalb von −20 bis 0'
|
||
);
|
||
$this->assertEquals(
|
||
'Kleine Beträge',
|
||
AbgleichRegel::passendeRegel(['empfaenger' => 'Stadtwerke', 'verwendungszweck' => '', 'betrag' => -12.0])['name'],
|
||
'−12,00 liegt darin'
|
||
);
|
||
AbgleichRegel::loeschen($grenze);
|
||
|
||
// Eine abgeschaltete Regel greift nicht mehr.
|
||
$regeln = AbgleichRegel::alle();
|
||
foreach ($regeln as $eintrag) {
|
||
if ($eintrag['name'] === 'Strom') {
|
||
AbgleichRegel::speichern((int)$eintrag['id'], [
|
||
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
|
||
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 0,
|
||
]);
|
||
}
|
||
}
|
||
$this->assertEquals(
|
||
'Wohnen allgemein',
|
||
AbgleichRegel::passendeRegel($umsatz)['name'],
|
||
'Abgeschaltet rückt die nächste Regel nach'
|
||
);
|
||
|
||
foreach (AbgleichRegel::alle() as $eintrag) {
|
||
if ($eintrag['name'] === 'Strom') {
|
||
AbgleichRegel::speichern((int)$eintrag['id'], [
|
||
'name' => 'Strom', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
|
||
'muster' => 'Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function regelnRechnenSplitsAus(): void
|
||
{
|
||
$id = AbgleichRegel::anlegen([
|
||
'name' => 'Geteilte Rechnung',
|
||
'feld' => 'empfaenger',
|
||
'vergleich' => 'beginnt',
|
||
'muster' => 'Vermieter',
|
||
'splits' => [
|
||
['kategorie_id' => $this->miete, 'anteil' => 0.6667],
|
||
['kategorie_id' => $this->strom, 'anteil' => 0.3333],
|
||
],
|
||
'prioritaet' => 5,
|
||
'aktiv' => 1,
|
||
]);
|
||
|
||
$regel = AbgleichRegel::find($id);
|
||
$umsatz = ['empfaenger' => 'Vermieter Meier', 'verwendungszweck' => 'Miete', 'betrag' => -1000.00];
|
||
|
||
$this->assert(AbgleichRegel::passt($regel, $umsatz), '„beginnt mit“ trifft');
|
||
$this->assert(
|
||
!AbgleichRegel::passt($regel, ['empfaenger' => 'Der Vermieter', 'verwendungszweck' => '', 'betrag' => -1]),
|
||
'Aber nicht mitten im Text'
|
||
);
|
||
|
||
$zuordnung = AbgleichRegel::anwenden($regel, $umsatz);
|
||
$this->assertEquals(2, count($zuordnung['splits']), 'Zwei Splitzeilen');
|
||
$this->assertEquals(-666.70, $zuordnung['splits'][0]['betrag'], 'Erste Zeile nach Anteil');
|
||
|
||
// Die letzte Zeile bekommt den Rest – sonst ginge bei krummen Anteilen
|
||
// ein Cent verloren und die Buchung ließe sich nicht speichern.
|
||
$summe = array_sum(array_column($zuordnung['splits'], 'betrag'));
|
||
$this->assertEquals(-1000.00, round($summe, 2), 'Die Splits ergeben den Buchungsbetrag');
|
||
|
||
AbgleichRegel::loeschen($id);
|
||
}
|
||
|
||
private function ungueltigesMusterWirdAbgewiesen(): void
|
||
{
|
||
$this->assert(
|
||
!AbgleichRegel::musterIstGueltig('regex', 'Miete('),
|
||
'Eine offene Klammer ist kein gültiger Ausdruck'
|
||
);
|
||
$this->assert(
|
||
AbgleichRegel::musterIstGueltig('regex', 'Miete|Nebenkosten'),
|
||
'Eine Alternative schon'
|
||
);
|
||
|
||
$fehler = false;
|
||
try {
|
||
AbgleichRegel::anlegen([
|
||
'name' => 'Kaputt', 'feld' => 'beides', 'vergleich' => 'regex',
|
||
'muster' => 'Miete(', 'aktiv' => 1,
|
||
]);
|
||
} catch (\InvalidArgumentException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Und wird gar nicht erst gespeichert');
|
||
|
||
$id = AbgleichRegel::anlegen([
|
||
'name' => 'Ausdruck', 'feld' => 'verwendungszweck', 'vergleich' => 'regex',
|
||
'muster' => 'Strom|Gas', 'kategorie_id' => $this->strom, 'prioritaet' => 50, 'aktiv' => 1,
|
||
]);
|
||
$this->assert(
|
||
AbgleichRegel::passt(
|
||
AbgleichRegel::find($id),
|
||
['empfaenger' => '', 'verwendungszweck' => 'Abschlag Gas 2026', 'betrag' => -50]
|
||
),
|
||
'Der reguläre Ausdruck trifft die Alternative'
|
||
);
|
||
AbgleichRegel::loeschen($id);
|
||
}
|
||
|
||
private function abgleichFindetVorhandeneBuchung(): void
|
||
{
|
||
Einstellung::set('banking_abgleich_tage', '5', 'banking');
|
||
|
||
// Von Hand erfasst, drei Tage vor dem Banktag – der Normalfall.
|
||
$buchungId = Buchung::anlegen([
|
||
'konto_id' => $this->giro, 'datum' => '2026-08-01', 'betrag' => -84.20,
|
||
'empfaenger' => 'Stadtwerke', 'kategorie_id' => $this->strom,
|
||
]);
|
||
|
||
$umsatz = Bankumsatz::liste(['status' => 'offen'])[0];
|
||
// Die Liste ist absteigend sortiert; gesucht ist der über 84,20.
|
||
foreach (Bankumsatz::liste(['status' => 'offen']) as $eintrag) {
|
||
if (abs((float)$eintrag['betrag'] + 84.20) < 0.005) {
|
||
$umsatz = $eintrag;
|
||
}
|
||
}
|
||
|
||
$treffer = Bankumsatz::passendeBuchung($umsatz);
|
||
$this->assertNotEmpty($treffer, 'Die vorhandene Buchung wird gefunden');
|
||
$this->assertEquals($buchungId, (int)$treffer['id'], 'Und zwar genau sie');
|
||
|
||
$vorschlag = Bankumsatz::vorschlag($umsatz);
|
||
$this->assertEquals('verbinden', $vorschlag['art'], 'Der Vorschlag lautet: verbinden');
|
||
|
||
// Außerhalb der Toleranz zählt sie nicht mehr.
|
||
Einstellung::set('banking_abgleich_tage', '1', 'banking');
|
||
$this->assertNull(
|
||
Bankumsatz::passendeBuchung($umsatz),
|
||
'Mit einem Tag Toleranz liegen drei Tage zu weit auseinander'
|
||
);
|
||
Einstellung::set('banking_abgleich_tage', '5', 'banking');
|
||
|
||
// Ein anderer Betrag passt nie.
|
||
$this->assertNull(
|
||
Bankumsatz::passendeBuchung(['konto_id' => $this->giro, 'buchungstag' => '2026-08-03', 'betrag' => -99.99, 'id' => 0]),
|
||
'Ein anderer Betrag ist eine andere Buchung'
|
||
);
|
||
|
||
Bankumsatz::verbinden((int)$umsatz['id'], $buchungId);
|
||
|
||
$nachher = Bankumsatz::find((int)$umsatz['id']);
|
||
$this->assertEquals('uebernommen', $nachher['status'], 'Der Umsatz gilt als erledigt');
|
||
$this->assertEquals($buchungId, (int)$nachher['buchung_id'], 'Und zeigt auf die Buchung');
|
||
$this->assertEquals(
|
||
1,
|
||
(int)Buchung::find($buchungId)['abgeglichen'],
|
||
'Die Buchung ist jetzt abgeglichen'
|
||
);
|
||
|
||
// Eine schon verbundene Buchung darf kein zweiter Umsatz greifen.
|
||
$zweiter = $this->umsatzAnlegen([
|
||
'buchungstag' => '2026-08-04', 'betrag' => -84.20,
|
||
'empfaenger' => 'Stadtwerke Musterstadt', 'verwendungszweck' => 'Zweite Lastschrift',
|
||
]);
|
||
$this->assertNull(
|
||
Bankumsatz::passendeBuchung(Bankumsatz::find((int)$zweiter)),
|
||
'Eine bereits verbundene Buchung wird nicht noch einmal vorgeschlagen'
|
||
);
|
||
}
|
||
|
||
private function uebernehmenErzeugtEineBuchung(): void
|
||
{
|
||
$offen = Bankumsatz::liste(['status' => 'offen']);
|
||
$this->assertNotEmpty($offen, 'Es gibt noch offene Umsätze');
|
||
|
||
$umsatz = $offen[0];
|
||
$vorher = Buchung::anzahl();
|
||
|
||
$buchungId = Bankumsatz::uebernehmen((int)$umsatz['id'], [
|
||
'kategorie_id' => $this->strom,
|
||
]);
|
||
|
||
$this->assertEquals($vorher + 1, Buchung::anzahl(), 'Es gibt eine Buchung mehr');
|
||
|
||
$buchung = Buchung::find($buchungId);
|
||
$this->assertEquals((float)$umsatz['betrag'], (float)$buchung['betrag'], 'Mit dem Betrag des Umsatzes');
|
||
$this->assertEquals((string)$umsatz['buchungstag'], (string)$buchung['datum'], 'Und seinem Datum');
|
||
$this->assertEquals('fints', $buchung['quelle'], 'Die Quelle ist der Bankabruf');
|
||
$this->assertEquals(1, (int)$buchung['abgeglichen'], 'Was von der Bank kommt, gilt als abgeglichen');
|
||
$this->assertEquals($this->strom, (int)$buchung['kategorie_id'], 'Die Kategorie kam aus der Zuordnung');
|
||
|
||
// Ein zweites Übernehmen desselben Umsatzes muss auffallen.
|
||
$fehler = false;
|
||
try {
|
||
Bankumsatz::uebernehmen((int)$umsatz['id'], []);
|
||
} catch (\RuntimeException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Zweimal übernehmen geht nicht');
|
||
|
||
// Umsätze ohne Konto lassen sich nicht buchen – das Konto fehlt.
|
||
$ohneKonto = $this->umsatzAnlegen([
|
||
'konto_id' => null, 'buchungstag' => '2026-08-05', 'betrag' => -5.00,
|
||
'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Ohne Kontozuordnung',
|
||
]);
|
||
$fehler = false;
|
||
try {
|
||
Bankumsatz::uebernehmen((int)$ohneKonto, []);
|
||
} catch (\RuntimeException) {
|
||
$fehler = true;
|
||
}
|
||
$this->assert($fehler, 'Ohne zugeordnetes Konto verweigert der Abgleich die Buchung');
|
||
|
||
// Mit Konto in der Zuordnung geht es dann doch.
|
||
$mitKonto = Bankumsatz::uebernehmen((int)$ohneKonto, ['konto_id' => $this->sparkonto]);
|
||
$this->assertEquals(
|
||
$this->sparkonto,
|
||
(int)Buchung::find($mitKonto)['konto_id'],
|
||
'Das Konto aus der Zuordnung zählt'
|
||
);
|
||
}
|
||
|
||
private function verwerfenUndZuruecksetzen(): void
|
||
{
|
||
$id = $this->umsatzAnlegen([
|
||
'buchungstag' => '2026-08-06', 'betrag' => -3.50,
|
||
'empfaenger' => 'Doppelbuchung', 'verwendungszweck' => 'Storniert',
|
||
]);
|
||
|
||
Bankumsatz::verwerfen((int)$id);
|
||
$this->assertEquals('verworfen', Bankumsatz::find((int)$id)['status'], 'Verworfen ist verworfen');
|
||
|
||
Bankumsatz::zuruecksetzen((int)$id);
|
||
$this->assertEquals('offen', Bankumsatz::find((int)$id)['status'], 'Und lässt sich zurückholen');
|
||
$this->assertNull(Bankumsatz::find((int)$id)['buchung_id'], 'Ohne Buchungsverweis');
|
||
}
|
||
|
||
private function abrufprotokollHaeltDenVerlauf(): void
|
||
{
|
||
$id = Bankabruf::beginnen($this->zugang);
|
||
|
||
$this->assertEquals('laeuft', Bankabruf::find($id)['status'], 'Ein neuer Abruf läuft');
|
||
$this->assertNull(Bankabruf::find($id)['beendet_am'], 'Und hat noch kein Ende');
|
||
|
||
Bankabruf::abschliessen($id, 'tan_offen', 0, 0, 'Wartet auf Freigabe.', ['12:00:00 Angemeldet.']);
|
||
$wartend = Bankabruf::find($id);
|
||
$this->assertEquals('tan_offen', $wartend['status'], 'Der Abruf wartet auf eine TAN');
|
||
$this->assertNull($wartend['beendet_am'], 'Ein wartender Abruf ist nicht beendet');
|
||
|
||
Bankabruf::abschliessen($id, 'erfolg', 7, 3, '7 neue Umsätze, 3 bereits bekannt.', ['12:01:00 Fertig.']);
|
||
$fertig = Bankabruf::find($id);
|
||
$this->assertEquals('erfolg', $fertig['status'], 'Danach ist er erfolgreich');
|
||
$this->assertEquals(7, (int)$fertig['neu'], 'Mit der Zahl der neuen Umsätze');
|
||
$this->assertNotEmpty($fertig['beendet_am'], 'Und einem Ende');
|
||
$this->assert(str_contains((string)$fertig['protokoll'], 'Fertig'), 'Das Protokoll steht dabei');
|
||
|
||
// Steckengebliebene Abrufe werden aufgeräumt, sonst blockieren sie die Anzeige.
|
||
$haenger = Bankabruf::beginnen($this->zugang);
|
||
\Finanzmanager\Database::execute(
|
||
'UPDATE bank_abrufe SET begonnen_am = DATE_SUB(NOW(), INTERVAL 5 HOUR) WHERE id = ?',
|
||
[$haenger]
|
||
);
|
||
$this->assertGreaterThan(0, Bankabruf::raeumeAlteAuf(2), 'Der Hänger wurde aufgeräumt');
|
||
$this->assertEquals(
|
||
'abgebrochen',
|
||
Bankabruf::find($haenger)['status'],
|
||
'Und gilt als abgebrochen'
|
||
);
|
||
}
|
||
}
|