Fix n.n.: Buchungen im Kontoblatt lassen sich manuell verknüpfen
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>
This commit is contained in:
parent
ecd01cde29
commit
e02bac22c0
7 changed files with 368 additions and 9 deletions
27
Changes.md
27
Changes.md
|
|
@ -1,5 +1,32 @@
|
||||||
# Änderungen
|
# Änderungen
|
||||||
|
|
||||||
|
## Umbuchung wird auch von der Gegenkontoseite erkannt (27.09.2026)
|
||||||
|
|
||||||
|
Eine Umbuchung gehört ihrem Ursprungskonto; der automatische Abgleich suchte eine
|
||||||
|
passende Buchung bisher nur über deren `konto_id`. Kam der Kontoauszug der
|
||||||
|
Empfängerseite mit ein paar Tagen Verzug herein, fand er die schon gebuchte Umbuchung
|
||||||
|
nie und legte sie als eigenständige Buchung an – die Umbuchung stand danach doppelt im
|
||||||
|
Kontoblatt und verfälschte den Saldo.
|
||||||
|
|
||||||
|
- `Bankumsatz::passendeBuchung()` berücksichtigt jetzt auch die Gegenkonto-Seite einer
|
||||||
|
Umbuchung, mit umgekehrtem Vorzeichen wie im Kontoblatt auch.
|
||||||
|
- Test in `tests/16_BankingTest.php`
|
||||||
|
(`umbuchungWirdAuchVonDerGegenkontoseiteErkannt`).
|
||||||
|
|
||||||
|
## Buchungen im Kontoblatt verknüpfen (27.09.2026)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- Neu: `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 auch die Gegenseite einer fremden Umbuchung ankreuzen;
|
||||||
|
der Knopf „Verknüpfen“ neben „Auswahl löschen“ nutzt dieselbe Auswahl.
|
||||||
|
- Test in `tests/44_BuchungenVerknuepfenTest.php`.
|
||||||
|
|
||||||
## Buchungsbericht: Splitbuchung nach Konto/Empfänger zusammengefasst (27.09.2026)
|
## Buchungsbericht: Splitbuchung nach Konto/Empfänger zusammengefasst (27.09.2026)
|
||||||
|
|
||||||
Nach Kategorie oder Klasse gruppiert erscheint eine Splitbuchung weiterhin mit jeder
|
Nach Kategorie oder Klasse gruppiert erscheint eine Splitbuchung weiterhin mit jeder
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,8 @@ $router->get('/konten/{id}/buchung', fn(array $p) => (new BuchungenController()
|
||||||
$router->post('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->anlegen($p));
|
$router->post('/konten/{id}/buchung', fn(array $p) => (new BuchungenController())->anlegen($p));
|
||||||
$router->post('/konten/{id}/buchungen/loeschen',
|
$router->post('/konten/{id}/buchungen/loeschen',
|
||||||
fn(array $p) => (new BuchungenController())->auswahlLoeschen($p));
|
fn(array $p) => (new BuchungenController())->auswahlLoeschen($p));
|
||||||
|
$router->post('/konten/{id}/buchungen/verknuepfen',
|
||||||
|
fn(array $p) => (new BuchungenController())->verknuepfen($p));
|
||||||
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
|
$router->get('/buchungen/{id}', fn(array $p) => (new BuchungenController())->bearbeiten($p));
|
||||||
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
|
$router->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
|
||||||
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
|
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formulare mit data-bestaetigen fragen vor dem Absenden nach.
|
// Formulare mit data-bestaetigen fragen vor dem Absenden nach. Trägt der
|
||||||
|
// gedrückte Knopf selbst ein data-bestaetigen, gilt seine Frage – so
|
||||||
|
// können mehrere Knöpfe im selben Formular (verschiedene Ziele über
|
||||||
|
// formaction) je ihre eigene Rückfrage stellen.
|
||||||
// In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein
|
// In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein
|
||||||
// anderer Handler das Ereignis unterwegs stoppt.
|
// anderer Handler das Ereignis unterwegs stoppt.
|
||||||
document.addEventListener('submit', function (ereignis) {
|
document.addEventListener('submit', function (ereignis) {
|
||||||
|
|
@ -38,7 +41,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frage = formular.dataset.bestaetigen;
|
const frage = (ereignis.submitter && ereignis.submitter.dataset.bestaetigen)
|
||||||
|
|| formular.dataset.bestaetigen;
|
||||||
if (frage && !window.confirm(frage)) {
|
if (frage && !window.confirm(frage)) {
|
||||||
ereignis.preventDefault();
|
ereignis.preventDefault();
|
||||||
ereignis.stopPropagation();
|
ereignis.stopPropagation();
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,41 @@ class BuchungenController extends BaseController
|
||||||
: $anzahl . ' Buchungen gelöscht.');
|
: $anzahl . ' Buchungen gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt zwei angehakte Buchungen dieses Kontoblatts zusammen: eine schon
|
||||||
|
* fertige Umbuchung und eine einzeln erfasste Buchung, die denselben
|
||||||
|
* Vorgang doppelt zeigen, weil sie zu weit auseinanderlagen, um sich
|
||||||
|
* automatisch zu finden.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $params
|
||||||
|
*/
|
||||||
|
public function verknuepfen(array $params): void
|
||||||
|
{
|
||||||
|
$this->verlangeAnmeldung();
|
||||||
|
|
||||||
|
$kontoId = (int)$params['id'];
|
||||||
|
if (Konto::find($kontoId) === null) {
|
||||||
|
$this->nichtGefunden('/konten/' . $params['id']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ziel = $this->kontoblattPfad($kontoId);
|
||||||
|
$ids = $this->postIds('buchungen');
|
||||||
|
if (count($ids) !== 2) {
|
||||||
|
View::redirect($ziel, 'Zum Verknüpfen bitte genau zwei Buchungen ankreuzen.', 'warnung');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Buchung::verknuepfen($kontoId, $ids[0], $ids[1]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
View::redirect($ziel, $e->getMessage(), 'fehler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect($ziel, 'Buchungen verknüpft.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite.
|
* Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,66 @@ class Buchung
|
||||||
Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
Database::execute('DELETE FROM buchungen WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt zwei Buchungen zusammen, die auf diesem Konto dieselbe Umbuchung
|
||||||
|
* doppelt zeigen: einmal als schon fertige Umbuchung (eigene Seite oder
|
||||||
|
* Gegenseite), einmal als einzeln erfasste Buchung mit demselben Betrag –
|
||||||
|
* etwa weil die Bank sie erst Tage später gebucht hat und der automatische
|
||||||
|
* Abgleich sie deshalb nicht zusammenführte.
|
||||||
|
*
|
||||||
|
* Die Umbuchung bleibt bestehen, die einzelne Buchung fällt weg; ihr
|
||||||
|
* Bankumsatz hängt sich stattdessen an die Umbuchung, wie es ein
|
||||||
|
* automatischer Abgleich auch täte.
|
||||||
|
*/
|
||||||
|
public static function verknuepfen(int $kontoId, int $idA, int $idB): void
|
||||||
|
{
|
||||||
|
$a = self::find($idA);
|
||||||
|
$b = self::find($idB);
|
||||||
|
if ($a === null || $b === null) {
|
||||||
|
throw new \RuntimeException('Eine der beiden Buchungen gibt es nicht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$istUmbuchungHier = static fn(array $z): bool => $z['gegenkonto_id'] !== null
|
||||||
|
&& (int)$z['gesplittet'] === 0
|
||||||
|
&& ((int)$z['konto_id'] === $kontoId || (int)$z['gegenkonto_id'] === $kontoId);
|
||||||
|
|
||||||
|
$istEinzelnHier = static fn(array $z): bool => $z['gegenkonto_id'] === null
|
||||||
|
&& (int)$z['gesplittet'] === 0
|
||||||
|
&& (int)$z['konto_id'] === $kontoId;
|
||||||
|
|
||||||
|
[$umbuchung, $einzeln] = match (true) {
|
||||||
|
$istUmbuchungHier($a) && $istEinzelnHier($b) => [$a, $b],
|
||||||
|
$istUmbuchungHier($b) && $istEinzelnHier($a) => [$b, $a],
|
||||||
|
default => throw new \RuntimeException(
|
||||||
|
'Verknüpfen geht nur zwischen einer Umbuchung und einer einzeln erfassten '
|
||||||
|
. 'Buchung auf diesem Konto.'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auf der Gegenkontoseite zählt der Betrag mit umgekehrtem Vorzeichen,
|
||||||
|
// wie im Kontoblatt auch – ein grober Kontrollblick, kein Ersatz für die
|
||||||
|
// eigene Prüfung der Auswahl.
|
||||||
|
$vorzeichen = (int)$umbuchung['konto_id'] === $kontoId ? 1 : -1;
|
||||||
|
$effektiv = $vorzeichen * (float)$umbuchung['betrag'];
|
||||||
|
if (abs($effektiv - (float)$einzeln['betrag']) >= self::CENT) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Die Beträge der beiden Buchungen passen nicht zusammen – bitte die Auswahl prüfen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::transaktion(static function () use ($umbuchung, $einzeln): void {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE bank_umsaetze SET buchung_id = ? WHERE buchung_id = ? AND benutzer_id = ?',
|
||||||
|
[(int)$umbuchung['id'], (int)$einzeln['id'], Mandant::id()]
|
||||||
|
);
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?',
|
||||||
|
[(int)$umbuchung['id'], Mandant::id()]
|
||||||
|
);
|
||||||
|
self::loeschen((int)$einzeln['id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Das Kontoblatt: eigene Buchungen und die Gegenseiten fremder Umbuchungen,
|
* Das Kontoblatt: eigene Buchungen und die Gegenseiten fremder Umbuchungen,
|
||||||
* chronologisch mit laufendem Saldo.
|
* chronologisch mit laufendem Saldo.
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,9 @@ $seitenPfad = static function (int $nr) use ($filter): string {
|
||||||
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
|
// Nach dem Löschen soll dieselbe Ansicht wieder erscheinen – Filter und Seite
|
||||||
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
|
// hängen deshalb am Ziel des Löschformulars. Dasselbe gilt für die
|
||||||
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
|
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
|
||||||
$abfrage = $seitenPfad($seite);
|
$abfrage = $seitenPfad($seite);
|
||||||
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
|
$loeschZiel = '/konten/' . (int)$konto['id'] . '/buchungen/loeschen' . $abfrage;
|
||||||
|
$verknuepfenZiel = '/konten/' . (int)$konto['id'] . '/buchungen/verknuepfen' . $abfrage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
|
* Die Zuordnung einer Zeile als Wert der gemeinsamen Auswahlliste – dieselben
|
||||||
|
|
@ -135,7 +136,19 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
data-frage-eine="Die ausgewählte Buchung wirklich löschen?"
|
data-frage-eine="Die ausgewählte Buchung wirklich löschen?"
|
||||||
data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?">
|
data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?">
|
||||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken ?? '') ?>">
|
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken ?? '') ?>">
|
||||||
|
<?php /* Der erste Knopf im Formular bleibt „Auswahl löschen“ –
|
||||||
|
kontoblatt.js/app.js greift sich per querySelector den
|
||||||
|
ersten Absende-Knopf, um ihn nach Anzahl der Auswahl zu
|
||||||
|
beschriften und zu sperren. Verknüpfen nutzt dasselbe
|
||||||
|
Formular und Ankreuzfeld, nur mit anderem Ziel: zwei
|
||||||
|
ausgewählte Buchungen, die denselben Vorgang doppelt
|
||||||
|
zeigen (eine Umbuchung und ihre einzeln erfasste
|
||||||
|
Dublette), führt es zusammen. */ ?>
|
||||||
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
|
<button type="submit" class="knopf gefahr">Auswahl löschen</button>
|
||||||
|
<button type="submit" formaction="<?= View::h($verknuepfenZiel) ?>" class="knopf"
|
||||||
|
data-bestaetigen="Beide ausgewählten Buchungen zu einer Umbuchung verknüpfen? Die einzeln erfasste Buchung wird dabei gelöscht.">
|
||||||
|
Verknüpfen
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<span class="fuellung"></span>
|
<span class="fuellung"></span>
|
||||||
|
|
@ -222,11 +235,19 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php foreach ($zeilen as $zeile): ?>
|
<?php foreach ($zeilen as $zeile): ?>
|
||||||
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
|
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
|
||||||
<td class="wahlspalte">
|
<td class="wahlspalte">
|
||||||
<?php if (!$zeile['gegensicht']): ?>
|
<?php
|
||||||
<input type="checkbox" name="buchungen[]" value="<?= (int)$zeile['id'] ?>"
|
// Auch die Gegenseite einer fremden Umbuchung lässt sich
|
||||||
form="buchungsauswahl"
|
// ankreuzen – gelöscht wird sie dadurch nicht (das prüft
|
||||||
aria-label="Buchung vom <?= View::h(View::datum($zeile['datum'])) ?> auswählen">
|
// der Controller anhand ihres wirklichen Kontos), aber
|
||||||
<?php endif; ?>
|
// verknüpfen lässt sie sich mit einer einzeln erfassten
|
||||||
|
// Dublette auf diesem Konto.
|
||||||
|
$auswahlLabel = $zeile['gegensicht']
|
||||||
|
? 'Umbuchung vom ' . View::datum($zeile['datum']) . ' auswählen'
|
||||||
|
: 'Buchung vom ' . View::datum($zeile['datum']) . ' auswählen';
|
||||||
|
?>
|
||||||
|
<input type="checkbox" name="buchungen[]" value="<?= (int)$zeile['id'] ?>"
|
||||||
|
form="buchungsauswahl"
|
||||||
|
aria-label="<?= View::h($auswahlLabel) ?>">
|
||||||
</td>
|
</td>
|
||||||
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
||||||
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
|
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
|
||||||
|
|
|
||||||
210
tests/44_BuchungenVerknuepfenTest.php
Normal file
210
tests/44_BuchungenVerknuepfenTest.php
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue