Finanzmanager/src/Controllers/BuchungenController.php
fds2610-bot ddf0638693 Fix #21: Abgleich mit dem Bargeld-Tool
Beim Zuweisen einer Kategorie fragt der Finanzmanager beim Bargeld-Tool
(FSCash) nach, ob es zum Betrag dort schon eine Buchung gibt.

Eingerichtet wird das unter Verwaltung → Bargeld-Tool: Adresse,
Zugangsdaten (Passwort AES-verschlüsselt), Suchzeitraum und die Auswahl
der abzugleichenden Kategorien. Die Vormerkung steht als
`bargeld_abgleich` an der Kategorie selbst, nicht als Nummernliste in den
Einstellungen – so verschwindet sie mit der Kategorie.

Geprüft wird nur, was geprüft werden soll: Kategorie an der Buchung,
Kategorie vorgemerkt, Adresse konfiguriert. Fällt eines weg, verschwindet
der Status. Gesucht wird rückwärts ab dem Buchungsdatum, weil Bargeld
ausgegeben wird, bevor der Umsatz auf dem Konto ankommt.

Im Kontoblatt steht das Ergebnis zwischen Klasse und Betrag: grauer Kreis,
grüner Haken, rotes X, Ausrufezeichen. Das Ausrufezeichen behält die
Textfarbe des Farbschemas – der Fehler liegt beim Abruf, nicht bei der
Buchung. Ohne hinterlegte Adresse entfällt die Spalte.

Ein Fehlschlag ist ein Ergebnis, kein Abbruch: Die Kategorie wird auch
dann gespeichert, wenn das Bargeld-Tool schweigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:06:20 +02:00

