Finanzmanager/tests/19_KontenzuordnungTest.php
fds2610-bot 78f1b91bc0 Online-Banking: Kontenzuordnung, Sammellöschen und drei Umsatz-Fehler
Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu
welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie
stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt.
Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`).

Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen
jetzt über der Ansicht, statt den Inhalt springen zu lassen.

Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos
verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte
nichts, und in der Vorschlagsliste standen sie ebenfalls nicht:

- Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der
  Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt
  den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege
  ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus.
- Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog
  mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN
  wird am vorhandenen Eintrag nachgetragen.
- Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die
  `bank_konto_id`, die fest steht, solange es den Bankzugang gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:54:23 +02:00

486 lines
18 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\Controllers\BankingController;
use Finanzmanager\Database;
use Finanzmanager\Models\AbgleichRegel;
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\Models\Monatsabschluss;
use Finanzmanager\Services\Bankabgleich;
/**
* Kontenzuordnung und automatisches Buchen.
*
* Der Weg, den ein Umsatz nimmt, wenn die Zuordnung Bankkonto → eigenes Konto
* erst nach dem Abruf getroffen wird – und was danach von allein im Kontoblatt
* landet. Der FinTS-Dialog selbst bleibt außen vor: Geprüft wird, was mit seinem
* Ergebnis geschieht.
*/
class KontenzuordnungTest extends TestCase
{
protected string $testName = 'Test 19: Kontenzuordnung und automatisches Buchen';
private int $giro = 0;
private int $zweitkonto = 0;
private int $strom = 0;
private int $zugang = 0;
private int $bankkonto = 0;
private int $zweitesBankkonto = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->zuordnungZiehtOffeneUmsaetzeNach();
$this->automatischesBuchenLegtBuchungenAn();
$this->automatischesBuchenVerbindetStattZuDoppeln();
$this->umsaetzeOhneKontoBleibenLiegen();
$this->einZeitverriegelterUmsatzBrichtDenLaufNichtAb();
$this->abgeschalteteAutomatikBuchtNicht();
$this->allesUebernehmenBleibtZurueckhaltend();
$this->unkategorisierteZaehlenJeKonto();
$this->kontenuebersichtKenntDieOffenenBuchungen();
$this->dieEinrichtungEndetInDerUebersicht();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Zuordnungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zweitkonto = Konto::anlegen([
'name' => 'Zuordnungssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->strom = Kategorie::findenOderAnlegenNachPfad('300 - Wohnen:Strom Zuordnung', 'ausgabe');
$this->zugang = Bankzugang::anlegen([
'name' => 'Zuordnungsbank',
'bankname' => 'Zuordnungsbank AG',
'blz' => '12030000',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
// So legt der Abruf ein bei der Bank gefundenes Konto an: erst einmal
// ohne Zuordnung, denn welches eigene Konto gemeint ist, weiß nur der
// Nutzer.
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE29120300000000202052',
'202052',
'Girokonto'
)['id'];
$this->zweitesBankkonto = (int)Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE44120300000000202053',
'202053',
'Sparkonto'
)['id'];
Einstellung::set('banking_automatisch_buchen', '1', 'banking');
Einstellung::set('banking_abgleich_tage', '5', 'banking');
}
/** @param array<string,mixed> $daten */
private function umsatzAnlegen(array $daten): int
{
$id = Bankumsatz::anlegen([
'zugang_id' => $this->zugang,
'konto_id' => $daten['konto_id'] ?? null,
'bank_konto_id' => $daten['bank_konto_id'] ?? $this->bankkonto,
'extern_id' => Bankumsatz::externId(
(int)($daten['bank_konto_id'] ?? $this->bankkonto),
(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',
]);
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . ($daten['verwendungszweck'] ?? ''));
return (int)$id;
}
private function heute(string $tage = ''): string
{
return (new \DateTimeImmutable($tage === '' ? 'today' : 'today ' . $tage))->format('Y-m-d');
}
/**
* Der Kern des dritten Einrichtungsschritts: Umsätze, die vor der Zuordnung
* hereinkamen, dürfen nicht ohne Konto stehen bleiben – sonst ließen sie
* sich nie buchen.
*/
private function zuordnungZiehtOffeneUmsaetzeNach(): void
{
$offen = $this->umsatzAnlegen([
'buchungstag' => $this->heute('-3 days'), 'betrag' => -49.90,
'empfaenger' => 'Stadtwerke Zuordnung', 'verwendungszweck' => 'Abschlag Strom',
]);
$this->assertNull(
Bankumsatz::find($offen)['konto_id'],
'Vor der Zuordnung hat der Umsatz kein Konto'
);
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
$this->assertEquals(
$this->giro,
(int)Bankumsatz::find($offen)['konto_id'],
'Mit der Zuordnung bekommt der offene Umsatz sein Konto'
);
// Ein Umsatz des anderen Bankkontos darf davon nichts abbekommen.
$fremd = $this->umsatzAnlegen([
'bank_konto_id' => $this->zweitesBankkonto,
'buchungstag' => $this->heute('-3 days'), 'betrag' => -7.10,
'empfaenger' => 'Sparkonto', 'verwendungszweck' => 'Anderes Bankkonto',
]);
$this->assertNull(
Bankumsatz::find($fremd)['konto_id'],
'Die Zuordnung greift nur auf das Bankkonto, um das es geht'
);
Bankzugang::ordneKontoZu($this->zweitesBankkonto, $this->zweitkonto);
$this->assertEquals(
$this->zweitkonto,
(int)Bankumsatz::find($fremd)['konto_id'],
'Und dann auch dort'
);
// Übernommene bleiben, wo sie sind: Ihre Buchung steht bereits in einem
// Kontoblatt, ein Kontowechsel würde sie dort wegziehen.
$gebucht = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $this->heute('-2 days'), 'betrag' => -11.00,
'empfaenger' => 'Bäcker', 'verwendungszweck' => 'Schon gebucht',
]);
Bankumsatz::uebernehmen($gebucht, ['kategorie_id' => $this->strom]);
Bankzugang::ordneKontoZu($this->bankkonto, $this->zweitkonto);
$this->assertEquals(
$this->giro,
(int)Bankumsatz::find($gebucht)['konto_id'],
'Ein übernommener Umsatz wandert nicht mit'
);
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
}
private function automatischesBuchenLegtBuchungenAn(): void
{
AbgleichRegel::anlegen([
'name' => 'Strom Zuordnung', 'feld' => 'verwendungszweck', 'vergleich' => 'enthaelt',
'muster' => 'Abschlag Strom', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
]);
$ohneRegel = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $this->heute('-1 day'), 'betrag' => -23.45,
'empfaenger' => 'Unbekannt GmbH', 'verwendungszweck' => 'Keine Regel trifft',
]);
$vorher = Buchung::anzahl();
$ergebnis = Bankabgleich::automatisch();
$this->assertNotEmpty($ergebnis, 'Eingeschaltet läuft der automatische Abgleich');
$this->assertEquals(0, $ergebnis['offen'], 'Nichts bleibt liegen');
$this->assertEquals(
$vorher + $ergebnis['uebernommen'],
Buchung::anzahl(),
'Für jeden übernommenen Umsatz steht eine Buchung mehr da'
);
// Der Umsatz mit Regel bekommt seine Kategorie …
$mitRegel = Bankumsatz::liste(['status' => 'uebernommen']);
$strom = null;
foreach ($mitRegel as $eintrag) {
if (str_contains((string)$eintrag['verwendungszweck'], 'Abschlag Strom')) {
$strom = $eintrag;
}
}
$this->assertNotEmpty($strom, 'Der Umsatz mit Regel ist übernommen');
$this->assertEquals(
$this->strom,
(int)Buchung::find((int)$strom['buchung_id'])['kategorie_id'],
'Die Kategorie kommt aus der Regel'
);
// … der ohne Regel wird trotzdem gebucht. Genau dafür ist die Automatik
// da: Der Umsatz soll im Kontoblatt stehen, die Kategorie kommt später.
$buchungOhneRegel = Buchung::find((int)Bankumsatz::find($ohneRegel)['buchung_id']);
$this->assertEquals(
'uebernommen',
Bankumsatz::find($ohneRegel)['status'],
'Auch ohne Regel wird gebucht'
);
$this->assertNull($buchungOhneRegel['kategorie_id'], 'Aber ohne Kategorie');
$this->assertEquals($this->giro, (int)$buchungOhneRegel['konto_id'], 'Im zugeordneten Konto');
$this->assertEquals('fints', $buchungOhneRegel['quelle'], 'Und mit dem Bankabruf als Quelle');
}
/** Wer seine Buchungen selbst erfasst, will sie nicht doppelt sehen. */
private function automatischesBuchenVerbindetStattZuDoppeln(): void
{
$buchungId = Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => $this->heute('-4 days'), 'betrag' => -128.00,
'empfaenger' => 'Versicherung', 'kategorie_id' => $this->strom,
]);
$umsatz = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $this->heute('-2 days'), 'betrag' => -128.00,
'empfaenger' => 'Versicherung AG', 'verwendungszweck' => 'Beitrag',
]);
$vorher = Buchung::anzahl();
$ergebnis = Bankabgleich::automatisch();
$this->assertEquals(1, $ergebnis['verbunden'], 'Der Umsatz wurde verbunden');
$this->assertEquals($vorher, Buchung::anzahl(), 'Und keine zweite Buchung angelegt');
$this->assertEquals(
$buchungId,
(int)Bankumsatz::find($umsatz)['buchung_id'],
'Er zeigt auf die selbst erfasste Buchung'
);
$this->assertEquals(
1,
(int)Buchung::find($buchungId)['abgeglichen'],
'Die gilt damit als abgeglichen'
);
}
private function umsaetzeOhneKontoBleibenLiegen(): void
{
// Ein Bankkonto ohne Zuordnung – etwa ein Depot, das nicht mitgeführt wird.
$ohne = Bankzugang::findeOderLegeKontoAn($this->zugang, 'DE60120300000000202054', '202054', 'Ohne');
$umsatz = $this->umsatzAnlegen([
'bank_konto_id' => (int)$ohne['id'],
'buchungstag' => $this->heute('-1 day'), 'betrag' => -9.99,
'empfaenger' => 'Ohne Zuordnung', 'verwendungszweck' => 'Bleibt offen',
]);
$ergebnis = Bankabgleich::automatisch();
$this->assertEquals(1, $ergebnis['offen'], 'Ein Umsatz ohne Konto bleibt offen');
$this->assertEquals(
'offen',
Bankumsatz::find($umsatz)['status'],
'Und steht weiter in der Vorschlagsliste'
);
$this->assertEquals([], $ergebnis['fehler'], 'Ohne Fehlermeldung – das ist kein Fehler');
}
/**
* Ein Umsatz, der sich nicht buchen lässt, darf die übrigen nicht mitreißen.
* Der abgeschlossene Monat ist der greifbarste Fall davon.
*/
private function einZeitverriegelterUmsatzBrichtDenLaufNichtAb(): void
{
$jahr = (int)date('Y') - 1;
$datum = sprintf('%d-03-15', $jahr);
Monatsabschluss::abschliessen($jahr, 3, 'Test');
$verriegelt = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $datum, 'betrag' => -31.00,
'empfaenger' => 'Aus dem Archiv', 'verwendungszweck' => 'Abgeschlossener Monat',
]);
$daneben = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $this->heute(), 'betrag' => -6.60,
'empfaenger' => 'Heute', 'verwendungszweck' => 'Geht durch',
]);
$ergebnis = Bankabgleich::automatisch();
$this->assertEquals(1, count($ergebnis['fehler']), 'Ein Umsatz ließ sich nicht buchen');
$this->assertEquals(
'offen',
Bankumsatz::find($verriegelt)['status'],
'Er bleibt offen und wartet auf die Wiedereröffnung'
);
$this->assertEquals(
'uebernommen',
Bankumsatz::find($daneben)['status'],
'Der Umsatz daneben wurde trotzdem gebucht'
);
$this->assert(
str_contains(Bankabgleich::bericht($ergebnis), 'Nicht gebucht'),
'Der Bericht nennt den Fall'
);
$abschluss = Monatsabschluss::fuerMonat($jahr, 3);
Monatsabschluss::wiedereroeffnen((int)$abschluss['id']);
}
private function abgeschalteteAutomatikBuchtNicht(): void
{
Einstellung::set('banking_automatisch_buchen', '0', 'banking');
$umsatz = $this->umsatzAnlegen([
'konto_id' => $this->giro,
'buchungstag' => $this->heute(), 'betrag' => -4.20,
'empfaenger' => 'Kiosk', 'verwendungszweck' => 'Von Hand abgleichen',
]);
$this->assertNull(Bankabgleich::automatisch(), 'Abgeschaltet läuft nichts');
$this->assertEquals(
'offen',
Bankumsatz::find($umsatz)['status'],
'Der Umsatz wartet auf den Abgleich'
);
Einstellung::set('banking_automatisch_buchen', '1', 'banking');
}
/**
* Der Knopf „Alles übernehmen“ ist zurückhaltender als die Automatik: Ohne
* Regel bleibt der Umsatz liegen, damit die Zuordnung beim Nutzer bleibt.
*/
private function allesUebernehmenBleibtZurueckhaltend(): void
{
$ergebnis = Bankabgleich::laufenLassen(false);
$this->assertEquals(0, $ergebnis['uebernommen'], 'Ohne Regel wird nichts gebucht');
$this->assertGreaterThan(0, $ergebnis['offen'], 'Die Umsätze bleiben offen');
// Mit Regel greift derselbe Lauf dann doch.
AbgleichRegel::anlegen([
'name' => 'Kiosk Zuordnung', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt',
'muster' => 'Kiosk', 'kategorie_id' => $this->strom, 'prioritaet' => 10, 'aktiv' => 1,
]);
$mitRegel = Bankabgleich::laufenLassen(false);
$this->assertEquals(1, $mitRegel['uebernommen'], 'Mit Regel wird gebucht');
}
private function unkategorisierteZaehlenJeKonto(): void
{
$jahr = (int)date('Y');
$vorher = Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0;
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -15.00,
'empfaenger' => 'Ohne Kategorie',
]);
$this->assertEquals(
$vorher + 1,
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
'Eine Buchung ohne Kategorie zählt mit'
);
// Eine Umbuchung ist zugeordnet, auch ohne Kategorie.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -100.00,
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->zweitkonto,
]);
// Eine Splitbuchung trägt ihre Zuordnung in den Zeilen.
Buchung::anlegen(
[
'konto_id' => $this->giro, 'datum' => $this->heute(), 'betrag' => -50.00,
'empfaenger' => 'Gesplittet',
],
[
['kategorie_id' => $this->strom, 'betrag' => -30.00],
['kategorie_id' => $this->strom, 'betrag' => -20.00],
]
);
$this->assertEquals(
$vorher + 1,
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
'Umbuchung und Splitbuchung zählen nicht als unkategorisiert'
);
// Das Vorjahr bleibt außen vor – die Übersicht zeigt das laufende Jahr.
Database::execute(
'UPDATE buchungen SET datum = ? WHERE benutzer_id = ? AND konto_id = ?
AND kategorie_id IS NULL AND gegenkonto_id IS NULL AND gesplittet = 0',
[($jahr - 1) . '-06-15', \Finanzmanager\Mandant::id(), $this->giro]
);
$this->assertEquals(
0,
Buchung::unkategorisierteJeKonto($jahr)[$this->giro] ?? 0,
'Buchungen aus dem Vorjahr zählen im laufenden Jahr nicht'
);
$this->assertGreaterThan(
0,
Buchung::unkategorisierteJeKonto($jahr - 1)[$this->giro] ?? 0,
'Dafür im Vorjahr'
);
}
private function kontenuebersichtKenntDieOffenenBuchungen(): void
{
Buchung::anlegen([
'konto_id' => $this->zweitkonto, 'datum' => $this->heute(), 'betrag' => -8.00,
'empfaenger' => 'Frisch abgerufen', 'quelle' => 'fints',
]);
$uebersicht = Konto::uebersicht();
$gefunden = [];
foreach ($uebersicht['gruppen'] as $gruppe) {
foreach ($gruppe['konten'] as $konto) {
$gefunden[(int)$konto['id']] = $konto;
}
}
$this->assertNotEmpty($gefunden[$this->zweitkonto] ?? null, 'Das Konto steht in der Übersicht');
$this->assertGreaterThan(
0,
(int)$gefunden[$this->zweitkonto]['unkategorisiert'],
'Und meldet seine Buchung ohne Kategorie – die Übersicht setzt es fett'
);
$this->assertEquals(
0,
(int)($gefunden[$this->giro]['unkategorisiert'] ?? -1),
'Das aufgeräumte Konto bleibt normal'
);
}
/**
* Der letzte Schritt der Einrichtung führt zurück in die Übersicht der
* Bankverbindungen, nicht in die Zugangsmaske: Dort ist alles erledigt, was
* sie anbietet.
*/
private function dieEinrichtungEndetInDerUebersicht(): void
{
$this->assertEquals(
'/banking',
BankingController::zielNachZuordnung($this->zugang, true),
'Nach der Einrichtung geht es in die Übersicht der Bankverbindungen'
);
$this->assertEquals(
'/banking/' . $this->zugang . '/konten',
BankingController::zielNachZuordnung($this->zugang, false),
'Die einzeln aufgerufene Zuordnung bleibt, wo sie ist'
);
}
}