Finanzmanager/src/Models/Bankumsatz.php
fds2610-bot ecd01cde29 Fix n.n.: Umbuchung wird auch von der Gegenkontoseite erkannt
Eine Umbuchung gehört ihrem Ursprungskonto; der automatische Abgleich
suchte eine passende Buchung bisher nur über deren konto_id. Kam der
Kontoauszug der Empfängerseite mit ein paar Tagen Verzug herein, fand
er die schon gebuchte Umbuchung nie und legte sie als eigenständige
Buchung an – die Umbuchung stand danach doppelt im Kontoblatt und
verfälschte den Saldo.

Die Suche berücksichtigt jetzt auch die Gegenkonto-Seite einer
Umbuchung, mit umgekehrtem Vorzeichen wie im Kontoblatt auch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:48:08 +02:00

417 lines
15 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\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
use Finanzmanager\Models\Einstellung;
/**
* Ein abgerufener Bankumsatz, solange er noch keine Buchung ist.
*
* Der Abruf schreibt Rohdaten hierher; erst der Abgleich macht daraus eine
* Buchung im Kontoblatt. Dazwischen liegt die Vorschlagsliste: Zu jedem Umsatz
* schlägt die Anwendung vor, ihn zu **übernehmen** (neue Buchung), mit einer
* bereits erfassten Buchung zu **verbinden** oder zu **verwerfen**.
*
* Warum nicht direkt buchen: Wer seine Buchungen selbst erfasst, hat sie beim
* Abruf meist schon im Kontoblatt stehen. Ohne Zwischenschritt stünde alles
* doppelt da.
*/
class Bankumsatz
{
public const STATUS = [
'offen' => 'offen',
'uebernommen' => 'übernommen',
'verworfen' => 'verworfen',
];
/**
* Fingerabdruck eines Umsatzes bei der Bank. Er entscheidet, ob ein Umsatz
* schon einmal abgerufen wurde – Abrufzeiträume überlappen sich fast immer.
*
* Das Konto steckt als `bank_konto_id` darin, nicht als IBAN oder
* Kontonummer: Welche Kennung eine Bank liefert, wechselt von Dialog zu
* Dialog, und mit ihr kippte der Fingerabdruck – derselbe Umsatz käme ein
* zweites Mal herein. Die Kennung der Zeile in `bank_konten` steht dagegen
* fest, solange es den Bankzugang gibt; verschwindet er, nimmt er seine
* Umsätze per CASCADE ohnehin mit.
*/
public static function externId(
int $bankKontoId,
string $datum,
float $betrag,
string $zweck,
string $empfaenger = ''
): string {
return sha1(implode('|', [
$bankKontoId,
$datum,
number_format($betrag, 2, '.', ''),
trim($zweck),
trim($empfaenger),
]));
}
/**
* Fingerabdruck aus der Kennung, die die Gegenstelle selbst vergibt.
*
* PayPal nennt zu jeder Buchung eine Transaktionsnummer, die sich nicht mehr
* ändert. Sie ist der bessere Fingerabdruck als der aus Datum, Betrag und
* Text: Korrigiert PayPal nachträglich einen Betrag oder den Namen des
* Gegenübers, bleibt es dieselbe Buchung – über den zusammengesetzten
* Fingerabdruck käme sie ein zweites Mal herein.
*/
public static function externIdKennung(int $bankKontoId, string $kennung): string
{
return sha1($bankKontoId . '|' . trim($kennung));
}
public static function existiert(string $externId): bool
{
return Database::fetchValue(
'SELECT 1 FROM bank_umsaetze WHERE benutzer_id = ? AND extern_id = ?',
[Mandant::id(), $externId]
) !== null;
}
/**
* @param array<string,mixed> $daten
* @return int|null Die neue Kennung, oder null wenn der Umsatz schon da war.
*/
public static function anlegen(array $daten): ?int
{
$externId = (string)($daten['extern_id'] ?? '');
if ($externId === '' || self::existiert($externId)) {
return null;
}
Database::execute(
'INSERT INTO bank_umsaetze
(benutzer_id, zugang_id, konto_id, bank_konto_id, extern_id, buchungstag, wertstellung,
betrag, waehrung, empfaenger, verwendungszweck, buchungsart, roh)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
Mandant::id(),
(int)$daten['zugang_id'],
($daten['konto_id'] ?? null) ?: null,
// Woher der Umsatz stammt. Ohne das bliebe er ohne Konto stehen,
// wenn die Zuordnung erst nach dem Abruf getroffen wird.
($daten['bank_konto_id'] ?? null) ?: null,
$externId,
(string)$daten['buchungstag'],
($daten['wertstellung'] ?? null) ?: null,
round((float)($daten['betrag'] ?? 0), 2),
mb_substr((string)($daten['waehrung'] ?? 'EUR'), 0, 3),
mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255),
(string)($daten['verwendungszweck'] ?? ''),
mb_substr(trim((string)($daten['buchungsart'] ?? '')), 0, 100),
($daten['roh'] ?? null) ?: null,
]
);
return Database::lastInsertId();
}
public static function find(int $id): ?array
{
$zeile = Database::fetchOne(
'SELECT * FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?',
[$id, Mandant::id()]
);
return $zeile === false ? null : $zeile;
}
/**
* @param array<string,mixed> $filter status, zugang_id, konto_id
* @return list<array<string,mixed>>
*/
public static function liste(array $filter = []): array
{
$params = [Mandant::id()];
$sql = 'SELECT u.*, k.name AS kontoname, z.name AS zugangsname
FROM bank_umsaetze u
LEFT JOIN konten k ON k.id = u.konto_id
JOIN bank_zugaenge z ON z.id = u.zugang_id
WHERE u.benutzer_id = ?';
if (!empty($filter['status'])) {
$sql .= ' AND u.status = ?';
$params[] = (string)$filter['status'];
}
if (!empty($filter['zugang_id'])) {
$sql .= ' AND u.zugang_id = ?';
$params[] = (int)$filter['zugang_id'];
}
if (!empty($filter['konto_id'])) {
$sql .= ' AND u.konto_id = ?';
$params[] = (int)$filter['konto_id'];
}
return Database::fetchAll($sql . ' ORDER BY u.buchungstag DESC, u.id DESC', $params);
}
public static function anzahlOffen(): int
{
return (int)Database::fetchValue(
"SELECT COUNT(*) FROM bank_umsaetze WHERE benutzer_id = ? AND status = 'offen'",
[Mandant::id()],
0
);
}
/**
* Der Vorschlag zu einem Umsatz: eine passende bereits erfasste Buchung –
* oder, wenn es keine gibt, die Zuordnung aus der ersten passenden Regel.
*
* @param array<string,mixed> $umsatz
* @param list<array<string,mixed>>|null $regeln
* @return array{
* art: string,
* buchung: array<string,mixed>|null,
* regel: array<string,mixed>|null,
* zuordnung: array<string,mixed>|null
* }
*/
public static function vorschlag(array $umsatz, ?array $regeln = null): array
{
$buchung = self::passendeBuchung($umsatz);
if ($buchung !== null) {
return ['art' => 'verbinden', 'buchung' => $buchung, 'regel' => null, 'zuordnung' => null];
}
$regel = AbgleichRegel::passendeRegel($umsatz, $regeln);
if ($regel !== null) {
return [
'art' => 'uebernehmen',
'buchung' => null,
'regel' => $regel,
'zuordnung' => AbgleichRegel::anwenden($regel, $umsatz),
];
}
return ['art' => 'uebernehmen', 'buchung' => null, 'regel' => null, 'zuordnung' => null];
}
/**
* Sucht eine schon erfasste Buchung, die dieser Umsatz sein könnte: gleiches
* Konto, gleicher Betrag, Datum nah beieinander, noch mit keinem anderen
* Umsatz verbunden.
*
* Eine Umbuchung ist dabei auch dann „dasselbe Konto“, wenn sie auf ihrem
* Ursprungskonto steht und dieses Konto nur ihr Gegenkonto ist – sonst fände
* der Kontoauszug der Empfängerseite seine eigene Umbuchung nie, weil die
* Buchung formal einem anderen Konto gehört. Auf der Gegenkonto-Seite zählt
* der Betrag mit umgekehrtem Vorzeichen, wie im Kontoblatt auch.
*
* Die Toleranz in Tagen steht in den Einstellungen (`banking_abgleich_tage`):
* Zwischen dem Tag, an dem man eine Zahlung erfasst, und dem Tag, an dem die
* Bank sie bucht, liegen erfahrungsgemäß ein paar Tage.
*
* @param array<string,mixed> $umsatz
* @return array<string,mixed>|null
*/
public static function passendeBuchung(array $umsatz): ?array
{
$kontoId = (int)($umsatz['konto_id'] ?? 0);
if ($kontoId === 0) {
return null;
}
$tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5));
$betrag = round((float)$umsatz['betrag'], 2);
$zeile = Database::fetchOne(
'SELECT b.* FROM buchungen b
WHERE b.benutzer_id = ?
AND (
(b.konto_id = ? AND ABS(b.betrag - ?) < 0.005)
OR (b.gegenkonto_id = ? AND b.gesplittet = 0 AND ABS(b.betrag + ?) < 0.005)
)
AND ABS(DATEDIFF(b.datum, ?)) <= ?
AND NOT EXISTS (
SELECT 1 FROM bank_umsaetze u
WHERE u.buchung_id = b.id AND u.id <> ?
)
ORDER BY ABS(DATEDIFF(b.datum, ?)) LIMIT 1',
[
Mandant::id(),
$kontoId,
$betrag,
$kontoId,
$betrag,
(string)$umsatz['buchungstag'],
$tage,
(int)($umsatz['id'] ?? 0),
(string)$umsatz['buchungstag'],
]
);
return $zeile === false ? null : $zeile;
}
/**
* Macht aus dem Umsatz eine Buchung. `$zuordnung` überschreibt, was die
* Regel vorgeschlagen hat – der Nutzer hat in der Vorschlagsliste das
* letzte Wort.
*
* @param array<string,mixed> $zuordnung kategorie_id, klasse_id, gegenkonto_id, splits
*/
public static function uebernehmen(int $id, array $zuordnung = []): int
{
$umsatz = self::find($id);
if ($umsatz === null) {
throw new \RuntimeException('Den Umsatz gibt es nicht.');
}
if ($umsatz['status'] === 'uebernommen') {
throw new \RuntimeException('Dieser Umsatz wurde bereits übernommen.');
}
$kontoId = (int)($zuordnung['konto_id'] ?? $umsatz['konto_id'] ?? 0);
if ($kontoId === 0) {
throw new \RuntimeException(
'Der Umsatz hängt an keinem Konto. Zuerst das Bankkonto einem eigenen Konto zuordnen.'
);
}
$splits = $zuordnung['splits'] ?? [];
$buchungId = Buchung::anlegen(
[
'konto_id' => $kontoId,
'datum' => (string)$umsatz['buchungstag'],
'valuta' => $umsatz['wertstellung'],
'betrag' => (float)$umsatz['betrag'],
'empfaenger' => (string)$umsatz['empfaenger'],
'buchungstext' => (string)$umsatz['verwendungszweck'],
'kategorie_id' => $zuordnung['kategorie_id'] ?? null,
'klasse_id' => $zuordnung['klasse_id'] ?? null,
'gegenkonto_id' => $zuordnung['gegenkonto_id'] ?? null,
// Abgerufene Umsätze sind bei der Bank gebucht – damit gelten sie
// als abgeglichen, genau wie das C-Kennzeichen im QIF.
'abgeglichen' => 1,
'quelle' => 'fints',
'import_hash' => Buchung::hash(
$kontoId,
(string)$umsatz['buchungstag'],
(float)$umsatz['betrag'],
'',
(string)$umsatz['verwendungszweck']
),
],
is_array($splits) ? $splits : []
);
self::verbinden($id, $buchungId);
return $buchungId;
}
/** Verbindet den Umsatz mit einer bereits erfassten Buchung. */
public static function verbinden(int $id, int $buchungId): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'uebernommen', buchung_id = ?
WHERE id = ? AND benutzer_id = ?",
[$buchungId, $id, Mandant::id()]
);
// Was von der Bank kommt, ist dort gebucht – das Kennzeichen der
// verbundenen Buchung wird nachgezogen.
Database::execute(
'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?',
[$buchungId, Mandant::id()]
);
}
public static function verwerfen(int $id): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'verworfen', buchung_id = NULL
WHERE id = ? AND benutzer_id = ?",
[$id, Mandant::id()]
);
}
/** Zurück in die Vorschlagsliste – etwa wenn die Buchung doch falsch war. */
public static function zuruecksetzen(int $id): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'offen', buchung_id = NULL
WHERE id = ? AND benutzer_id = ?",
[$id, Mandant::id()]
);
}
/**
* Löst die Umsätze einer Buchung, die gleich gelöscht wird.
*
* Muss **vor** dem DELETE laufen: Danach hat der Fremdschlüssel `buchung_id`
* schon auf NULL gesetzt, und die Umsätze sind nicht mehr auffindbar.
*/
public static function loeseVonBuchung(int $buchungId): void
{
Database::execute(
"UPDATE bank_umsaetze SET status = 'offen', buchung_id = NULL
WHERE buchung_id = ? AND benutzer_id = ?",
[$buchungId, Mandant::id()]
);
}
/**
* Öffnet Umsätze, deren Buchung hinter unserem Rücken verschwunden ist.
*
* `Buchung::loeschen()` räumt selbst auf, aber nicht jede Buchung geht
* diesen Weg: Wird ein Konto gelöscht, räumt der Fremdschlüssel seine
* Buchungen per CASCADE ab – und Kaskaden lösen in MariaDB keine Trigger
* aus. Zurück blieben Umsätze mit Status „uebernommen“ ohne Buchung, die
* weder im Kontoblatt noch in der Vorschlagsliste auftauchen und beim
* nächsten Abruf als schon vorhanden übersprungen werden.
*
* Der Aufruf steht vor dem Abruf und vor der Vorschlagsliste, damit sich
* dieser Zustand von selbst wieder auflöst.
*
* @return int Wie viele Umsätze wieder offen sind.
*/
public static function verwaisteOeffnen(): int
{
return Database::execute(
"UPDATE bank_umsaetze SET status = 'offen'
WHERE benutzer_id = ? AND status = 'uebernommen' AND buchung_id IS NULL",
[Mandant::id()]
);
}
public static function loeschen(int $id): void
{
Database::execute('DELETE FROM bank_umsaetze WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
}
/**
* Mehrere Umsätze auf einmal löschen – die Mehrfachauswahl der Abgleichliste.
*
* Eine schon erzeugte Buchung bleibt stehen: Sie gehört ins Kontoblatt und
* wird auch nur dort gelöscht. Weg ist allein die abgerufene Zeile – und mit
* ihr ihr Fingerabdruck, weshalb ein gelöschter Umsatz beim nächsten Abruf
* wieder hereinkommen kann.
*
* @param list<int> $ids
* @return int Wie viele Umsätze tatsächlich gelöscht wurden.
*/
public static function mehrereLoeschen(array $ids): int
{
$ids = array_values(array_unique(array_filter($ids, static fn (int $id): bool => $id > 0)));
if ($ids === []) {
return 0;
}
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
return Database::execute(
'DELETE FROM bank_umsaetze WHERE benutzer_id = ? AND id IN (' . $platzhalter . ')',
array_merge([Mandant::id()], $ids)
);
}
}