Finanzmanager/tests/20_VerwaisteUmsaetzeTest.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

213 lines
7.9 KiB
PHP
Raw 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\Database;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\Models\Bankzugang;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Konto;
/**
* Umsätze, die ihre Buchung verlieren – und Bankkonten, die doppelt entstehen.
*
* Beides führte zum selben Schaden: Umsätze, die nirgends mehr auftauchen. Der
* Abruf meldete sie als schon vorhanden, im Kontoblatt stand nichts, und die
* Vorschlagsliste zeigte sie nicht, weil sie noch auf „uebernommen“ standen.
*/
class VerwaisteUmsaetzeTest extends TestCase
{
protected string $testName = 'Test 20: Verwaiste Umsätze und doppelte Bankkonten';
private int $giro = 0;
private int $zugang = 0;
private int $bankkonto = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->geloeschteBuchungGibtDenUmsatzFrei();
$this->verwaisterUmsatzWirdWiederGeoeffnet();
$this->fingerabdruckHaengtNichtAnDerIban();
$this->bankkontoOhneIbanFindetSichUeberDieKontonummer();
$this->nachgelieferteIbanWirdNachgetragen();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Verwaisungsgiro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->zugang = Bankzugang::anlegen([
'name' => 'Verwaisungsbank',
'bankname' => 'Verwaisungsbank AG',
'blz' => '20041111',
'fints_url' => 'https://banking.example.org/fints',
'benutzerkennung' => 'testkunde',
'pin' => 'geheim1234',
'abrufzeitraum' => 'letzter_abruf',
'aktiv' => 1,
]);
$this->bankkonto = (int)Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE53200411110847626900',
'847626900',
'Girokonto'
)['id'];
Bankzugang::ordneKontoZu($this->bankkonto, $this->giro);
}
private function umsatzAnlegen(string $zweck, float $betrag = -12.34): int
{
$id = Bankumsatz::anlegen([
'zugang_id' => $this->zugang,
'konto_id' => $this->giro,
'bank_konto_id' => $this->bankkonto,
'extern_id' => Bankumsatz::externId($this->bankkonto, '2026-08-05', $betrag, $zweck),
'buchungstag' => '2026-08-05',
'wertstellung' => '2026-08-05',
'betrag' => $betrag,
'empfaenger' => 'Testempfänger',
'verwendungszweck' => $zweck,
'buchungsart' => 'LASTSCHRIFT',
]);
$this->assertNotEmpty($id, 'Umsatz angelegt: ' . $zweck);
return (int)$id;
}
private function status(int $umsatzId): string
{
return (string)Database::fetchValue(
'SELECT status FROM bank_umsaetze WHERE id = ?',
[$umsatzId],
''
);
}
/**
* Der eigentliche Fehler: Wer eine gebuchte Zahlung im Kontoblatt löscht,
* bekam den Umsatz nie wieder zu Gesicht.
*/
private function geloeschteBuchungGibtDenUmsatzFrei(): void
{
$umsatz = $this->umsatzAnlegen('Strom August');
$buchungId = Bankumsatz::uebernehmen($umsatz, []);
$this->assertEquals('uebernommen', $this->status($umsatz), 'Nach dem Übernehmen ist der Umsatz übernommen');
Buchung::loeschen((int)$buchungId);
$this->assertEquals('offen', $this->status($umsatz), 'Gelöschte Buchung gibt den Umsatz wieder frei');
$this->assertNull(
Database::fetchValue('SELECT buchung_id FROM bank_umsaetze WHERE id = ?', [$umsatz]),
'Der Umsatz zeigt auf keine Buchung mehr'
);
$offene = array_column(Bankumsatz::liste(['status' => 'offen']), 'id');
$this->assert(in_array((string)$umsatz, array_map('strval', $offene), true), 'Der Umsatz steht wieder in der Vorschlagsliste');
}
/**
* Nicht jede Buchung verschwindet über `Buchung::loeschen()`: Wird ein Konto
* gelöscht, räumt der Fremdschlüssel seine Buchungen per CASCADE ab – und
* Kaskaden lösen in MariaDB keine Trigger aus. Dieser Zustand muss sich von
* selbst wieder auflösen.
*/
private function verwaisterUmsatzWirdWiederGeoeffnet(): void
{
$umsatz = $this->umsatzAnlegen('Kaskadenlastschrift', -8.39);
Bankumsatz::uebernehmen($umsatz, []);
// Genau der Zustand, den eine Kaskade hinterlässt.
Database::execute(
"UPDATE bank_umsaetze SET buchung_id = NULL WHERE id = ?",
[$umsatz]
);
$this->assertEquals('uebernommen', $this->status($umsatz), 'Die Kaskade lässt den Status stehen');
$anzahl = Bankumsatz::verwaisteOeffnen();
$this->assertGreaterThan(0, $anzahl, 'Mindestens ein verwaister Umsatz wurde geöffnet');
$this->assertEquals('offen', $this->status($umsatz), 'Der verwaiste Umsatz ist wieder offen');
}
/**
* Die Comdirect nennt die IBAN beim Kontenabruf, beim Umsatzabruf aber
* nicht. Hing der Fingerabdruck daran, kam derselbe Umsatz ein zweites Mal
* herein.
*/
private function fingerabdruckHaengtNichtAnDerIban(): void
{
$mitIban = Bankumsatz::externId($this->bankkonto, '2026-08-07', -22.00, 'Kiosk');
$erneut = Bankumsatz::externId($this->bankkonto, '2026-08-07', -22.00, 'Kiosk');
$this->assertEquals($mitIban, $erneut, 'Derselbe Umsatz ergibt denselben Fingerabdruck');
$anderesKonto = Bankumsatz::externId($this->bankkonto + 1, '2026-08-07', -22.00, 'Kiosk');
$this->assert($mitIban !== $anderesKonto, 'Ein anderes Bankkonto ergibt einen anderen Fingerabdruck');
}
/**
* Liefert die Bank beim zweiten Dialog keine IBAN mehr, darf daraus kein
* zweites Bankkonto entstehen – sonst hätte das Konto zwei Zuordnungen und
* zwei Fingerabdrücke, und jeder Umsatz stünde doppelt da.
*/
private function bankkontoOhneIbanFindetSichUeberDieKontonummer(): void
{
$ohneIban = Bankzugang::findeOderLegeKontoAn($this->zugang, '', '847626900', '847626900');
$this->assertEquals(
$this->bankkonto,
(int)$ohneIban['id'],
'Ohne IBAN findet sich dasselbe Bankkonto über die Kontonummer'
);
$this->assertEquals(
'DE53200411110847626900',
(string)$ohneIban['iban'],
'Die einmal bekannte IBAN bleibt erhalten'
);
$this->assertEquals(
$this->giro,
(int)$ohneIban['konto_id'],
'Und mit ihr die Zuordnung zum eigenen Konto'
);
$this->assertEquals(
1,
(int)Database::fetchValue(
'SELECT COUNT(*) FROM bank_konten WHERE zugang_id = ? AND kontonummer = ?',
[$this->zugang, '847626900'],
0
),
'Es gibt weiterhin nur einen Eintrag für dieses Konto'
);
}
/** Der umgekehrte Weg: erst ohne IBAN gesehen, dann mit. */
private function nachgelieferteIbanWirdNachgetragen(): void
{
$ohneIban = Bankzugang::findeOderLegeKontoAn($this->zugang, '', '847626905', 'Tagesgeld');
$this->assertEquals('', (string)$ohneIban['iban'], 'Beim ersten Sehen ohne IBAN angelegt');
$mitIban = Bankzugang::findeOderLegeKontoAn(
$this->zugang,
'DE15200411110847626905',
'847626905',
'Tagesgeld'
);
$this->assertEquals((int)$ohneIban['id'], (int)$mitIban['id'], 'Es bleibt dasselbe Bankkonto');
$this->assertEquals(
'DE15200411110847626905',
(string)Database::fetchValue('SELECT iban FROM bank_konten WHERE id = ?', [(int)$mitIban['id']], ''),
'Die nachgelieferte IBAN wurde eingetragen'
);
}
}