618 lines
22 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);
namespace Finanzmanager\Controllers;
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Klasse;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\BargeldAbgleich;
use Finanzmanager\Services\Wertpapiergeschaeft;
use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View;
/**
* Kontoblatt und Buchungsmaske.
*
* Das Kontoblatt zeigt eigene Buchungen und die Gegenseiten fremder
* Umbuchungen; Letztere lassen sich nur auf ihrem Ursprungskonto bearbeiten,
* damit eine Umbuchung nicht aus zwei Richtungen widersprüchlich geändert wird.
*/
class BuchungenController extends BaseController
{
/** @param array<string,string> $params */
public function kontoblatt(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$filter = [
'suche' => $this->query('suche'),
'von' => $this->query('von'),
'bis' => $this->query('bis'),
'kategorie_id' => $this->query('kategorie_id'),
'klasse_id' => $this->query('klasse_id'),
'nur_offene' => $this->query('nur_offene') === '1',
];
$zeilen = Buchung::kontoblatt((int)$konto['id'], $filter);
// Die neuesten Buchungen zuerst zeigen, den Saldo aber chronologisch
// aufbauen – deshalb erst rechnen, dann umdrehen.
$zeilen = array_reverse($zeilen);
$grenze = Einstellung::getInt('zeilen_pro_seite', 100);
$gesamt = count($zeilen);
$seite = max(1, (int)$this->query('seite', '1'));
$zeilen = array_slice($zeilen, ($seite - 1) * $grenze, $grenze);
// Die Splitzeilen der angezeigten Buchungen in einer Abfrage – der
// Splitdialog zeigt damit eine bestehende Aufteilung, ohne sie
// nachzuladen.
$gesplittete = [];
foreach ($zeilen as $zeile) {
if ((int)$zeile['gesplittet'] === 1 && !$zeile['gegensicht']) {
$gesplittete[] = (int)$zeile['id'];
}
}
$this->render('buchungen/kontoblatt.php', [
'titel' => 'Kontoblatt: ' . $konto['name'],
'konto' => Konto::mitSalden($konto),
'zeilen' => $zeilen,
'gesamt' => $gesamt,
'seite' => $seite,
'seiten' => max(1, (int)ceil($gesamt / max(1, $grenze))),
'filter' => $filter,
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
'splits' => Buchung::splitsJeBuchung($gesplittete),
// Für den Wertpapierdialog, den die Zuordnung auf ein Depot öffnet.
'wertpapiere' => Wertpapier::alle(true),
// Die Abgleichspalte erscheint nur, wenn es ein Bargeld-Tool gibt –
// sonst stünde dort in jeder Zeile derselbe graue Kreis.
'bargeld' => BargeldAbgleich::eingerichtet(),
]);
}
/** @param array<string,string> $params */
public function neu(array $params): void
{
$this->verlangeAnmeldung();
$konto = Konto::find((int)$params['id']);
if ($konto === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
$this->maske($konto, null);
}
/** @param array<string,string> $params */
public function anlegen(array $params): void
{
$this->verlangeAnmeldung();
$kontoId = (int)$params['id'];
if (Konto::find($kontoId) === null) {
$this->nichtGefunden('/konten/' . $params['id']);
return;
}
try {
[$daten, $splits] = $this->formulardaten($kontoId);
$id = Buchung::anlegen($daten, $splits);
} catch (\Throwable $e) {
View::redirect('/konten/' . $kontoId . '/buchung', $e->getMessage(), 'fehler');
return;
}
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId);
return;
}
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect('/konten/' . $kontoId, 'Buchung angelegt.' . BargeldAbgleich::rueckmeldung($abgleich));
}
/** @param array<string,string> $params */
public function bearbeiten(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$this->maske(Konto::find((int)$buchung['konto_id']), $buchung);
}
/** @param array<string,string> $params */
public function speichern(array $params): void
{
$this->verlangeAnmeldung();
$id = (int)$params['id'];
$buchung = Buchung::find($id);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
try {
[$daten, $splits] = $this->formulardaten((int)$buchung['konto_id']);
Buchung::speichern($id, $daten, $splits);
} catch (\Throwable $e) {
View::redirect('/buchungen/' . $id, $e->getMessage(), 'fehler');
return;
}
// Ein Depot in der Zuordnungsliste ist der Anfang eines Geschäfts: Die
// Buchung steht jetzt mit ihrem Betrag fest, die Wertpapiermaske baut
// darauf auf.
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . $id . '/wertpapier?depot=' . $depotId);
return;
}
$abgleich = BargeldAbgleich::pruefe($id);
View::redirect(
'/konten/' . $buchung['konto_id'],
'Buchung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich)
);
}
/** @param array<string,string> $params */
public function loeschen(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
Buchung::loeschen((int)$buchung['id']);
View::redirect('/konten/' . $buchung['konto_id'], 'Buchung gelöscht.');
}
/**
* Schnellzuordnung aus dem Kontoblatt: Kategorie oder Gegenkonto einer
* Buchung setzen, ohne die Maske zu öffnen.
*
* @param array<string,string> $params
*/
public function zuordnung(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
// Ein Depot ist keine Zuordnung, sondern der Anfang eines Geschäfts:
// Stückzahl, Kurs und Nebenkosten fehlen noch. Mit JavaScript öffnet
// das Kontoblatt dafür einen Dialog und kommt hier gar nicht an; ohne
// führt derselbe Weg auf die Seite mit denselben Feldern.
$depotId = self::depotAus($this->post('zuordnung'));
if ($depotId !== null) {
View::redirect('/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . $depotId);
return;
}
try {
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
Buchung::zuordnen(
(int)$buchung['id'],
$zuordnung['kategorie_id'],
$zuordnung['gegenkonto_id']
);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
// Die zugewiesene Kategorie kann eine sein, die gegen das Bargeld-Tool
// zu prüfen ist. Das Ergebnis landet an der Buchung und steht danach
// als Symbol im Kontoblatt.
$abgleich = BargeldAbgleich::pruefe((int)$buchung['id']);
View::redirect($ziel, 'Zuordnung gespeichert.' . BargeldAbgleich::rueckmeldung($abgleich));
}
/**
* Schnellauswahl aus dem Kontoblatt: die Klasse einer Buchung setzen. Ein
* leerer Wert löscht die vorhandene Klasse.
*
* @param array<string,string> $params
*/
public function klasse(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
$klasseId = (int)$this->post('klasse_id');
try {
Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
View::redirect($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.');
}
/**
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
* Ohne Zeilen wird die Splitbuchung aufgehoben.
*
* Der Knopf „Splitbuchung löschen“ schickt dasselbe Formular mit
* `aktion=loeschen`. Seine Zeilen zählen dann nicht: Gelöscht wird die
* Aufteilung, und die Buchung steht danach ohne Zuordnung da.
*
* @param array<string,string> $params
*/
public function splitbuchung(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
$loeschen = $this->post('aktion') === 'loeschen';
try {
Buchung::splitsErsetzen((int)$buchung['id'], $loeschen ? [] : $this->splitzeilen());
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
// Eine Splitbuchung trägt keine eigene Kategorie mehr; ein früherer
// Abgleichstatus wäre danach eine Aussage über eine Zuordnung, die es
// nicht mehr gibt. `pruefe()` räumt ihn weg.
BargeldAbgleich::pruefe((int)$buchung['id']);
View::redirect($ziel, $loeschen ? 'Splitbuchung gelöscht.' : 'Splitbuchung gespeichert.');
}
/**
* Die Wertpapiermaske zu einer Buchung als eigene Seite – der Weg ohne
* JavaScript. Sie zeigt dieselben Felder wie der Dialog des Kontoblatts und
* schickt sie an dasselbe Ziel.
*
* @param array<string,string> $params
*/
public function wertpapierFormular(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$depot = Konto::find((int)$this->query('depot'));
if ($depot === null || (string)$depot['kontoart'] !== 'depot') {
View::redirect(
$this->kontoblattPfad((int)$buchung['konto_id']),
'Für ein Wertpapiergeschäft fehlt das Depot.',
'fehler'
);
return;
}
$this->render('buchungen/wertpapier.php', [
'titel' => 'Wertpapiergeschäft',
'buchung' => $buchung,
'konto' => Konto::find((int)$buchung['konto_id']),
'depot' => $depot,
'wertpapiere' => Wertpapier::alle(true),
'kategorien' => Kategorie::baumFlach(),
'vorschlag' => Wertpapier::ausText((string)$buchung['buchungstext'] . ' ' . (string)$buchung['empfaenger']),
]);
}
/**
* Ein Wertpapiergeschäft zu einer vorhandenen Buchung.
*
* Die Buchung bleibt, wie die Bank sie geliefert hat – ihr Betrag ist die
* feste Größe. Aus ihm werden die Nebenkosten herausgerechnet; was übrig
* bleibt, ist der Kurswert und damit der Einstandspreis. Beides erscheint
* als Splitbuchung: je Kostenart eine Zeile in ihrer Kategorie, der Rest als
* Umbuchung ins Depot.
*
* @param array<string,string> $params
*/
public function wertpapier(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
try {
$geschaeft = Wertpapiergeschaeft::buchen($buchung, $this->wertpapiereingaben());
} catch (\Throwable $e) {
View::redirect(
'/buchungen/' . (int)$buchung['id'] . '/wertpapier?depot=' . (int)$this->post('depot_id'),
$e->getMessage(),
'fehler'
);
return;
}
View::redirect($ziel, sprintf(
'%s gebucht – Einstandspreis %s €.',
Depotbuchung::VORGAENGE[$geschaeft['vorgang']],
number_format($geschaeft['betrag'], 2, ',', '.')
));
}
/**
* Die Formulareingaben des Wertpapierdialogs, wie der Dienst sie erwartet.
*
* @return array<string,mixed>
*/
private function wertpapiereingaben(): array
{
$kosten = [];
foreach (array_keys(Wertpapierkosten::arten()) as $art) {
$kosten[$art] = KontenController::zahl($this->post('kosten_' . $art, ''));
}
return [
'depot_id' => (int)$this->post('depot_id'),
'wertpapier_id' => (int)$this->post('wertpapier_id'),
'wertpapier_name' => $this->post('wertpapier_name'),
'isin' => $this->post('isin'),
'wkn' => $this->post('wkn'),
'art' => $this->post('art', 'aktie'),
'vorgang' => $this->post('vorgang'),
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'stueck' => KontenController::zahl($this->post('stueck', '0')),
'kurs' => KontenController::zahl($this->post('kurs', '0')),
'notiz' => $this->post('notiz'),
'kosten' => $kosten,
'kosten_sonstige_kategorie' => (int)$this->post('kosten_sonstige_kategorie') ?: null,
];
}
/**
* Mehrere im Kontoblatt angekreuzte Buchungen auf einmal löschen.
*
* @param array<string,string> $params
*/
public function auswahlLoeschen(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 ($ids === []) {
View::redirect($ziel, 'Keine Buchung ausgewählt.', 'warnung');
return;
}
$anzahl = 0;
foreach ($ids as $id) {
$buchung = Buchung::find($id);
// Nur eigene Buchungen dieses Kontos: Die Gegenseite einer fremden
// Umbuchung gehört dem Ursprungskonto und wird auch nur dort
// gelöscht.
if ($buchung === null || (int)$buchung['konto_id'] !== $kontoId) {
continue;
}
try {
Buchung::loeschen($id);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
$anzahl++;
}
View::redirect($ziel, $anzahl === 1
? 'Buchung gelöscht.'
: $anzahl . ' Buchungen gelöscht.');
}
/**
* Weg zurück ins Kontoblatt – mit demselben Filter und derselben Seite.
*
* Die Werte kommen aus der Abfragezeichenfolge des Formularziels und werden
* hier neu zusammengesetzt statt übernommen: So kann kein fremdes Ziel
* eingeschleust werden.
*/
private function kontoblattPfad(int $kontoId): string
{
$query = array_filter([
'suche' => $this->query('suche'),
'von' => $this->query('von'),
'bis' => $this->query('bis'),
'kategorie_id' => $this->query('kategorie_id'),
'klasse_id' => $this->query('klasse_id'),
'nur_offene' => $this->query('nur_offene') === '1' ? '1' : '',
'seite' => $this->query('seite'),
], static fn(string $wert): bool => $wert !== '');
return '/konten/' . $kontoId . ($query === [] ? '' : '?' . http_build_query($query));
}
/** @param array<string,mixed>|null $konto */
private function maske(?array $konto, ?array $buchung): void
{
if ($konto === null) {
$this->nichtGefunden('/konten');
return;
}
$this->render('buchungen/formular.php', [
'titel' => $buchung === null ? 'Neue Buchung' : 'Buchung bearbeiten',
'konto' => $konto,
'buchung' => $buchung,
'splits' => $buchung === null ? [] : Buchung::splits((int)$buchung['id']),
'kategorien' => Kategorie::baumFlach(),
'klassen' => Klasse::alle(),
'konten' => Konto::alle(),
]);
}
/**
* @return array{0: array<string,mixed>, 1: list<array<string,mixed>>}
*/
private function formulardaten(int $kontoId): array
{
// In der Maske wird ein positiver Betrag eingegeben; die Richtung
// bestimmt die Auswahl Einnahme / Ausgabe.
$betrag = abs(KontenController::zahl($this->post('betrag', '0')));
if ($this->post('art', 'ausgabe') !== 'einnahme') {
$betrag = -$betrag;
}
// Kategorie und Gegenkonto stehen in einem Feld: Ein Konto in der Liste
// macht die Buchung zur Umbuchung – wie das Kategoriefeld im
// FinanzManager, das `[Konto]` annimmt.
$zuordnung = self::zuordnungAus($this->post('zuordnung'));
$daten = [
'konto_id' => $kontoId,
'datum' => $this->post('datum'),
'valuta' => $this->post('valuta'),
'betrag' => $betrag,
'empfaenger' => $this->post('empfaenger'),
'buchungstext' => $this->post('buchungstext'),
'nummer' => $this->post('nummer'),
'abgeglichen' => isset($_POST['abgeglichen']),
'kategorie_id' => $zuordnung['kategorie_id'],
'klasse_id' => $this->post('klasse_id'),
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'quelle' => 'manuell',
];
return [$daten, $this->splitzeilen()];
}
/**
* Die ausgefüllten Splitzeilen eines Formulars. Zeilen ohne Betrag zählen
* nicht – so bleiben die leeren Zeilen der Maske folgenlos.
*
* @return list<array<string,mixed>>
*/
private function splitzeilen(): array
{
$zeilen = $_POST['split'] ?? [];
if (!is_array($zeilen)) {
return [];
}
$splits = [];
foreach ($zeilen as $zeile) {
if (!is_array($zeile)) {
continue;
}
$wert = trim((string)($zeile['betrag'] ?? ''));
if ($wert === '') {
continue;
}
$zuordnung = self::zuordnungAus((string)($zeile['zuordnung'] ?? ''));
$splits[] = [
'kategorie_id' => $zuordnung['kategorie_id'],
'gegenkonto_id' => $zuordnung['gegenkonto_id'],
'klasse_id' => $zeile['klasse_id'] ?? null,
'betrag' => KontenController::zahl($wert),
'notiz' => (string)($zeile['notiz'] ?? ''),
];
}
return $splits;
}
/**
* Ein Auswahlwert aus der gemeinsamen Liste „Kategorie oder Gegenkonto“:
* `kategorie:12`, `konto:7` oder leer.
*
* @return array{kategorie_id: ?int, gegenkonto_id: ?int}
*/
public static function zuordnungAus(string $wert): array
{
$leer = ['kategorie_id' => null, 'gegenkonto_id' => null];
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
if ((int)$id <= 0) {
return $leer;
}
return match ($art) {
'kategorie' => ['kategorie_id' => (int)$id, 'gegenkonto_id' => null],
'konto' => ['kategorie_id' => null, 'gegenkonto_id' => (int)$id],
default => $leer,
};
}
/**
* Das Depot aus einem Auswahlwert `depot:7` – oder null, wenn der Wert für
* eine gewöhnliche Zuordnung steht.
*/
public static function depotAus(string $wert): ?int
{
[$art, $id] = array_pad(explode(':', $wert, 2), 2, '');
return $art === 'depot' && (int)$id > 0 ? (int)$id : null;
}
private function nichtGefunden(string $pfad): void
{
http_response_code(404);
$this->render('layout/404.php', ['pfad' => $pfad, 'titel' => 'Nicht gefunden']);
}
}