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>
417 lines
15 KiB
PHP
417 lines
15 KiB
PHP
<?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)
|
||
);
|
||
}
|
||
}
|