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>
This commit is contained in:
fds2610-bot 2026-10-12 21:30:44 +02:00
parent 0e8000e3dc
commit c58837dee8
37 changed files with 859 additions and 206 deletions

View file

@ -8,6 +8,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
### Added
- Kategorieauswahl: Unter den Kategorien stehen überall die aktiven Umbuchungskonten,
sortiert und gruppiert nach Kontengruppe – in Regeln samt Splitzeilen, Umsatzabgleich,
Buchungsvorlagen, Regelmäßigen Buchungen, Zahlungsverkehr, Verträgen sowie in den
Filtern von Buchungsbericht und Export (#124)
- Buchungsmaske: Schalter „Bewusst ohne Kategorie“ für Abschreibungen und rein rechnerische
Erträge; solche Buchungen gelten im Cockpit, im Monatsabschluss und in der
Kontenübersicht nicht als offen (#134)

View file

@ -1,5 +1,16 @@
# Änderungen
## Umbuchungskonten in jeder Kategorieauswahl (12.10.2026)
- Anlass (#124): In den Splitzeilen der Regeln, beim Anlegen einer Regel, im Filter des Buchungsberichts und weiteren Listen fehlten die Umbuchungskonten unter den Kategorien.
- Die gemeinsame Liste *Kategorie / Gegenkonto* der Buchungsmaske (`Views/buchungen/zuordnungsliste.php`) steht jetzt überall, wo eine Kategorie gewählt wird: Regeln (Neue Regel, Regeltabelle, Regeldialog im Kontoblatt) samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und Verträge. Das getrennte Feld „Gegenkonto“ der Regel entfällt; die Regeltabelle verlor bisher beim Speichern ein Gegenkonto, weil sie es gar nicht mitschickte.
- Angeboten werden nur aktive Umbuchungskonten, in eigenen Gruppen je Kontengruppe und in deren Reihenfolge (`Konto::umbuchungsgruppen()`). Ein schon gewähltes inaktives Konto bleibt sichtbar; die übrigen inaktiven stehen verborgen in der Liste, damit bestehende Splitzeilen beim Bearbeiten nicht verloren gehen (`kombobox.js` blendet sie aus).
- Regel-Splitzeilen können auf ein Umbuchungskonto zeigen; daraus wird eine Splitzeile mit Gegenkonto. Auch „Regel anlegen“ aus einer Buchung übernimmt solche Zeilen.
- Buchungsbericht: Umbuchungskonten stehen unter den Kategorien im Filter und bringen ihre Umbuchungen auch ohne „Umbuchungen mitzählen“ mit. Export: Umbuchungskonten unter den Kategorien (`gegenkonten[]`).
- Zahlungsaufträge und Verträge bekommen ein Gegenkonto (Migration `030_gegenkonto_auftrag_vertrag.sql`); eine Überweisung aufs eigene Tagesgeld wird beim Senden als Umbuchung gebucht.
- Nicht betroffen: Auswahllisten, die keine Buchung zuordnen (Wertpapierkosten in den Einstellungen, Bargeld-Tool, Ober-/Zielkategorie in der Kategorieverwaltung, Budgetplaner).
- Tests: `tests/61_UmbuchungskontenAuswahlTest.php`, `tests/e2e/specs/regelausbuchung.spec.js`.
## Regelmäßige Buchungen: automatisch abgleichen (12.10.2026)
- Anlass (#123): Buchungen aus regelmäßigen Buchungen mussten nach dem Buchen einzeln abgeglichen werden.

View file

@ -113,6 +113,13 @@ Kategorien zeigen die Summe ihres Astes.
Eine gewählte Kategorie **schließt ihre Unterkategorien ein** – wer „400 - Leben“ filtert,
meint den ganzen Ast. Nichts ausgewählt heißt alles.
Im **Buchungsbericht** stehen unter den Kategorien zusätzlich die aktiven Umbuchungskonten,
je Kontengruppe (#124). Ihr Wert `konto:7` landet im Filter als `gegenkonten`; eine
Bewegung passt, wenn sie zu einer gewählten Kategorie **oder** einem gewählten
Umbuchungskonto gehört. Ein gewähltes Umbuchungskonto bringt seine Umbuchungen auch ohne
„Umbuchungen mitzählen“ mit – sonst bliebe die Auswahl leer. Die übrigen Berichte zählen
Umbuchungen nicht und bieten die Konten deshalb nicht an.
Zwei Berichte bestimmen ihre Zeilen selbst und sperren das Feld: der Kontostandsbericht
führt immer Konten, der Budgetbericht immer Kategorien. Zwei bestimmen ihre Spalten
selbst: Vergleichs- und Budgetbericht.

View file

@ -453,13 +453,17 @@ Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck:
so.
* **Betragsgrenzen** rechnen mit Vorzeichen: „von −60 bis −40“ trifft Lastschriften
zwischen 40 und 60 Euro.
* **Wirkung**: Kategorie, Klasse, Gegenkonto (macht daraus eine Umbuchung) oder eine
**Splitbuchung**.
* **Wirkung**: Kategorie oder Gegenkonto (macht daraus eine Umbuchung) aus der
gemeinsamen Liste *Kategorie / Gegenkonto*, dazu eine Klasse – oder eine
**Splitbuchung**. Die Liste ist dieselbe wie in der Buchungsmaske: unter den Kategorien
die aktiven Umbuchungskonten je Kontengruppe (#124).
### Splitbuchung in einer Regel
Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie, wahlweise
Klasse und Notiz, dazu ein Anteil in Prozent. Greift die Regel, entsteht keine Buchung mit
Unter jeder Regel klappt *Splitbuchung* die Zeilen auf: je Zeile eine Kategorie oder ein
Umbuchungskonto, wahlweise Klasse und Notiz, dazu ein Anteil in Prozent. Eine Zeile mit
Umbuchungskonto wird zur Splitzeile mit Gegenkonto – etwa der Sparanteil einer Überweisung,
die sonst Lebensmittel bezahlt. Greift die Regel, entsteht keine Buchung mit
einer Kategorie, sondern eine Splitbuchung mit diesen Zeilen.
Die Anteile gelten **zueinander**, nicht als feste Beträge – darin liegt der ganze Zweck:
@ -473,7 +477,7 @@ Fehler wäre in der fertigen Buchung nicht mehr zu sehen. Die letzte Zeile bekom
den Rundungsrest, damit die Summe auf den Cent zum Buchungsbetrag passt – bei einem Drittel
zu zwei Dritteln ginge sonst ein Cent verloren, und die Buchung ließe sich nicht speichern.
Eine Zeile ohne Kategorie oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel
Eine Zeile ohne Kategorie bzw. Gegenkonto oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die Regel
wie zuvor auf ihre Kategorie. Im Abgleichdialog zeigt der Vorschlag die errechneten Zeilen
mit ihren Beträgen an und übernimmt sie unverändert – geändert wird eine Aufteilung im
Kontoblatt, wo die ganze Buchung samt Summenprüfung vorliegt.

View file

@ -57,10 +57,14 @@ nicht. Deshalb stehen Kategorien und Konten in derselben Auswahlliste; die Konte
in eckigen Klammern, wie das Kategoriefeld im FinanzManager sie schreibt. Das eigene Konto
fehlt in der Liste.
Die Liste steht in `Views/buchungen/zuordnungsliste.php` und wird an vier Stellen
eingebunden: Buchungsmaske, deren Splitzeilen, Schnellzuordnung im Kontoblatt und
Splitdialog. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
auseinandernimmt:
Die Liste steht in `Views/buchungen/zuordnungsliste.php`. Sie wird nicht nur in der
Buchungsmaske, deren Splitzeilen, der Schnellzuordnung im Kontoblatt und im Splitdialog
eingebunden, sondern überall, wo eine Kategorie gewählt wird (#124): Regeln samt ihren
Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige Buchungen, Zahlungsverkehr und
Verträge. Die Werte tragen ein Präfix, das `BuchungenController::zuordnungAus()` wieder
auseinandernimmt (`zuordnungAusFormular()` nimmt ersatzweise die versteckten Einzelfelder
`kategorie_id`/`gegenkonto_id` einer Tabellenzeile, `zuordnungswert()` baut den Wert für die
Vorauswahl):
| Wert | Bedeutung |
|---|---|
@ -72,6 +76,17 @@ auseinandernimmt:
Die Auswahl *Ausgabe / Einnahme* bestimmt seither nur noch die Richtung des Betrags.
Angeboten werden nur **aktive** Umbuchungskonten, je Kontengruppe in einer eigenen Gruppe
(„Umbuchungskonten – Liquide Mittel“, ohne Kontengruppen schlicht „Umbuchungskonten“) und
in der Reihenfolge der Kontengruppen (`Konto::umbuchungsgruppen()`). Ein inaktives Konto,
das schon gewählt ist, bleibt sichtbar stehen – sonst ginge die Zuordnung beim nächsten
Speichern verloren. Alle übrigen inaktiven Konten stehen als verborgene Einträge
(`<option hidden>`) in der Liste, damit `kontoblatt.js` eine bestehende Splitzeile damit
vorbelegen kann; die Kombobox zeigt sie nur an, solange sie gewählt sind.
Die Filter von Buchungsbericht und Export führen die Umbuchungskonten ebenfalls unter den
Kategorien (siehe `docs/auswertungen.md` und `docs/import-export.md`).
Ein Depot steht **zweimal** in der Liste: als gewöhnliche Umbuchung (`konto:7`) und in der
Gruppe *Wertpapiergeschäft im Depot* (`depot:7`). Beides gibt es wirklich – auf ein Depot
lässt sich Geld überweisen, ohne dass etwas gekauft wird. `depot:7` speichert keine

View file

@ -259,6 +259,9 @@ Zwei Eigenheiten des Datenmodells schlagen auf den Filter durch:
Auszug des Gegenkontos seine einzige Bewegung.
* **Kategorie und Klasse zählen auch in den Splitzeilen.** Sonst fiele jede Splitbuchung
aus dem Filter, obwohl sie die gesuchte Kategorie führt.
* **Unter den Kategorien stehen die aktiven Umbuchungskonten** (#124, `gegenkonten[]`).
Ein gewähltes Konto nimmt jede Buchung oder Splitzeile mit diesem Gegenkonto mit;
Kategorien und Umbuchungskonten verbinden sich als „oder“, wie in einer Auswahlliste.
Die Kontenliste der Datei enthält zusätzlich jedes Konto, das nur Umbuchungsziel ist. Ohne
seinen Eintrag bezöge sich die Datei auf ein Konto, das der Import nicht kennt, und die

View file

@ -95,6 +95,10 @@ vierteljährlich × 4 und so fort.
Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine Zahlung erledigt.
Statt einer Kategorie kann ein Vertrag auf ein Umbuchungskonto zeigen – ein Bauspar- oder
Sparvertrag etwa (#124, Spalte `vertraege.gegenkonto_id`). Die Auswahl ist dieselbe Liste
*Kategorie / Gegenkonto* wie in der Buchungsmaske.
## Sparziele
Der erreichte Stand kommt **aus dem Kontosaldo**, nicht aus einem eigenen Zähler – ein
@ -135,6 +139,12 @@ Fehlgeschlagene tragen die Meldung der Bank daneben.
Ohne Ausführungstermin geht die Überweisung sofort hinaus, mit Termin als
Terminüberweisung; sie erscheint dann im Finanzkalender.
**Kategorie / Gegenkonto.** Wie in der Buchungsmaske stehen unter den Kategorien die
aktiven Umbuchungskonten (#124, Spalte `zahlungsauftraege.gegenkonto_id`). Geht die
Überweisung auf ein eigenes Konto – etwa aufs Tagesgeld –, wird sie beim Senden gleich als
Umbuchung gebucht. Das Auftragskonto selbst als Gegenkonto weist schon das Erfassen ab;
erst beim Senden fiele es sonst auf, wenn die Bank das Geld längst hat.
**Echtzeitüberweisung.** Das Häkchen „Echtzeitüberweisung“ im Überweisungsdialog
(Spalte `zahlungsauftraege.echtzeit`) macht aus dem Auftrag eine SEPA-Echtzeitüberweisung
(SCT Inst): Das Geld ist binnen Sekunden beim Empfänger. SEPA behandelt sie als eigenes

View file

@ -0,0 +1,19 @@
-- Umbuchungskonten in jeder Kategorieauswahl (#124).
--
-- Überall, wo eine Kategorie gewählt wird, stehen darunter jetzt auch die
-- aktiven Umbuchungskonten. Buchungen, Splitzeilen, Vorlagen, regelmäßige
-- Buchungen und Regeln kennen das Gegenkonto schon; Zahlungsaufträge und
-- Verträge bekommen es hier. Eine Überweisung auf das eigene Tagesgeldkonto
-- wird so beim Senden gleich als Umbuchung gebucht.
--
-- Idempotent über IF NOT EXISTS.
ALTER TABLE zahlungsauftraege ADD COLUMN IF NOT EXISTS
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
ALTER TABLE zahlungsauftraege ADD CONSTRAINT fk_auftrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL;
ALTER TABLE vertraege ADD COLUMN IF NOT EXISTS
gegenkonto_id INT UNSIGNED NULL AFTER kategorie_id;
ALTER TABLE vertraege ADD CONSTRAINT fk_vertrag_gegenkonto FOREIGN KEY IF NOT EXISTS (gegenkonto_id)
REFERENCES konten (id) ON DELETE SET NULL;

View file

@ -43,8 +43,10 @@
return gruppen;
}
/* Ein verborgener Eintrag (etwa ein inaktives Umbuchungskonto) bleibt als
* Wert gültig, erscheint in der Liste aber nur, solange er gewählt ist. */
function eintragAus(option) {
return { wert: option.value, text: option.textContent.trim() };
return { wert: option.value, text: option.textContent.trim(), verborgen: option.hidden };
}
function passt(text, filter) {
@ -130,7 +132,8 @@
gruppen.forEach(function (gruppe) {
const treffer = gruppe.optionen.filter(function (eintrag) {
return passt(eintrag.text, filterText);
return (!eintrag.verborgen || eintrag.wert === select.value)
&& passt(eintrag.text, filterText);
});
if (treffer.length === 0) {
return;

View file

@ -825,7 +825,7 @@ class BankingController extends BaseController
'umsaetze' => $umsaetze,
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
'konten' => Konto::alle(),
'offen' => Bankumsatz::anzahlOffen(),
]);
}
@ -844,11 +844,12 @@ class BankingController extends BaseController
if ($buchungId > 0) {
Bankumsatz::verbinden($id, $buchungId);
} else {
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
Bankumsatz::uebernehmen($id, [
'konto_id' => $this->post('konto_id') !== '' ? (int)$this->post('konto_id') : null,
'kategorie_id' => $this->post('kategorie_id') !== '' ? (int)$this->post('kategorie_id') : null,
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id') !== '' ? (int)$this->post('klasse_id') : null,
'gegenkonto_id' => $this->post('gegenkonto_id') !== '' ? (int)$this->post('gegenkonto_id') : null,
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'splits' => $this->splitsAusFormular(),
]);
}
@ -938,7 +939,7 @@ class BankingController extends BaseController
'regeln' => AbgleichRegel::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(true),
'konten' => Konto::alle(),
]);
}
@ -1122,6 +1123,8 @@ class BankingController extends BaseController
/** @return array<string,mixed> */
private function regeldaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'feld' => $this->post('feld'),
@ -1129,9 +1132,9 @@ class BankingController extends BaseController
'muster' => $this->post('muster'),
'betrag_von' => $this->post('betrag_von'),
'betrag_bis' => $this->post('betrag_bis'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'splits' => $this->regelsplits(),
'prioritaet' => $this->post('prioritaet', '100'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,
@ -1161,12 +1164,14 @@ class BankingController extends BaseController
}
$prozent = KontenController::zahl((string)($zeile['prozent'] ?? ''));
if (($zeile['kategorie_id'] ?? '') === '' || $prozent <= 0.0) {
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
if (($zuordnung['kategorie_id'] === null && $zuordnung['gegenkonto_id'] === null) || $prozent <= 0.0) {
continue;
}
$splits[] = [
'kategorie_id' => (int)$zeile['kategorie_id'],
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'anteil' => $prozent / 100,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -1280,8 +1285,10 @@ class BankingController extends BaseController
continue;
}
$zuordnung = BuchungenController::zuordnungAusFormular($zeile);
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? '') === '' ? null : (int)$zeile['kategorie_id'],
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => ($zeile['klasse_id'] ?? '') === '' ? null : (int)$zeile['klasse_id'],
'betrag' => (float)str_replace(',', '.', (string)$zeile['betrag']),
'notiz' => (string)($zeile['notiz'] ?? ''),

View file

@ -692,6 +692,46 @@ class BuchungenController extends BaseController
return $splits;
}
/**
* Der umgekehrte Weg zu zuordnungAus(): der Auswahlwert für eine Zeile mit
* `kategorie_id` und `gegenkonto_id` – das Gegenkonto sticht, wie in der
* Buchungsmaske.
*
* @param array<string,mixed>|null $zeile
*/
public static function zuordnungswert(?array $zeile): string
{
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
}
/**
* Kategorie und Gegenkonto aus einem Formular, das entweder die gemeinsame
* Liste `zuordnung` schickt oder – etwa aus versteckten Feldern einer
* Tabellenzeile – die beiden Kennungen einzeln.
*
* @param array<string,mixed> $daten meist $_POST oder eine Splitzeile daraus
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAusFormular(array $daten): array
{
if (array_key_exists('zuordnung', $daten)) {
return self::zuordnungAus((string)$daten['zuordnung']);
}
$id = static fn(mixed $wert): ?int => is_scalar($wert) && (int)$wert > 0 ? (int)$wert : null;
return [
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
];
}
/**
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
* `kategorie:12`, `konto:7` oder leer.

View file

@ -108,7 +108,7 @@ class ExportController extends BaseController
/**
* @return array{von: string, bis: string, konten: list<int>,
* kategorien: list<int>, klassen: list<int>}
* kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>}
*/
private function filter(): array
{
@ -119,6 +119,7 @@ class ExportController extends BaseController
'bis' => $this->datum($this->text('bis')),
'konten' => $this->ids($eingabe['konten'] ?? null),
'kategorien' => $this->ids($eingabe['kategorien'] ?? null),
'gegenkonten' => $this->ids($eingabe['gegenkonten'] ?? null),
'klassen' => $this->ids($eingabe['klassen'] ?? null),
];
}

View file

@ -187,15 +187,19 @@ class TermineController extends BaseController
/** @return array<string,mixed> */
private function formulardaten(): array
{
// Der Dialog „Neue Reihe“ schickt die gemeinsame Liste, die Tabellenzeilen
// Kategorie und Gegenkonto als versteckte Felder.
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
'betrag' => str_replace(',', '.', $this->post('betrag')),
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'intervall' => $this->post('intervall'),
'faktor' => $this->post('faktor', '1'),
'beginn' => $this->post('beginn'),

View file

@ -47,6 +47,7 @@ class VertraegeController extends BaseController
'fristen' => Vertrag::fristenLaufenAb(90),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'konten' => Konto::alle(true),
'reihen' => RegelmaessigeBuchung::alle(),
'intervalle' => RegelmaessigeBuchung::INTERVALLE,
]);
@ -281,12 +282,15 @@ class VertraegeController extends BaseController
/** @return array<string,mixed> */
private function vertragsdaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'partner' => $this->post('partner'),
'adresse_id' => $this->post('adresse_id'),
'vertragsnummer' => $this->post('vertragsnummer'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'betrag' => str_replace(',', '.', $this->post('betrag')),
'intervall' => $this->post('intervall'),
'beginn' => $this->post('beginn'),

View file

@ -110,6 +110,8 @@ class VorlagenController extends BaseController
/** @return array<string,mixed> */
private function formulardaten(): array
{
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'name' => $this->post('name'),
'konto_id' => $this->post('konto_id'),
@ -117,9 +119,9 @@ class VorlagenController extends BaseController
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $this->post('gegenkonto_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
];
}
}

View file

@ -96,6 +96,7 @@ class ZahlungsverkehrController extends BaseController
'konten' => $this->kontenMitBank(),
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'umbuchungskonten' => Konto::alle(),
'klassen' => Klasse::alle(),
]);
}
@ -158,6 +159,7 @@ class ZahlungsverkehrController extends BaseController
'konten' => $konten,
'adressen' => Adresse::alle(),
'kategorien' => Kategorie::baumFlach(null, true),
'umbuchungskonten' => Konto::alle(),
'klassen' => Klasse::alle(),
]);
}
@ -322,6 +324,7 @@ class ZahlungsverkehrController extends BaseController
private function formulardaten(): array
{
$kontoId = (int)$this->post('konto_id');
$zuordnung = BuchungenController::zuordnungAusFormular($_POST);
return [
'konto_id' => $kontoId,
@ -336,7 +339,8 @@ class ZahlungsverkehrController extends BaseController
'verwendungszweck' => $this->post('verwendungszweck'),
'ausfuehrung_am' => $this->post('ausfuehrung_am'),
'echtzeit' => isset($_POST['echtzeit']),
'kategorie_id' => $this->post('kategorie_id'),
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => $this->post('klasse_id'),
];
}

