Liegen zwei Buchungen, die eigentlich derselbe Vorgang sind, zu weit auseinander oder gehörte eine ursprünglich einem anderen Ursprungskonto, findet der automatische Abgleich sie nicht – die Umbuchung und ihre einzeln erfasste Dublette stehen dann dauerhaft nebeneinander im Kontoblatt. Buchung::verknuepfen() führt eine Umbuchung (eigene Seite oder Gegenseite auf diesem Konto) und eine einzeln erfasste Buchung mit passendem Betrag zusammen: Die Umbuchung bleibt, die Dublette fällt weg, ihr Bankumsatz hängt sich an die Umbuchung. Im Kontoblatt lässt sich dafür jetzt auch die Gegenseite einer fremden Umbuchung ankreuzen; ein neuer Knopf „Verknüpfen“ nutzt dieselbe Auswahl wie „Auswahl löschen“. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
8.1 KiB
PHP
210 lines
8.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Finanzmanager\Models\Bankumsatz;
|
||
use Finanzmanager\Models\Bankzugang;
|
||
use Finanzmanager\Models\Buchung;
|
||
use Finanzmanager\Models\Konto;
|
||
|
||
/**
|
||
* Manuelles Verknüpfen zweier Buchungen im Kontoblatt: eine Umbuchung und eine
|
||
* einzeln erfasste Buchung, die denselben Vorgang doppelt zeigen, weil sie zu
|
||
* weit auseinanderlagen, um sich beim automatischen Abgleich zu finden (siehe
|
||
* auch Test 16, „Umbuchung wird auch von der Gegenkontoseite erkannt“ – dort
|
||
* geht es um den automatischen Fall, hier um den, den nur ein Mensch auflösen
|
||
* kann).
|
||
*/
|
||
class BuchungenVerknuepfenTest extends TestCase
|
||
{
|
||
protected string $testName = 'Test 44: Buchungen im Kontoblatt verknüpfen';
|
||
|
||
private int $paypal = 0;
|
||
private int $dkb = 0;
|
||
private int $zugang = 0;
|
||
|
||
public function run(): void
|
||
{
|
||
$this->mandantAnlegen();
|
||
$this->stammdaten();
|
||
|
||
$this->gegenseiteUndDublette();
|
||
$this->eigeneUmbuchungUndDublette();
|
||
$this->reihenfolgeDerAuswahlSpieltKeineRolle();
|
||
$this->zweiEinzelneLassenSichNichtVerknuepfen();
|
||
$this->zweiUmbuchungenLassenSichNichtVerknuepfen();
|
||
$this->unpassenderBetragWirdAbgewiesen();
|
||
}
|
||
|
||
private function stammdaten(): void
|
||
{
|
||
$this->paypal = Konto::anlegen([
|
||
'name' => 'Paypal', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$this->dkb = Konto::anlegen([
|
||
'name' => 'DKB Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
|
||
]);
|
||
$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 (int)Bankumsatz::anlegen(array_merge([
|
||
'zugang_id' => $this->zugang,
|
||
'extern_id' => 'verknuepfen-' . bin2hex(random_bytes(4)),
|
||
'wertstellung' => $daten['buchungstag'],
|
||
'empfaenger' => '',
|
||
'verwendungszweck' => '',
|
||
'buchungsart' => 'GUTSCHRIFT',
|
||
], $daten));
|
||
}
|
||
|
||
/**
|
||
* Der Fall aus der Praxis: Die Umbuchung wurde von Paypal aus gebucht (ihr
|
||
* Ursprungskonto), im DKB-Kontoblatt steht sie nur als Gegenseite. Die
|
||
* echte Bankbuchung kommt einen Tag später herein und wird – weil der
|
||
* automatische Abgleich sie nicht fand – als eigene Buchung übernommen.
|
||
*/
|
||
private function gegenseiteUndDublette(): void
|
||
{
|
||
$umbuchungId = Buchung::anlegen([
|
||
'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 26.00,
|
||
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb,
|
||
]);
|
||
|
||
$dubletteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -26.00,
|
||
'empfaenger' => 'PayPal Europe',
|
||
]);
|
||
$umsatzId = $this->umsatzAnlegen([
|
||
'konto_id' => $this->dkb, 'buchungstag' => '2026-09-24', 'betrag' => -26.00,
|
||
]);
|
||
Bankumsatz::verbinden($umsatzId, $dubletteId);
|
||
|
||
Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId);
|
||
|
||
$this->assertNull(Buchung::find($dubletteId), 'Die einzeln erfasste Dublette ist weg');
|
||
$this->assertNotEmpty(Buchung::find($umbuchungId), 'Die Umbuchung bleibt bestehen');
|
||
$this->assertEquals(
|
||
1,
|
||
(int)Buchung::find($umbuchungId)['abgeglichen'],
|
||
'Die Umbuchung gilt jetzt als abgeglichen'
|
||
);
|
||
|
||
$umsatz = Bankumsatz::find($umsatzId);
|
||
$this->assertEquals($umbuchungId, (int)$umsatz['buchung_id'], 'Ihr Bankumsatz zeigt jetzt auf die Umbuchung');
|
||
$this->assertEquals('uebernommen', $umsatz['status'], 'Und gilt weiter als erledigt');
|
||
}
|
||
|
||
/**
|
||
* Gespiegelter Fall: Die Umbuchung wurde direkt im DKB-Konto gebucht (ihr
|
||
* Ursprungskonto liegt hier), die Dublette ebenfalls hier.
|
||
*/
|
||
private function eigeneUmbuchungUndDublette(): void
|
||
{
|
||
$umbuchungId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-23', 'betrag' => -75.20,
|
||
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->paypal,
|
||
]);
|
||
$dubletteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-26', 'betrag' => -75.20,
|
||
'empfaenger' => 'Reservix',
|
||
]);
|
||
|
||
Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId);
|
||
|
||
$this->assertNull(Buchung::find($dubletteId), 'Die Dublette ist weg');
|
||
$this->assertEquals(
|
||
$this->paypal,
|
||
(int)Buchung::find($umbuchungId)['gegenkonto_id'],
|
||
'Die Umbuchung bleibt, was sie war'
|
||
);
|
||
}
|
||
|
||
private function reihenfolgeDerAuswahlSpieltKeineRolle(): void
|
||
{
|
||
$umbuchungId = Buchung::anlegen([
|
||
'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 12.00,
|
||
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb,
|
||
]);
|
||
$dubletteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -12.00,
|
||
'empfaenger' => 'Dublette',
|
||
]);
|
||
|
||
// Zuerst die Dublette, dann die Umbuchung ausgewählt.
|
||
Buchung::verknuepfen($this->dkb, $dubletteId, $umbuchungId);
|
||
|
||
$this->assertNull(Buchung::find($dubletteId), 'Auch andersherum verschwindet die Dublette');
|
||
$this->assertNotEmpty(Buchung::find($umbuchungId), 'Und die Umbuchung bleibt');
|
||
}
|
||
|
||
private function zweiEinzelneLassenSichNichtVerknuepfen(): void
|
||
{
|
||
$ersteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -10.00, 'empfaenger' => 'Erste',
|
||
]);
|
||
$zweiteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -10.00, 'empfaenger' => 'Zweite',
|
||
]);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $ersteId, $zweiteId)),
|
||
'Zwei einzeln erfasste Buchungen lassen sich nicht verknüpfen'
|
||
);
|
||
$this->assertNotEmpty(Buchung::find($ersteId), 'Keine der beiden wurde gelöscht');
|
||
$this->assertNotEmpty(Buchung::find($zweiteId), 'Auch die zweite nicht');
|
||
}
|
||
|
||
private function zweiUmbuchungenLassenSichNichtVerknuepfen(): void
|
||
{
|
||
$ersteId = Buchung::anlegen([
|
||
'konto_id' => $this->paypal, 'datum' => '2026-09-25', 'betrag' => 5.00,
|
||
'empfaenger' => 'Umbuchung 1', 'gegenkonto_id' => $this->dkb,
|
||
]);
|
||
$zweiteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-25', 'betrag' => -7.00,
|
||
'empfaenger' => 'Umbuchung 2', 'gegenkonto_id' => $this->paypal,
|
||
]);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $ersteId, $zweiteId)),
|
||
'Zwei Umbuchungen lassen sich nicht verknüpfen'
|
||
);
|
||
}
|
||
|
||
private function unpassenderBetragWirdAbgewiesen(): void
|
||
{
|
||
$umbuchungId = Buchung::anlegen([
|
||
'konto_id' => $this->paypal, 'datum' => '2026-09-23', 'betrag' => 26.00,
|
||
'empfaenger' => 'Umbuchung', 'gegenkonto_id' => $this->dkb,
|
||
]);
|
||
$dubletteId = Buchung::anlegen([
|
||
'konto_id' => $this->dkb, 'datum' => '2026-09-24', 'betrag' => -19.99,
|
||
'empfaenger' => 'Anderer Betrag',
|
||
]);
|
||
|
||
$this->assert(
|
||
$this->wirftFehler(fn() => Buchung::verknuepfen($this->dkb, $umbuchungId, $dubletteId)),
|
||
'Unterschiedliche Beträge werden abgewiesen'
|
||
);
|
||
$this->assertNotEmpty(Buchung::find($dubletteId), 'Die Dublette bleibt unangetastet');
|
||
}
|
||
|
||
/** Hilfsmittel: Wirft der Aufruf eine Ausnahme? */
|
||
private function wirftFehler(callable $aufruf): bool
|
||
{
|
||
try {
|
||
$aufruf();
|
||
} catch (\Throwable) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
}
|