Finanzmanager/tests/61_UmbuchungskontenAuswahlTest.php
fds2610-bot c58837dee8 Fix #124: Umbuchungskonten in jeder Kategorieauswahl
Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je
Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()):
Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige
Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht
und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie /
Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel
entfällt.

Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge
und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes
inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:30:44 +02:00

313 lines
14 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\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Buchungsvorlage;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Kontengruppe;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Vertrag;
use Finanzmanager\Models\Zahlungsauftrag;
use Finanzmanager\Services\Auswertung;
use Finanzmanager\Services\BuchungsExport;
/**
* Umbuchungskonten in jeder Kategorieauswahl (#124).
*
* Unter den Kategorien stehen überall die aktiven Umbuchungskonten, sortiert
* und beschriftet nach Kontengruppe – in der Buchungsmaske, bei den Regeln
* samt ihren Splitzeilen, im Umsatzabgleich, bei Vorlagen, regelmäßigen
* Buchungen, Überweisungen und Verträgen sowie in den Filtern von
* Buchungsbericht und Export.
*/
class UmbuchungskontenAuswahlTest extends TestCase
{
protected string $testName = 'Test 61: Umbuchungskonten in der Kategorieauswahl';
private int $giro = 0;
private int $tagesgeld = 0;
private int $bauspar = 0;
private int $alt = 0;
private int $lebensmittel = 0;
public function run(): void
{
$this->mandantAnlegen();
// Die Gruppen in umgekehrter Namensreihenfolge sortiert: Die Liste muss
// der Sortierung der Kontengruppen folgen, nicht dem Alphabet.
$sparen = Kontengruppe::anlegen('Sparen', '', 1);
$liquide = Kontengruppe::anlegen('Liquide Mittel', '', 2);
$this->giro = Konto::anlegen([
'name' => 'Auswahl Giro', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
'kontengruppe_id' => $liquide,
]);
$this->tagesgeld = Konto::anlegen([
'name' => 'Auswahl Tagesgeld', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
'kontengruppe_id' => $sparen,
]);
$this->bauspar = Konto::anlegen([
'name' => 'Auswahl Bausparen', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->alt = Konto::anlegen([
'name' => 'Auswahl Altkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 0,
'kontengruppe_id' => $sparen,
]);
$this->lebensmittel = Kategorie::anlegen(['name' => 'Auswahl Lebensmittel', 'art' => 'ausgabe']);
$this->gruppenNurAktivNachKontengruppe();
$this->auswahllisteZeigtUmbuchungskonten();
$this->formularwerteHinUndZurueck();
$this->regelsplitsMitGegenkonto();
$this->regelmaskeHatGemeinsameListe();
$this->buchungsberichtFiltertNachUmbuchungskonto();
$this->exportFiltertNachUmbuchungskonto();
$this->ueberweisungAufEigenesKonto();
$this->vertragUndVorlageMitGegenkonto();
}
private function gruppenNurAktivNachKontengruppe(): void
{
$gruppen = Konto::umbuchungsgruppen(Konto::alle());
$this->assertEquals(
['Umbuchungskonten – Sparen', 'Umbuchungskonten – Liquide Mittel', 'Umbuchungskonten – nicht gruppiert'],
array_keys($gruppen),
'Die Umbuchungskonten stehen je Kontengruppe in deren Reihenfolge'
);
$namen = array_merge(...array_map(
static fn(array $konten): array => array_column($konten, 'name'),
array_values($gruppen)
));
$this->assert(!in_array('Auswahl Altkonto', $namen, true), 'Ein inaktives Konto wird nicht angeboten');
$mitAltem = Konto::umbuchungsgruppen(Konto::alle(), [$this->alt]);
$this->assertEquals(
['Auswahl Altkonto', 'Auswahl Tagesgeld'],
array_column($mitAltem['Umbuchungskonten – Sparen'], 'name'),
'Ein schon gewähltes inaktives Konto bleibt in seiner Gruppe stehen'
);
$ohneGruppen = Konto::umbuchungsgruppen([
['id' => 1, 'name' => 'A', 'aktiv' => 1, 'gruppenname' => null],
]);
$this->assertEquals(['Umbuchungskonten'], array_keys($ohneGruppen), 'Ohne Kontengruppen eine einzige Überschrift');
}
private function auswahllisteZeigtUmbuchungskonten(): void
{
$html = $this->auswahlliste('kategorie:' . $this->lebensmittel, $this->giro);
$this->assert(str_contains($html, '<optgroup label="Kategorien">'), 'Die Kategorien stehen oben');
$kategorien = strpos($html, 'Auswahl Lebensmittel');
$tagesgeld = strpos($html, '[Auswahl Tagesgeld]');
$this->assert(
$kategorien !== false && $tagesgeld !== false && $kategorien < $tagesgeld,
'Die Umbuchungskonten stehen unter den Kategorien'
);
$this->assert(
str_contains($html, '<optgroup label="Umbuchungskonten – Sparen">'),
'Die Umbuchungskonten sind nach Kontengruppe beschriftet'
);
$this->assert(!str_contains($html, '[Auswahl Giro]'), 'Das eigene Konto wird nicht angeboten');
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->alt . '" hidden>#', $html),
'Ein inaktives Konto bleibt nur als verborgener Eintrag für bestehende Zuordnungen'
);
$gewaehlt = $this->auswahlliste('konto:' . $this->alt, 0);
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->alt . '" selected>#', $gewaehlt),
'Ein schon gewähltes inaktives Konto steht sichtbar und gewählt da'
);
}
private function formularwerteHinUndZurueck(): void
{
$this->assertEquals('konto:7', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => 7]),
'Das Gegenkonto sticht die Kategorie');
$this->assertEquals('kategorie:3', BuchungenController::zuordnungswert(['kategorie_id' => 3, 'gegenkonto_id' => null]),
'Ohne Gegenkonto die Kategorie');
$this->assertEquals('', BuchungenController::zuordnungswert(null), 'Ohne Zeile kein Wert');
$this->assertEquals(
['kategorie_id' => null, 'gegenkonto_id' => 7],
BuchungenController::zuordnungAusFormular(['zuordnung' => 'konto:7', 'kategorie_id' => '3']),
'Die gemeinsame Liste hat Vorrang vor einzelnen Feldern'
);
$this->assertEquals(
['kategorie_id' => 3, 'gegenkonto_id' => null],
BuchungenController::zuordnungAusFormular(['kategorie_id' => '3', 'gegenkonto_id' => '']),
'Ohne Liste gelten die versteckten Einzelfelder einer Tabellenzeile'
);
}
private function regelsplitsMitGegenkonto(): void
{
$id = AbgleichRegel::anlegen([
'name' => 'Sparplan aufteilen', 'feld' => 'empfaenger', 'vergleich' => 'enthaelt', 'muster' => 'Sparplan',
'splits' => [
['kategorie_id' => $this->lebensmittel, 'anteil' => 0.25],
['gegenkonto_id' => $this->tagesgeld, 'anteil' => 0.75],
['kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->bauspar, 'anteil' => 0.0],
],
]);
$regel = AbgleichRegel::find($id);
$zeilen = AbgleichRegel::splitzeilen($regel);
$this->assertEquals(2, count($zeilen), 'Eine Zeile ohne Anteil fällt weg, eine mit Gegenkonto bleibt');
$this->assertEquals($this->tagesgeld, $zeilen[1]['gegenkonto_id'], 'Die Splitzeile behält ihr Gegenkonto');
$this->assertNull($zeilen[1]['kategorie_id'], 'Die Umbuchungszeile führt keine Kategorie');
$zuordnung = AbgleichRegel::anwenden($regel, ['betrag' => -100.0]);
$this->assertEquals($this->tagesgeld, $zuordnung['splits'][1]['gegenkonto_id'], 'anwenden() reicht das Gegenkonto weiter');
$this->assertEquals(-75.0, $zuordnung['splits'][1]['betrag'], 'Der Anteil wird auch für die Umbuchung umgerechnet');
$buchung = Buchung::anlegen(
['konto_id' => $this->giro, 'datum' => date('Y-m-d'), 'betrag' => -100.0, 'empfaenger' => 'Sparplan'],
$zuordnung['splits']
);
$splits = Buchung::splits($buchung);
$this->assertEquals($this->tagesgeld, (int)$splits[1]['gegenkonto_id'], 'Die Splitbuchung aus der Regel bucht auf das Umbuchungskonto');
$neu = AbgleichRegel::find((int)AbgleichRegel::ausBuchung($buchung));
$this->assertEquals(
$this->tagesgeld,
AbgleichRegel::splitzeilen($neu)[1]['gegenkonto_id'],
'Eine Regel aus der Buchung übernimmt die Umbuchungszeile'
);
}
private function regelmaskeHatGemeinsameListe(): void
{
$regel = AbgleichRegel::find(AbgleichRegel::anlegen([
'name' => 'Umbuchung', 'muster' => 'Tagesgeld', 'gegenkonto_id' => $this->tagesgeld,
]));
$daten = [
'regel' => $regel,
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
'idPrefix' => 'test-',
];
extract($daten, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/src/Views/banking/regelfelder.php';
$html = (string)ob_get_clean();
$this->assert(str_contains($html, 'name="zuordnung"'), 'Regel anlegen: eine Liste Kategorie / Gegenkonto');
$this->assert(!str_contains($html, 'name="gegenkonto_id"'), 'Das getrennte Gegenkonto-Feld entfällt');
$this->assert(
(bool)preg_match('#<option value="konto:' . $this->tagesgeld . '" selected>#', $html),
'Das Gegenkonto der Regel ist vorausgewählt'
);
$this->assert(
str_contains($html, 'name="split[0][zuordnung]"'),
'Die Splitzeilen der Regel bieten ebenfalls Umbuchungskonten an'
);
}
private function buchungsberichtFiltertNachUmbuchungskonto(): void
{
$datum = '2026-03-10';
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -50.0,
'empfaenger' => 'Sparrate', 'gegenkonto_id' => $this->tagesgeld]);
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -20.0,
'empfaenger' => 'Markt', 'kategorie_id' => $this->lebensmittel]);
Buchung::anlegen(['konto_id' => $this->giro, 'datum' => $datum, 'betrag' => -30.0,
'empfaenger' => 'Bausparrate', 'gegenkonto_id' => $this->bauspar]);
$filter = Auswertung::normalisiere([
'typ' => 'buchungsbericht', 'kategorien' => ['konto:' . $this->tagesgeld, (string)$this->lebensmittel],
]);
$this->assertEquals([$this->tagesgeld], $filter['gegenkonten'], 'konto:… aus der Kategorieauswahl wird zum Umbuchungskonto');
$this->assertEquals([$this->lebensmittel], $filter['kategorien'], 'Die Kategorien bleiben daneben erhalten');
$summe = Auswertung::normalisiere(['typ' => 'summenbericht', 'kategorien' => ['konto:' . $this->tagesgeld]]);
$this->assertEquals([], $summe['gegenkonten'], 'Nur der Buchungsbericht kennt Umbuchungskonten im Filter');
$bericht = Auswertung::bericht(['zeitraum' => 'benutzerdefiniert', 'von' => $datum, 'bis' => $datum] + $filter);
$empfaenger = [];
foreach ($bericht['gruppen'] as $gruppe) {
$empfaenger = array_merge($empfaenger, array_column($gruppe['buchungen'], 'empfaenger'));
}
sort($empfaenger);
$this->assertEquals(
['Markt', 'Sparrate'],
$empfaenger,
'Der Buchungsbericht zeigt Kategorie und gewähltes Umbuchungskonto – auch ohne „Umbuchungen mitzählen“'
);
}
private function exportFiltertNachUmbuchungskonto(): void
{
$basis = ['von' => '2026-03-10', 'bis' => '2026-03-10'];
$this->assertEquals(1, BuchungsExport::anzahl($basis + ['gegenkonten' => [$this->bauspar]]),
'Export: ein Umbuchungskonto filtert seine Umbuchungen');
$this->assertEquals(
2,
BuchungsExport::anzahl($basis + ['kategorien' => [$this->lebensmittel], 'gegenkonten' => [$this->bauspar]]),
'Export: Kategorie und Umbuchungskonto aus derselben Auswahl verbinden sich mit „oder“'
);
}
private function ueberweisungAufEigenesKonto(): void
{
$id = Zahlungsauftrag::anlegen([
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
'betrag' => 100.0, 'kategorie_id' => $this->lebensmittel, 'gegenkonto_id' => $this->tagesgeld,
]);
$auftrag = Zahlungsauftrag::find($id);
$this->assertEquals($this->tagesgeld, (int)$auftrag['gegenkonto_id'], 'Die Überweisung merkt sich das Umbuchungskonto');
$this->assertNull($auftrag['kategorie_id'], 'Kategorie und Gegenkonto schließen sich aus');
Zahlungsauftrag::alsGesendetVermerken($id, 'Angenommen.');
$buchung = Buchung::find((int)Zahlungsauftrag::find($id)['buchung_id']);
$this->assertEquals($this->tagesgeld, (int)$buchung['gegenkonto_id'], 'Gesendet wird sie als Umbuchung gebucht');
$abgewiesen = false;
try {
Zahlungsauftrag::anlegen([
'konto_id' => $this->giro, 'empfaenger' => 'Ich selbst', 'iban' => 'DE02120300000000202051',
'betrag' => 1.0, 'gegenkonto_id' => $this->giro,
]);
} catch (\InvalidArgumentException) {
$abgewiesen = true;
}
$this->assert($abgewiesen, 'Eine Überweisung auf das Auftragskonto selbst wird schon beim Erfassen abgewiesen');
}
private function vertragUndVorlageMitGegenkonto(): void
{
$vertrag = Vertrag::find(Vertrag::anlegen([
'name' => 'Bausparvertrag', 'betrag' => 50, 'gegenkonto_id' => $this->bauspar,
]));
$this->assertEquals($this->bauspar, (int)$vertrag['gegenkonto_id'], 'Ein Vertrag kann auf ein Umbuchungskonto zeigen');
Buchungsvorlage::anlegen(['name' => 'Auswahl Sparrate', 'konto_id' => $this->giro, 'betrag' => -50,
'gegenkonto_id' => $this->tagesgeld]);
$vorlagen = array_column(Buchungsvorlage::alle('Auswahl Sparrate'), 'gegenkontoname', 'name');
$this->assertEquals('Auswahl Tagesgeld', $vorlagen['Auswahl Sparrate'] ?? null,
'Die Vorlagenliste kennt den Namen des Umbuchungskontos');
}
/** Die Auswahlliste rendern, wie sie jede Maske einbindet. */
private function auswahlliste(string $gewaehlt, int $eigenesKonto): string
{
$kategorien = Kategorie::baumFlach(null, true);
$konten = Konto::alle();
$mitSplit = false;
$mitDepot = false;
ob_start();
include dirname(__DIR__) . '/src/Views/buchungen/zuordnungsliste.php';
return (string)ob_get_clean();
}
}