View file

@ -129,6 +129,7 @@ class AbgleichRegel
$splitzeilen = array_values(array_filter(
Buchung::splits($buchungId),
static fn(array $zeile): bool => (int)($zeile['kategorie_id'] ?? 0) !== 0
|| (int)($zeile['gegenkonto_id'] ?? 0) !== 0
));
// Anteile am Buchungsbetrag statt roher Beträge: So zeigt der Dialog
@ -142,7 +143,8 @@ class AbgleichRegel
$splits = [];
foreach ($splitzeilen as $zeile) {
$splits[] = [
'kategorie_id' => (int)$zeile['kategorie_id'],
'kategorie_id' => $zeile['kategorie_id'],
'gegenkonto_id' => $zeile['gegenkonto_id'] ?? null,
'klasse_id' => $zeile['klasse_id'],
'anteil' => $gesamt > 0.0 ? round(abs((float)$zeile['betrag']) / $gesamt, 4) : 0,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -256,6 +258,7 @@ class AbgleichRegel
$splits[] = [
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
'gegenkonto_id' => ($zeile['gegenkonto_id'] ?? null) ?: null,
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
'betrag' => $teil,
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -293,6 +296,7 @@ class AbgleichRegel
$zeilen[] = [
'kategorie_id' => (int)($zeile['kategorie_id'] ?? 0) ?: null,
'gegenkonto_id' => (int)($zeile['gegenkonto_id'] ?? 0) ?: null,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'prozent' => round((float)($zeile['anteil'] ?? 0) * 100, 2),
'notiz' => (string)($zeile['notiz'] ?? ''),
@ -418,8 +422,10 @@ class AbgleichRegel
}
/**
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil.
* Zeilen ohne Kategorie oder ohne Anteil fallen weg.
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie oder ein
* Gegenkonto (Umbuchung) und ein Anteil. Zeilen ohne beides oder ohne
* Anteil fallen weg; steht beides da, sticht das Gegenkonto – wie in der
* gemeinsamen Auswahlliste der Buchungsmaske.
*
* Der Anteil wird nicht auf 1 gedeckelt: Er gilt im Verhältnis zu den
* übrigen Zeilen (siehe `anwenden()`), 100 Prozent sind also keine Grenze,
@ -435,14 +441,16 @@ class AbgleichRegel
continue;
}
$kategorie = (int)($zeile['kategorie_id'] ?? 0);
$gegenkonto = (int)($zeile['gegenkonto_id'] ?? 0);
$anteil = (float)($zeile['anteil'] ?? 0);
if ($kategorie === 0 || $anteil <= 0.0) {
if (($kategorie === 0 && $gegenkonto === 0) || $anteil <= 0.0) {
continue;
}
$zeilen[] = [
'kategorie_id' => $kategorie,
'kategorie_id' => $gegenkonto === 0 ? $kategorie : null,
'gegenkonto_id' => $gegenkonto === 0 ? null : $gegenkonto,
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
'anteil' => round($anteil, 4),
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),

View file

@ -22,10 +22,11 @@ class Buchungsvorlage
public static function alle(string $suche = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad
$sql = 'SELECT v.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname
FROM buchungsvorlagen v
LEFT JOIN konten k ON k.id = v.konto_id
LEFT JOIN kategorien kat ON kat.id = v.kategorie_id
LEFT JOIN konten g ON g.id = v.gegenkonto_id
WHERE v.benutzer_id = ?';
if ($suche !== '') {

View file

@ -71,6 +71,45 @@ class Konto
);
}
/**
* Die Umbuchungskonten, wie sie unter den Kategorien jeder Auswahlliste
* stehen (#124): nur aktive Konten, je Kontengruppe beschriftet und in der
* Reihenfolge von alle(). Ein inaktives Konto erscheint nur, wenn es schon
* gewählt ist – sonst ginge eine bestehende Zuordnung beim Speichern
* verloren.
*
* @param list<array<string,mixed>> $konten meist alle()
* @param list<int> $gewaehlt Kennungen, die immer bleiben
* @return array<string, list<array<string,mixed>>> Beschriftung => Konten
*/
public static function umbuchungsgruppen(array $konten, array $gewaehlt = []): array
{
$gewaehlt = array_flip(array_map('intval', $gewaehlt));
$gruppen = [];
$mitGruppen = false;
foreach ($konten as $konto) {
if ((int)($konto['aktiv'] ?? 1) !== 1 && !isset($gewaehlt[(int)$konto['id']])) {
continue;
}
$gruppe = $konto['gruppenname'] ?? null;
$mitGruppen = $mitGruppen || $gruppe !== null;
$gruppen[(string)$gruppe][] = $konto;
}
// Ohne eine einzige Kontengruppe reicht eine Überschrift.
if (!$mitGruppen) {
return $gruppen === [] ? [] : ['Umbuchungskonten' => $gruppen['']];
}
$beschriftet = [];
foreach ($gruppen as $gruppe => $eintraege) {
$beschriftet['Umbuchungskonten – ' . ($gruppe === '' ? 'nicht gruppiert' : $gruppe)] = $eintraege;
}
return $beschriftet;
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(

View file

@ -50,12 +50,12 @@ class Vertrag
Database::execute(
'INSERT INTO vertraege
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, betrag,
intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, gegenkonto_id,
betrag, intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'],
$f['kategorie_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
$f['kategorie_id'], $f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
$f['kuendigungsfrist_tage'], $f['regelmaessige_id'], $f['notiz'], $f['aktiv'],
]
);
@ -70,12 +70,12 @@ class Vertrag
Database::execute(
'UPDATE vertraege SET name = ?, partner = ?, adresse_id = ?, vertragsnummer = ?,
kategorie_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
kategorie_id = ?, gegenkonto_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
kuendigungsfrist_tage = ?, regelmaessige_id = ?, notiz = ?, aktiv = ?
WHERE id = ? AND benutzer_id = ?',
[
$f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'], $f['kategorie_id'],
$f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
$f['gegenkonto_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
$f['regelmaessige_id'], $f['notiz'], $f['aktiv'], $id, Mandant::id(),
]
);
@ -172,7 +172,9 @@ class Vertrag
'partner' => mb_substr(trim((string)($daten['partner'] ?? '')), 0, 255),
'adresse_id' => $id($daten['adresse_id'] ?? null),
'vertragsnummer' => mb_substr(trim((string)($daten['vertragsnummer'] ?? '')), 0, 64),
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
// Kategorie oder Umbuchungskonto (etwa ein Bausparvertrag) – nie beides.
'kategorie_id' => $id($daten['gegenkonto_id'] ?? null) === null ? $id($daten['kategorie_id'] ?? null) : null,
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
'intervall' => array_key_exists($intervall, RegelmaessigeBuchung::INTERVALLE)
? $intervall : 'monatlich',

View file

@ -30,11 +30,12 @@ class Zahlungsauftrag
public static function alle(string $status = ''): array
{
$params = [Mandant::id()];
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad,
$sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname,
(SELECT COUNT(*) FROM bank_protokolle p WHERE p.auftrag_id = z.id) AS protokolle
FROM zahlungsauftraege z
JOIN konten k ON k.id = z.konto_id
LEFT JOIN kategorien kat ON kat.id = z.kategorie_id
LEFT JOIN konten g ON g.id = z.gegenkonto_id
WHERE z.benutzer_id = ?';
if ($status !== '' && array_key_exists($status, self::STATUS)) {
@ -66,12 +67,13 @@ class Zahlungsauftrag
Database::execute(
'INSERT INTO zahlungsauftraege
(benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung,
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, gegenkonto_id, klasse_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'],
$f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'],
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], 'entwurf',
$f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'],
$f['klasse_id'], 'entwurf',
]
);
@ -94,13 +96,13 @@ class Zahlungsauftrag
Database::execute(
'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?,
bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?,
echtzeit = ?, kategorie_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
echtzeit = ?, kategorie_id = ?, gegenkonto_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL,
bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL
WHERE id = ? AND benutzer_id = ?',
[
$f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'],
$f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'],
$f['echtzeit'], $f['kategorie_id'], $f['klasse_id'], $id, Mandant::id(),
$f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'], $f['klasse_id'], $id, Mandant::id(),
]
);
}
@ -154,6 +156,7 @@ class Zahlungsauftrag
'empfaenger' => (string)$auftrag['empfaenger'],
'buchungstext' => (string)$auftrag['verwendungszweck'],
'kategorie_id' => $auftrag['kategorie_id'],
'gegenkonto_id' => $auftrag['gegenkonto_id'] ?? null,
'klasse_id' => $auftrag['klasse_id'],
'quelle' => 'fints',
]);
@ -380,6 +383,16 @@ class Zahlungsauftrag
$id = static fn(mixed $wert): ?int =>
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
// Erst beim Senden entsteht die Buchung – ein Gegenkonto, das sie
// ablehnen würde, fällt besser schon beim Erfassen auf.
$gegenkonto = $id($daten['gegenkonto_id'] ?? null);
if ($gegenkonto !== null && $gegenkonto === $kontoId) {
throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.');
}
if ($gegenkonto !== null && Konto::find($gegenkonto) === null) {
throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.');
}
return [
'konto_id' => $kontoId,
'zugang_id' => $id($daten['zugang_id'] ?? null),
@ -393,7 +406,9 @@ class Zahlungsauftrag
'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140),
'ausfuehrung_am' => $termin !== '' && !$echtzeit ? $termin : null,
'echtzeit' => $echtzeit ? 1 : 0,
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
// Kategorie oder Gegenkonto – nie beides, wie bei der Buchung.
'kategorie_id' => $gegenkonto === null ? $id($daten['kategorie_id'] ?? null) : null,
'gegenkonto_id' => $gegenkonto,
'klasse_id' => $id($daten['klasse_id'] ?? null),
];
}

View file

@ -115,6 +115,12 @@ class Auswertung
'umbuchungen' => self::bool($roh['umbuchungen'] ?? false),
'konten' => self::ids($roh['konten'] ?? []),
'kategorien' => self::ids($roh['kategorien'] ?? []),
// Umbuchungskonten stehen im Buchungsbericht mit in der
// Kategorieauswahl (`konto:7`, #124) – nur dort werden Umbuchungen
// überhaupt aufgeführt.
'gegenkonten' => $typ === 'buchungsbericht'
? self::gegenkonten($roh['gegenkonten'] ?? [], $roh['kategorien'] ?? [])
: [],
'klassen' => self::ids($roh['klassen'] ?? []),
'suche' => mb_substr(trim((string)($roh['suche'] ?? '')), 0, 120),
// Nur der Buchungsbericht führt die Abgleichspalte und filtert danach.
@ -1203,8 +1209,16 @@ class Auswertung
// Umbuchungen sind Vermögensverschiebungen, keine Einnahmen oder
// Ausgaben – nur der Buchungsbericht darf sie auf Wunsch mitführen.
// Ein ausdrücklich gewähltes Umbuchungskonto holt seine Umbuchungen
// auch ohne diesen Schalter herein.
$gegenkonten = $filter['gegenkonten'] ?? [];
if (empty($filter['umbuchungen'])) {
if ($gegenkonten === []) {
$wo[] = 'gegenkonto_id IS NULL';
} else {
$wo[] = '(gegenkonto_id IS NULL OR gegenkonto_id IN (' . self::platzhalter($gegenkonten) . '))';
$params = array_merge($params, $gegenkonten);
}
}
if (!empty($filter['konten'])) {
@ -1212,11 +1226,21 @@ class Auswertung
$params = array_merge($params, $filter['konten']);
}
// Kategorien und Umbuchungskonten stammen aus derselben Auswahl: Eine
// Bewegung passt, wenn sie zu einem der beiden gehört.
$zuordnung = [];
if (!empty($filter['kategorien'])) {
$ids = self::mitNachfahren($filter['kategorien']);
$wo[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
$zuordnung[] = 'kategorie_id IN (' . self::platzhalter($ids) . ')';
$params = array_merge($params, $ids);
}
if ($gegenkonten !== []) {
$zuordnung[] = 'gegenkonto_id IN (' . self::platzhalter($gegenkonten) . ')';
$params = array_merge($params, $gegenkonten);
}
if ($zuordnung !== []) {
$wo[] = '(' . implode(' OR ', $zuordnung) . ')';
}
if (!empty($filter['klassen'])) {
$wo[] = 'klasse_id IN (' . self::platzhalter($filter['klassen']) . ')';
@ -1443,6 +1467,24 @@ class Auswertung
return implode(', ', array_fill(0, count($werte), '?'));
}
/**
* Die gewählten Umbuchungskonten: als eigene Liste (aus der Adresse oder
* einem gespeicherten Bericht) oder als `konto:7` aus der Kategorieauswahl.
*
* @return list<int>
*/
private static function gegenkonten(mixed $liste, mixed $auswahl): array
{
$ids = self::ids($liste);
foreach (is_array($auswahl) ? $auswahl : [] as $eintrag) {
if (is_string($eintrag) && preg_match('/^konto:(\d+)$/', $eintrag, $treffer) === 1 && (int)$treffer[1] > 0) {
$ids[] = (int)$treffer[1];
}
}
return array_values(array_unique($ids));
}
/** @return list<int> */
private static function ids(mixed $wert): array
{

View file

@ -79,7 +79,7 @@ class BuchungsExport
/**
* Die gefilterten Buchungen samt Splitzeilen und Adressen.
*
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], klassen[]
* @param array<string,mixed> $filter von, bis, konten[], kategorien[], gegenkonten[], klassen[]
* @return list<array<string,mixed>>
*/
public static function buchungen(array $filter): array
@ -151,18 +151,30 @@ class BuchungsExport
// Kategorie und Klasse können in der Buchung oder in einer ihrer
// Splitzeilen stehen – beides zählt, sonst fiele jede Splitbuchung aus
// dem Filter, obwohl sie die gesuchte Kategorie führt.
foreach ([['kategorien', 'kategorie_id'], ['klassen', 'klasse_id']] as [$schluessel, $spalte]) {
// dem Filter, obwohl sie die gesuchte Kategorie führt. Kategorien und
// Umbuchungskonten stammen aus derselben Auswahl: Eine Buchung passt,
// wenn sie zu einem von beiden gehört.
$gruppen = [
[['kategorien', 'kategorie_id'], ['gegenkonten', 'gegenkonto_id']],
[['klassen', 'klasse_id']],
];
foreach ($gruppen as $gruppe) {
$oder = [];
foreach ($gruppe as [$schluessel, $spalte]) {
$ids = self::ids($filter[$schluessel] ?? []);
if ($ids === []) {
continue;
}
$platzhalter = self::platzhalter($ids);
$wo[] = '(b.' . $spalte . ' IN (' . $platzhalter . ')
$oder[] = 'b.' . $spalte . ' IN (' . $platzhalter . ')
OR EXISTS (SELECT 1 FROM buchung_splits s WHERE s.buchung_id = b.id
AND s.' . $spalte . ' IN (' . $platzhalter . ')))';
AND s.' . $spalte . ' IN (' . $platzhalter . '))';
$params = array_merge($params, $ids, $ids);
}
if ($oder !== []) {
$wo[] = '(' . implode(' OR ', $oder) . ')';
}
}
return [implode(' AND ', $wo), $params];
}

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/** @var array<string,mixed> $bericht */
@ -195,7 +196,7 @@ include __DIR__ . '/../layout/kopf.php';
<button type="submit" class="knopf primaer">Anzeigen</button>
</div>
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['klassen'] ? 'open' : '' ?>>
<details class="einschraenkung" <?= $filter['konten'] || $filter['kategorien'] || $filter['gegenkonten'] || $filter['klassen'] ? 'open' : '' ?>>
<summary>Einschränken auf Konten, Kategorien und Klassen</summary>
<div class="filterzeile">
<label>Konten
@ -217,6 +218,20 @@ include __DIR__ . '/../layout/kopf.php';
<?= View::h(str_repeat(' ', (int)$kategorie['ebene']) . $kategorie['name']) ?>
</option>
<?php endforeach; ?>
<?php /* Nur der Buchungsbericht führt Umbuchungen – dort stehen
die Umbuchungskonten unter den Kategorien (#124). */ ?>
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
<optgroup label="<?= View::h($kontengruppe) ?>">
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
<option value="konto:<?= (int)$umbuchungskonto['id'] ?>"
<?= in_array((int)$umbuchungskonto['id'], $filter['gegenkonten'], true) ? 'selected' : '' ?>>
[<?= View::h($umbuchungskonto['name']) ?>]
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
<?php endif; ?>
</select>
</label>
@ -234,6 +249,10 @@ include __DIR__ . '/../layout/kopf.php';
<p class="hinweis klein">
Eine gewählte Kategorie schließt ihre Unterkategorien ein. Mehrfachauswahl mit
gedrückter Strg-Taste; nichts ausgewählt heißt alles.
<?php if ($bericht['typ'] === 'buchungsbericht'): ?>
Ein Umbuchungskonto in eckigen Klammern zeigt die Umbuchungen auf dieses Konto,
auch ohne „Umbuchungen mitzählen“.
<?php endif; ?>
</p>
</details>
</form>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
@ -11,7 +12,7 @@ use Finanzmanager\View;
* @var array<string,mixed> $regel Vorbelegung; leeres Array für eine neue Regel
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $konten alle Konten; die Liste zeigt nur die aktiven
* @var string $idPrefix Vorsatz für die Feld-ids, damit mehrere solcher
* Formulare auf einer Seite nicht kollidieren
*/
@ -66,15 +67,12 @@ $betragsfeld = static function (?string $feld) use ($regel): string {
zwischen 40 und 60 Euro.
</p>
<label for="<?= $id('kategorie_id') ?>">Kategorie</label>
<select id="<?= $id('kategorie_id') ?>" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($regel['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<label for="<?= $id('zuordnung') ?>"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="<?= $id('zuordnung') ?>" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="<?= $id('klasse_id') ?>">Klasse</label>
@ -88,17 +86,6 @@ $betragsfeld = static function (?string $feld) use ($regel): string {
<?php endforeach; ?>
</select>
<label for="<?= $id('gegenkonto_id') ?>">Gegenkonto <span class="optional">(macht daraus eine Umbuchung)</span></label>
<select id="<?= $id('gegenkonto_id') ?>" name="gegenkonto_id">
<option value="">– keines –</option>
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)($regel['gegenkonto_id'] ?? 0) === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
</option>
<?php endforeach; ?>
</select>
<h4>Splitbuchung <span class="optional">(statt einer einzelnen Kategorie)</span></h4>
<?php $splitZeilen = AbgleichRegel::splitzeilen($regel); $splitForm = ''; ?>
<?php include __DIR__ . '/regelsplits.php'; ?>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\AbgleichRegel;
use Finanzmanager\View;
@ -47,7 +48,7 @@ include __DIR__ . '/../layout/kopf.php';
<thead>
<tr>
<th>Name</th><th>Feld</th><th>Vergleich</th><th>Muster</th>
<th>Kategorie</th><th>Klasse</th>
<th>Kategorie / Gegenkonto</th><th>Klasse</th>
<th class="rechts">Prio</th><th class="rechts">Treffer</th>
<th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
@ -80,14 +81,10 @@ include __DIR__ . '/../layout/kopf.php';
</td>
<td><input type="text" form="<?= $f ?>" name="muster" value="<?= View::h($regel['muster']) ?>" required></td>
<td>
<select form="<?= $f ?>" name="kategorie_id">
<option value="">–</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$regel['kategorie_id'] === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select form="<?= $f ?>" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($regel); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
</td>
<td>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/**
@ -11,13 +12,14 @@ use Finanzmanager\View;
* Felder ohnehin im umgebenden Formular stehen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $klassen
* @var list<array<string,mixed>> $konten
*/
// Ein paar leere Zeilen mehr, als belegt sind: So lässt sich eine Kategorie
// nachtragen, ohne dass ein Knopf und ein Stück JavaScript dafür nötig wären.
$anzahl = max(3, count($splitZeilen) + 2);
for ($i = count($splitZeilen); $i < $anzahl; $i++) {
$splitZeilen[] = ['kategorie_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => ''];
$splitZeilen[] = ['kategorie_id' => null, 'gegenkonto_id' => null, 'klasse_id' => null, 'prozent' => '', 'notiz' => ''];
}
$fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
@ -26,21 +28,17 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
<table class="liste splittabelle" data-anteilssumme>
<thead>
<tr>
<th>Kategorie</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil</th>
<th>Kategorie / Gegenkonto</th><th>Klasse</th><th>Notiz</th><th class="rechts">Anteil</th>
</tr>
</thead>
<tbody>
<?php foreach ($splitZeilen as $nr => $zeile): ?>
<tr>
<td>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][kategorie_id]">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zeile['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select<?= $fuerFormular ?> name="split[<?= (int)$nr ?>][zuordnung]">
<?php $gewaehlt = BuchungenController::zuordnungswert($zeile); ?>
<?php $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
</td>
<td>
@ -76,8 +74,9 @@ $fuerFormular = $splitForm === '' ? '' : ' form="' . View::h($splitForm) . '"';
<p class="feldhinweis">
Die Anteile gelten zueinander, nicht als feste Beträge: Bei jedem Buchungsbetrag
werden die Kategorien im selben Verhältnis bebucht. Ob dort 60 und 40 steht oder
3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt den Rest, damit
die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne Kategorie oder ohne
Anteil fällt weg; ohne jede Splitzeile bucht die Regel wie bisher auf ihre Kategorie.
werden die Kategorien und Umbuchungskonten im selben Verhältnis bebucht. Ob dort 60
und 40 steht oder 3 und 2, macht deshalb keinen Unterschied. Die letzte Zeile bekommt
den Rest, damit die Summe auf den Cent zum Buchungsbetrag passt. Eine Zeile ohne
Kategorie bzw. Gegenkonto oder ohne Anteil fällt weg; ohne jede Splitzeile bucht die
Regel wie bisher auf ihre Kategorie.
</p>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Bankumsatz;
use Finanzmanager\View;
@ -18,11 +19,19 @@ use Finanzmanager\View;
*/
// Für die Splitzeilen einer Regel: Die Vorschläge tragen nur Kennungen, hier
// soll der Pfad der Kategorie stehen.
// soll der Pfad der Kategorie stehen – bei einer Umbuchung das Gegenkonto in
// eckigen Klammern.
$kategoriepfad = [];
foreach ($kategorien as $eintrag) {
$kategoriepfad[(int)$eintrag['id']] = (string)$eintrag['pfad'];
}
$kontoname = array_column($konten, 'name', 'id');
$splitbezeichnung = static function (array $zeile) use ($kategoriepfad, $kontoname): string {
if (!empty($zeile['gegenkonto_id'])) {
return '[' . ($kontoname[(int)$zeile['gegenkonto_id']] ?? '?') . ']';
}
return $kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie';
};
include __DIR__ . '/../layout/kopf.php';
?>
@ -148,6 +157,7 @@ include __DIR__ . '/../layout/kopf.php';
<select name="konto_id" required>
<option value="">– Konto wählen –</option>
<?php foreach ($konten as $konto): ?>
<?php if ((int)$konto['aktiv'] !== 1) { continue; } ?>
<option value="<?= (int)$konto['id'] ?>"><?= View::h($konto['name']) ?></option>
<?php endforeach; ?>
</select>
@ -162,12 +172,12 @@ include __DIR__ . '/../layout/kopf.php';
<ul class="vorschlagssplits">
<?php foreach ($regelsplits as $nr => $zeile): ?>
<li>
<span><?= View::h($kategoriepfad[(int)($zeile['kategorie_id'] ?? 0)] ?? 'ohne Kategorie') ?></span>
<span><?= View::h($splitbezeichnung($zeile)) ?></span>
<span class="betrag <?= View::betragKlasse($zeile['betrag']) ?>">
<?= View::betrag($zeile['betrag']) ?>
</span>
<input type="hidden" name="split[<?= (int)$nr ?>][kategorie_id]"
value="<?= (int)($zeile['kategorie_id'] ?? 0) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][zuordnung]"
value="<?= View::h(BuchungenController::zuordnungswert($zeile)) ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][klasse_id]"
value="<?= (int)($zeile['klasse_id'] ?? 0) ?: '' ?>">
<input type="hidden" name="split[<?= (int)$nr ?>][notiz]"
@ -178,14 +188,10 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?>
</ul>
<?php else: ?>
<select name="kategorie_id">
<option value="">– ohne Kategorie –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)($zuordnung['kategorie_id'] ?? 0) === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<select name="zuordnung" aria-label="Kategorie oder Gegenkonto">
<?php $gewaehlt = BuchungenController::zuordnungswert($zuordnung); ?>
<?php $eigenesKonto = (int)$umsatz['konto_id']; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<select name="klasse_id">

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/** @var array<string,mixed> $konto */
@ -22,15 +23,7 @@ $eingaben = $eingaben ?? null;
$art = !$neu && $betrag >= 0 ? 'einnahme' : 'ausgabe';
/** Vorauswahl der gemeinsamen Liste Kategorie / Gegenkonto. */
$zuordnungswert = static function (?array $zeile): string {
if (!empty($zeile['gegenkonto_id'])) {
return 'konto:' . (int)$zeile['gegenkonto_id'];
}
if (!empty($zeile['kategorie_id'])) {
return 'kategorie:' . (int)$zeile['kategorie_id'];
}
return '';
};
$zuordnungswert = BuchungenController::zuordnungswert(...);
// Die Werte, die die Felder zeigen: normalerweise die gespeicherte Buchung
// (oder eine leere Maske), nach einem gescheiterten Speichern aber die zuletzt

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\View;
/**
@ -14,6 +15,8 @@ use Finanzmanager\View;
*
* @var list<array<string,mixed>> $kategorien Kategoriebaum, flach mit Pfad
* @var list<array<string,mixed>> $konten alle Konten des Mandanten
* @var list<array<string,mixed>>|null $umbuchungskonten stattdessen, wo `$konten`
* in der Seite etwas anderes meint
* @var int $eigenesKonto Konto der Buchung – kein Gegenkonto seiner selbst
* @var string $gewaehlt vorausgewählter Wert, '' für keine Zuordnung
* @var bool $mitSplit Eintrag „Splitbuchung“ anbieten (nur im Kontoblatt)
@ -29,40 +32,68 @@ use Finanzmanager\View;
*
* In den Splitzeilen fehlt die Gruppe. Dort steht der Betrag der Zeile schon
* fest, und die Umbuchung ins Depot ist genau das, was gebraucht wird.
*
* Die Umbuchungskonten stehen unter den Kategorien, je Kontengruppe in einer
* eigenen Gruppe und in deren Reihenfolge (Konto::umbuchungsgruppen()).
* Angeboten werden nur aktive Konten (#124). Ein inaktives Konto bleibt aber
* als verborgener Eintrag in der Liste: Ist es schon gewählt, steht es
* sichtbar da – sonst ginge die Zuordnung beim nächsten Speichern verloren –,
* und kontoblatt.js kann eine bestehende Splitzeile damit vorbelegen.
*
* Außerhalb der Buchungsmaske (Regeln, Vorlagen, Zahlungsverkehr …) wird die
* Liste mit `$eigenesKonto = 0` und ohne Split- und Depotgruppe eingebunden.
*/
$kontenliste = $umbuchungskonten ?? $konten;
$istDepot = static fn(array $konto): bool => (string)($konto['kontoart'] ?? '') === 'depot';
$depots = !empty($mitDepot) ? array_filter($konten, $istDepot) : [];
$depots = !empty($mitDepot) ? array_filter($kontenliste, $istDepot) : [];
// Umbuchungskonten je Kontengruppe (Konto::umbuchungsgruppen()); inaktive,
// nicht gewählte Konten getrennt und verborgen.
$umbuchungsziele = array_values(array_filter(
$kontenliste,
static fn(array $k): bool => (int)$k['id'] !== (int)$eigenesKonto && !($depots !== [] && $istDepot($k))
));
$gewaehltesKonto = str_starts_with((string)($gewaehlt ?? ''), 'konto:') ? (int)substr((string)$gewaehlt, 6) : 0;
$umbuchungsgruppen = Konto::umbuchungsgruppen($umbuchungsziele, [$gewaehltesKonto]);
$verborgeneKonten = array_filter(
$umbuchungsziele,
static fn(array $k): bool => (int)($k['aktiv'] ?? 1) !== 1 && (int)$k['id'] !== $gewaehltesKonto
);
?>
<option value=""<?= ($gewaehlt ?? '') === '' ? ' selected' : '' ?>>— keine —</option>
<?php if (!empty($mitSplit)): ?>
<option value="split"<?= ($gewaehlt ?? '') === 'split' ? ' selected' : '' ?>>Splitbuchung …</option>
<?php endif; ?>
<optgroup label="Kategorien">
<?php foreach ($kategorien as $eintrag): ?>
<?php $wert = 'kategorie:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
<?= View::h($eintrag['pfad']) ?>
<?php foreach ($kategorien as $listeneintrag): ?>
<?php $optionswert = 'kategorie:' . (int)$listeneintrag['id']; ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
<?= View::h($listeneintrag['pfad']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<optgroup label="Umbuchung auf ein Konto">
<?php foreach ($konten as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php if ($depots !== [] && $istDepot($eintrag)) { continue; } ?>
<?php $wert = 'konto:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>]
<?php foreach ($umbuchungsgruppen as $kontengruppe => $gruppeneintraege): ?>
<optgroup label="<?= View::h($kontengruppe) ?>">
<?php foreach ($gruppeneintraege as $listeneintrag): ?>
<?php $optionswert = 'konto:' . (int)$listeneintrag['id']; ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
[<?= View::h($listeneintrag['name']) ?>]
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
<?php foreach ($verborgeneKonten as $listeneintrag): ?>
<option value="konto:<?= (int)$listeneintrag['id'] ?>" hidden>[<?= View::h($listeneintrag['name']) ?>]</option>
<?php endforeach; ?>
<?php if ($depots !== []): ?>
<optgroup label="Wertpapiergeschäft im Depot">
<?php foreach ($depots as $eintrag): ?>
<?php if ((int)$eintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $wert = 'depot:' . (int)$eintrag['id']; ?>
<option value="<?= View::h($wert) ?>"<?= ($gewaehlt ?? '') === $wert ? ' selected' : '' ?>>
[<?= View::h($eintrag['name']) ?>] …
<?php foreach ($depots as $listeneintrag): ?>
<?php if ((int)$listeneintrag['id'] === (int)$eigenesKonto) { continue; } ?>
<?php $optionswert = 'depot:' . (int)$listeneintrag['id']; ?>
<?php if ((int)($listeneintrag['aktiv'] ?? 1) !== 1 && ($gewaehlt ?? '') !== $optionswert) { continue; } ?>
<option value="<?= View::h($optionswert) ?>"<?= ($gewaehlt ?? '') === $optionswert ? ' selected' : '' ?>>
[<?= View::h($listeneintrag['name']) ?>] …
</option>
<?php endforeach; ?>
</optgroup>

View file

@ -264,12 +264,11 @@ include __DIR__ . '/../layout/kopf.php';
<label for="neu_buchungstext">Buchungstext</label>
<input type="text" id="neu_buchungstext" name="buchungstext">
<label for="neu_kategorie">Kategorie</label>
<select id="neu_kategorie" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="neu_zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="neu_zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="neu_klasse">Klasse</label>

View file

@ -8,6 +8,7 @@ use Finanzmanager\View;
* @var list<array<string,mixed>> $fristen
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $reihen
* @var array<string,string> $intervalle
* @var string $csrfToken
@ -74,6 +75,8 @@ include __DIR__ . '/../layout/kopf.php';
value="<?= View::h((string)$vertrag['adresse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id"
value="<?= View::h((string)$vertrag['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id"
value="<?= View::h((string)$vertrag['gegenkonto_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="regelmaessige_id"
value="<?= View::h((string)$vertrag['regelmaessige_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="beginn"
@ -163,12 +166,11 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?>
</select>
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern – etwa für einen Bauspar- oder Sparvertrag.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<div class="feldpaar">

View file

@ -1,10 +1,11 @@
<?php
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Dateiformat;
use Finanzmanager\View;
/** @var string $format */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, klassen: list<int>} $filter */
/** @var array{von: string, bis: string, konten: list<int>, kategorien: list<int>, gegenkonten: list<int>, klassen: list<int>} $filter */
/** @var list<array<string,mixed>> $konten */
/** @var list<array<string,mixed>> $kategorien */
/** @var list<array<string,mixed>> $klassen */
@ -87,6 +88,21 @@ include __DIR__ . '/../layout/kopf.php';
</p>
<?php endif; ?>
<?php /* Unter den Kategorien die aktiven Umbuchungskonten (#124): Ein
gewähltes Konto nimmt die Umbuchungen darauf mit. */ ?>
<?php foreach (Konto::umbuchungsgruppen($konten, $filter['gegenkonten']) as $kontengruppe => $gruppenkonten): ?>
<h4><?= View::h($kontengruppe) ?></h4>
<div class="auswahlliste">
<?php foreach ($gruppenkonten as $umbuchungskonto): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="gegenkonten[]" value="<?= (int)$umbuchungskonto['id'] ?>"
<?= $gewaehlt($filter['gegenkonten'], (int)$umbuchungskonto['id']) ? 'checked' : '' ?>>
<span>[<?= View::h((string)$umbuchungskonto['name']) ?>]</span>
</label>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
<h3>Klassen</h3>
<?php if ($klassen === []): ?>
<p class="platzhalter">Noch keine Klassen angelegt.</p>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\View;
/**
@ -54,7 +55,7 @@ include __DIR__ . '/../layout/kopf.php';
<table class="liste">
<thead>
<tr>
<th>Vorlage</th><th>Kategorie</th><th>Konto</th>
<th>Vorlage</th><th>Kategorie / Gegenkonto</th><th>Konto</th>
<th class="rechts">Betrag</th><th>Buchen am</th>
<th class="rechts">Genutzt</th><th class="rechts">Aktion</th>
</tr>
@ -69,7 +70,9 @@ include __DIR__ . '/../layout/kopf.php';
<span class="marke">Split</span>
<?php endif; ?>
</td>
<td><?= View::h($vorlage['kategorie_pfad'] ?? '–') ?></td>
<td><?= View::h($vorlage['gegenkontoname'] !== null
? '[' . $vorlage['gegenkontoname'] . ']'
: ($vorlage['kategorie_pfad'] ?? '–')) ?></td>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<?php if ($vorlage['konto_id'] === null): ?>
@ -126,12 +129,11 @@ include __DIR__ . '/../layout/kopf.php';
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" value="0,00">
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"><?= View::h($kategorie['pfad']) ?></option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Eines davon macht aus der Buchung eine Umbuchung.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = ''; $eigenesKonto = 0; $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<label for="klasse_id">Klasse</label>

View file

@ -1,5 +1,6 @@
<?php
use Finanzmanager\Controllers\BuchungenController;
use Finanzmanager\Models\Adresse;
use Finanzmanager\View;
@ -8,6 +9,7 @@ use Finanzmanager\View;
* @var list<array<string,mixed>> $konten
* @var list<array<string,mixed>> $adressen
* @var list<array<string,mixed>> $kategorien
* @var list<array<string,mixed>> $umbuchungskonten
* @var list<array<string,mixed>> $klassen
* @var string $csrfToken
* @var bool|null $hatProtokoll
@ -141,15 +143,12 @@ include __DIR__ . '/../layout/kopf.php';
Auftrag abgewiesen und bleibt als Entwurf stehen.
</p>
<label for="kategorie_id">Kategorie</label>
<select id="kategorie_id" name="kategorie_id">
<option value="">– keine –</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= (int)$wert('kategorie_id') === (int)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
<label for="zuordnung"
title="Unter den Kategorien stehen die aktiven Umbuchungskonten in eckigen Klammern. Geht die Überweisung auf ein eigenes Konto, wird sie damit als Umbuchung gebucht.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = BuchungenController::zuordnungswert($auftrag); ?>
<?php $eigenesKonto = (int)$wert('konto_id'); $mitSplit = false; $mitDepot = false; ?>
<?php include __DIR__ . '/../buchungen/zuordnungsliste.php'; ?>
</select>
<p class="feldhinweis">Wird auf die Buchung übertragen, sobald die Bank den Auftrag annimmt.</p>

View file

@ -0,0 +1,313 @@
<?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();
}
}

View file

@ -7,6 +7,7 @@ const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
const MARKE = Date.now().toString().slice(-6)
const GIRO = `E2E RegelBuchung ${MARKE}`
const KATEGORIE = `E2E RegelBuchung Kategorie ${MARKE}`
const TAGESGELD = `E2E RegelBuchung Tagesgeld ${MARKE}`
async function anmelden(page) {
await page.goto('/login')
@ -21,6 +22,7 @@ async function anmelden(page) {
// erzeugen; ein Dialog zeigt sie danach zum Prüfen.
test.describe('Regel aus einer Buchung anlegen', () => {
let giroId = null
let tagesgeldId = null
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage()
@ -38,6 +40,12 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]')
await page.goto('/konten/neu')
await page.fill('#name', TAGESGELD)
await page.click('.formular button[type="submit"]')
await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/)
tagesgeldId = page.url().match(/\/konten\/(\d+)\//)[1]
await page.close()
})
@ -75,7 +83,7 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await expect(dialog).toBeVisible()
await expect(dialog.locator('input[name="name"]')).toHaveValue(empfaenger)
await expect(dialog.locator('input[name="muster"]')).toHaveValue(empfaenger)
await expect(dialog.locator('select[name="kategorie_id"]')).toHaveValue(/^\d+$/)
await expect(dialog.locator('select[name="zuordnung"]')).toHaveValue(/^kategorie:\d+$/)
await dialog.getByRole('button', { name: 'Abbruch' }).click()
@ -118,6 +126,31 @@ test.describe('Regel aus einer Buchung anlegen', () => {
await expect(zeile.locator('input[name="muster"]')).toHaveValue(angepasstesMuster)
})
// #124: Unter den Kategorien stehen auch die Umbuchungskonten – in der
// Regel selbst wie in ihren Splitzeilen.
test('Regel-Splitzeile bucht auf ein Umbuchungskonto', async ({ page }) => {
const name = `Sparregel ${MARKE}`
await page.goto('/banking/regeln')
await page.click('#neu-knopf')
const dialog = page.locator('#neudialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('#neu_zuordnung option', { hasText: `[${TAGESGELD}]` })).toHaveCount(1)
await dialog.locator('#neu_name').fill(name)
await dialog.locator('#neu_muster').fill(name)
await dialog.locator('select[name="split[0][zuordnung]"]').selectOption({ label: KATEGORIE })
await dialog.locator('input[name="split[0][prozent]"]').fill('25')
await dialog.locator('select[name="split[1][zuordnung]"]').selectOption({ label: `[${TAGESGELD}]` })
await dialog.locator('input[name="split[1][prozent]"]').fill('75')
await dialog.getByRole('button', { name: 'Regel anlegen' }).click()
await expect(page.locator('.meldung-erfolg')).toBeVisible()
const zeile = page.locator('tr').filter({ has: page.locator(`input[name="name"][value="${name}"]`) })
const splits = zeile.locator('xpath=following-sibling::tr[1]')
await expect(splits.locator('select[name="split[1][zuordnung]"]')).toHaveValue(`konto:${tagesgeldId}`)
})
test('Escape im Dialog verwirft die Regel wie ein Klick auf Abbruch', async ({ page }) => {
const empfaenger = `Escaperegel ${MARKE}`