Merge pull request 'Fix n.n.: Buchungen im Kontoblatt lassen sich manuell verknüpfen' (#82) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/82
This commit is contained in:
fds2610 2026-09-27 15:03:43 +02:00
commit 09fd8af15f
7 changed files with 368 additions and 9 deletions

View file

@ -1,5 +1,32 @@
# Ä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)
Nach Kategorie oder Klasse gruppiert erscheint eine Splitbuchung weiterhin mit jeder

View file

@ -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}/buchungen/loeschen',
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->post('/buchungen/{id}', fn(array $p) => (new BuchungenController())->speichern($p));
$router->post('/buchungen/{id}/loeschen', fn(array $p) => (new BuchungenController())->loeschen($p));

View file

@ -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
// anderer Handler das Ereignis unterwegs stoppt.
document.addEventListener('submit', function (ereignis) {
@ -38,7 +41,8 @@
return;
}
const frage = formular.dataset.bestaetigen;
const frage = (ereignis.submitter && ereignis.submitter.dataset.bestaetigen)
|| formular.dataset.bestaetigen;
if (frage && !window.confirm(frage)) {
ereignis.preventDefault();
ereignis.stopPropagation();

View file

@ -500,6 +500,41 @@ class BuchungenController extends BaseController
: $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.
*

View file

@ -266,6 +266,66 @@ class Buchung
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,
* chronologisch mit laufendem Saldo.

View file

@ -49,6 +49,7 @@ $seitenPfad = static function (int $nr) use ($filter): string {
// Schnellzuordnung: Ihre Ziele setzt kontoblatt.js aus dieser Abfrage zusammen.
$abfrage = $seitenPfad($seite);
$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
@ -135,7 +136,19 @@ include __DIR__ . '/../layout/kopf.php';
data-frage-eine="Die ausgewählte Buchung wirklich löschen?"
data-frage-mehrere="{anzahl} ausgewählte Buchungen wirklich löschen?">
<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" 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>
<?php endif; ?>
<span class="fuellung"></span>
@ -222,11 +235,19 @@ include __DIR__ . '/../layout/kopf.php';
<?php foreach ($zeilen as $zeile): ?>
<tr class="<?= $zeile['gegensicht'] ? 'gegensicht' : '' ?>">
<td class="wahlspalte">
<?php if (!$zeile['gegensicht']): ?>
<?php
// Auch die Gegenseite einer fremden Umbuchung lässt sich
// ankreuzen – gelöscht wird sie dadurch nicht (das prüft
// der Controller anhand ihres wirklichen Kontos), aber
// 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="Buchung vom <?= View::h(View::datum($zeile['datum'])) ?> auswählen">
<?php endif; ?>
aria-label="<?= View::h($auswahlLabel) ?>">
</td>
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td>

View 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;
}
}