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>
213 lines
7.9 KiB
PHP
213 lines
7.9 KiB
PHP
<?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'
|
||
);
|
||
}
|
||
}
|