Neuer Schalter „Bewusst ohne Kategorie“ in der Buchungsmaske für Abschreibungen und rein rechnerische Solarerträge. Solche Buchungen erscheinen nicht unter den offenen Vorgängen im Cockpit, nicht im Prüflauf des Monatsabschlusses, setzen das Konto in der Kontenübersicht nicht fett und werden von der Regelanwendung sowie dem automatischen Zusammenführen zu Umbuchungen ausgelassen. Kommt eine Kategorie, ein Gegenkonto oder eine Splitbuchung hinzu, entfällt die Markierung. Migration 028_bewusst_ohne_kategorie.sql, Test 59 und E2E-Test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
867 lines
34 KiB
PHP
867 lines
34 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.
|
||
* Ein bekannter Umsatz bekommt dabei nachgetragen, was ihm bisher
|
||
* fehlte (siehe ergaenzen()).
|
||
*/
|
||
public static function anlegen(array $daten): ?int
|
||
{
|
||
$externId = (string)($daten['extern_id'] ?? '');
|
||
if ($externId === '') {
|
||
return null;
|
||
}
|
||
if (self::existiert($externId)) {
|
||
self::ergaenzen($externId, $daten);
|
||
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, gegen_iban, gegen_bankname, 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(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34),
|
||
mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128),
|
||
mb_substr(trim((string)($daten['buchungsart'] ?? '')), 0, 100),
|
||
($daten['roh'] ?? null) ?: null,
|
||
]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Trägt Empfänger, IBAN und Bank der Gegenseite bei einem bereits bekannten
|
||
* Umsatz nach. Ältere Abrufe haben sie noch nicht gespeichert – den
|
||
* Empfänger etwa bei Kartenverfügungen, die ihn nur im Verwendungszweck
|
||
* nennen (#119); der erneute Abruf liefert sie, und der Fingerabdruck
|
||
* erkennt den Umsatz nur als bekannt.
|
||
* Nur leere Felder werden gefüllt – was schon dasteht, bleibt. Hängt der
|
||
* Umsatz an einer Buchung, bekommt auch sie die Angaben, soweit sie dort
|
||
* fehlen.
|
||
*
|
||
* @param array<string,mixed> $daten
|
||
*/
|
||
public static function ergaenzen(string $externId, array $daten): void
|
||
{
|
||
$empfaenger = mb_substr(trim((string)($daten['empfaenger'] ?? '')), 0, 255);
|
||
$iban = mb_substr(strtoupper(trim((string)($daten['gegen_iban'] ?? ''))), 0, 34);
|
||
$bankname = mb_substr(trim((string)($daten['gegen_bankname'] ?? '')), 0, 128);
|
||
if ($empfaenger === '' && $iban === '' && $bankname === '') {
|
||
return;
|
||
}
|
||
|
||
Database::execute(
|
||
"UPDATE bank_umsaetze
|
||
SET empfaenger = IF(empfaenger = '', ?, empfaenger),
|
||
gegen_iban = IF(gegen_iban = '', ?, gegen_iban),
|
||
gegen_bankname = IF(gegen_bankname = '', ?, gegen_bankname)
|
||
WHERE benutzer_id = ? AND extern_id = ?",
|
||
[$empfaenger, $iban, $bankname, Mandant::id(), $externId]
|
||
);
|
||
|
||
$buchungId = Database::fetchValue(
|
||
'SELECT buchung_id FROM bank_umsaetze WHERE benutzer_id = ? AND extern_id = ?',
|
||
[Mandant::id(), $externId]
|
||
);
|
||
if ($buchungId !== null && $buchungId !== false) {
|
||
self::herkunftUebertragen((int)$buchungId, $empfaenger, $iban, $bankname);
|
||
}
|
||
}
|
||
|
||
/** Füllt Empfänger, IBAN und Bank einer Buchung, soweit sie dort noch leer sind. */
|
||
private static function herkunftUebertragen(int $buchungId, string $empfaenger, string $iban, string $bankname): void
|
||
{
|
||
Database::execute(
|
||
"UPDATE buchungen
|
||
SET empfaenger = IF(empfaenger = '', ?, empfaenger),
|
||
iban = IF(iban = '', ?, iban),
|
||
bankname = IF(bankname = '', ?, bankname)
|
||
WHERE id = ? AND benutzer_id = ?",
|
||
[$empfaenger, $iban, $bankname, $buchungId, Mandant::id()]
|
||
);
|
||
}
|
||
|
||
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,
|
||
* sicher: bool
|
||
* }
|
||
* `sicher` sagt, ob der automatische Lauf den Vorschlag ohne Rückfrage ausführen darf.
|
||
*/
|
||
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,
|
||
'sicher' => true,
|
||
];
|
||
}
|
||
|
||
$gegenseite = self::gegenbuchung($umsatz);
|
||
if ($gegenseite !== null) {
|
||
return [
|
||
'art' => 'verbinden', 'buchung' => $gegenseite['buchung'], 'regel' => null, 'zuordnung' => null,
|
||
'sicher' => $gegenseite['sicher'],
|
||
];
|
||
}
|
||
|
||
$regel = AbgleichRegel::passendeRegel($umsatz, $regeln);
|
||
if ($regel !== null) {
|
||
return [
|
||
'art' => 'uebernehmen',
|
||
'buchung' => null,
|
||
'regel' => $regel,
|
||
'zuordnung' => AbgleichRegel::anwenden($regel, $umsatz),
|
||
'sicher' => true,
|
||
];
|
||
}
|
||
|
||
return ['art' => 'uebernehmen', 'buchung' => null, 'regel' => null, 'zuordnung' => null, 'sicher' => true];
|
||
}
|
||
|
||
/**
|
||
* Die IBANs der Bankkonten, die zu einem Konto gehören. Leer bei Konten ohne
|
||
* Bankanbindung (Bargeld) und bei PayPal, das keine IBAN liefert.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function kontoIbans(int $kontoId): array
|
||
{
|
||
$zeilen = Database::fetchAll(
|
||
"SELECT DISTINCT UPPER(bk.iban) AS iban
|
||
FROM bank_konten bk
|
||
JOIN bank_zugaenge z ON z.id = bk.zugang_id
|
||
WHERE bk.konto_id = ? AND z.benutzer_id = ? AND bk.iban <> ''",
|
||
[$kontoId, Mandant::id()]
|
||
);
|
||
|
||
return array_map(static fn (array $z): string => (string)$z['iban'], $zeilen);
|
||
}
|
||
|
||
/**
|
||
* Ob die Gegen-IBANs zweier Umsätze zu den Konten passen, auf denen sie stehen.
|
||
*
|
||
* Jeder Umsatz nennt die IBAN seiner Gegenseite; sie muss eine IBAN des anderen
|
||
* Kontos sein. Das prüft nur, was beide Seiten wissen: Fehlt einem Konto die
|
||
* IBAN (PayPal) oder dem Umsatz die Gegen-IBAN, sagt diese Seite nichts.
|
||
*
|
||
* @param list<string> $ibansA IBANs des Kontos von Umsatz A
|
||
* @param list<string> $ibansB IBANs des Kontos von Umsatz B
|
||
* @return bool|null false = Widerspruch, true = bestätigt, null = nicht zu entscheiden
|
||
*/
|
||
private static function ibanUrteil(string $gegenA, array $ibansB, string $gegenB, array $ibansA): ?bool
|
||
{
|
||
$bestaetigt = false;
|
||
foreach ([[$gegenA, $ibansB], [$gegenB, $ibansA]] as [$gegen, $eigene]) {
|
||
$gegen = strtoupper(trim((string)$gegen));
|
||
if ($gegen === '' || $eigene === []) {
|
||
continue;
|
||
}
|
||
if (!in_array($gegen, $eigene, true)) {
|
||
return false;
|
||
}
|
||
$bestaetigt = true;
|
||
}
|
||
|
||
return $bestaetigt ? true : null;
|
||
}
|
||
|
||
/**
|
||
* Sucht zu einem offenen Umsatz die Buchung seiner Gegenseite: die einzeln
|
||
* erfasste Buchung, die ein früherer Abruf vom anderen Konto der Umbuchung
|
||
* angelegt hat. Sie bekommt beim Verbinden das Gegenkonto und wird so zur
|
||
* Umbuchung, statt dass der Betrag im Kontoblatt doppelt steht.
|
||
*
|
||
* Kriterien: anderes Konto, Betrag mit umgekehrtem Vorzeichen, Buchungstag
|
||
* höchstens `banking_abgleich_tage` entfernt, keine Kategorie, keine Splitbuchung,
|
||
* hängt an genau einem Bankumsatz ihres Kontos – und die IBANs widersprechen sich
|
||
* nicht. Unter mehreren Kandidaten gewinnt der zeitlich nächste.
|
||
*
|
||
* `sicher` ist wahr, wenn die IBANs sich bestätigen oder es nur einen Kandidaten
|
||
* gibt; sonst ist es nur ein Vorschlag für die Liste.
|
||
*
|
||
* @param array<string,mixed> $umsatz
|
||
* @return array{buchung: array<string,mixed>, sicher: bool}|null
|
||
*/
|
||
public static function gegenbuchung(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);
|
||
if (abs($betrag) < 0.005) {
|
||
return null;
|
||
}
|
||
|
||
$kandidaten = Database::fetchAll(
|
||
'SELECT b.*, u2.gegen_iban AS gegenseite_iban
|
||
FROM buchungen b
|
||
JOIN bank_umsaetze u2 ON u2.buchung_id = b.id AND u2.konto_id = b.konto_id
|
||
WHERE b.benutzer_id = ?
|
||
AND b.konto_id <> ?
|
||
AND b.gegenkonto_id IS NULL AND b.gesplittet = 0 AND b.kategorie_id IS NULL
|
||
AND b.bewusst_ohne_kategorie = 0
|
||
AND ABS(b.betrag + ?) < 0.005
|
||
AND ABS(DATEDIFF(b.datum, ?)) <= ?
|
||
AND u2.id <> ?
|
||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) = 1
|
||
ORDER BY ABS(DATEDIFF(b.datum, ?)), b.id',
|
||
[
|
||
Mandant::id(),
|
||
$kontoId,
|
||
$betrag,
|
||
(string)$umsatz['buchungstag'],
|
||
$tage,
|
||
(int)($umsatz['id'] ?? 0),
|
||
(string)$umsatz['buchungstag'],
|
||
]
|
||
);
|
||
|
||
$eigene = self::kontoIbans($kontoId);
|
||
$bestaetigt = [];
|
||
$neutral = [];
|
||
foreach ($kandidaten as $kandidat) {
|
||
$urteil = self::ibanUrteil(
|
||
(string)($umsatz['gegen_iban'] ?? ''),
|
||
self::kontoIbans((int)$kandidat['konto_id']),
|
||
(string)$kandidat['gegenseite_iban'],
|
||
$eigene
|
||
);
|
||
if ($urteil === true) {
|
||
$bestaetigt[] = $kandidat;
|
||
} elseif ($urteil === null) {
|
||
$neutral[] = $kandidat;
|
||
}
|
||
}
|
||
|
||
if ($bestaetigt !== []) {
|
||
return ['buchung' => $bestaetigt[0], 'sicher' => true];
|
||
}
|
||
if ($neutral !== []) {
|
||
return ['buchung' => $neutral[0], 'sicher' => count($neutral) === 1];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Sucht zu einer Umbuchung die Gegenseite, die schon als eigene Buchung im
|
||
* Kontoblatt des Gegenkontos steht, und legt beide zusammen.
|
||
*
|
||
* Die Gegenseite ist entweder eine einzeln erfasste Buchung (ohne Kategorie und
|
||
* Klasse, nicht bewusst ohne Kategorie gebucht) oder eine Umbuchung in umgekehrter Richtung, an der auf diesem Konto
|
||
* noch kein Umsatz hängt. Betrag mit umgekehrtem Vorzeichen, Buchungstag
|
||
* höchstens `banking_abgleich_tage` entfernt; widersprechen sich die IBANs der
|
||
* Umsätze, scheidet der Kandidat aus. Bestätigte IBANs gehen vor, dann gewinnt
|
||
* der zeitlich nächste. Der Nutzer hat das Gegenkonto selbst gewählt, deshalb
|
||
* genügt hier auch ein mehrdeutiger Treffer.
|
||
*
|
||
* Die Umbuchung bleibt bestehen; ist die Gegenseite schon eine, bleibt diese
|
||
* und die eben zugeordnete fällt weg. Die Umsätze hängen danach an der
|
||
* verbliebenen Buchung.
|
||
*
|
||
* @return bool Ob eine Gegenseite gefunden und zusammengelegt wurde.
|
||
*/
|
||
public static function gegenseiteZusammenfuehren(int $buchungId): bool
|
||
{
|
||
$buchung = Buchung::find($buchungId);
|
||
if ($buchung === null || $buchung['gegenkonto_id'] === null || (int)$buchung['gesplittet'] !== 0) {
|
||
return false;
|
||
}
|
||
|
||
$konto = (int)$buchung['konto_id'];
|
||
$gegen = (int)$buchung['gegenkonto_id'];
|
||
$betrag = round((float)$buchung['betrag'], 2);
|
||
$tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5));
|
||
if (abs($betrag) < 0.005) {
|
||
return false;
|
||
}
|
||
|
||
$eigenerUmsatzIban = (string)Database::fetchValue(
|
||
'SELECT gegen_iban FROM bank_umsaetze WHERE buchung_id = ? AND konto_id = ? LIMIT 1',
|
||
[$buchungId, $konto],
|
||
''
|
||
);
|
||
|
||
$kandidaten = Database::fetchAll(
|
||
'SELECT b.*,
|
||
(SELECT x.gegen_iban FROM bank_umsaetze x
|
||
WHERE x.buchung_id = b.id AND x.konto_id = b.konto_id LIMIT 1) AS umsatz_iban
|
||
FROM buchungen b
|
||
WHERE b.benutzer_id = ? AND b.konto_id = ? AND b.id <> ?
|
||
AND b.gesplittet = 0
|
||
AND (
|
||
(b.gegenkonto_id IS NULL AND b.kategorie_id IS NULL AND b.klasse_id IS NULL
|
||
AND b.bewusst_ohne_kategorie = 0)
|
||
OR b.gegenkonto_id = ?
|
||
)
|
||
AND ABS(b.betrag + ?) < 0.005
|
||
AND ABS(DATEDIFF(b.datum, ?)) <= ?
|
||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id AND x.konto_id = ?) = 0
|
||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) <= 1
|
||
ORDER BY ABS(DATEDIFF(b.datum, ?)), b.id',
|
||
[
|
||
Mandant::id(), $gegen, $buchungId, $konto, $betrag,
|
||
(string)$buchung['datum'], $tage, $konto, (string)$buchung['datum'],
|
||
]
|
||
);
|
||
|
||
$eigene = self::kontoIbans($konto);
|
||
$gewaehlt = null;
|
||
foreach ($kandidaten as $kandidat) {
|
||
$urteil = self::ibanUrteil(
|
||
$eigenerUmsatzIban,
|
||
self::kontoIbans($gegen),
|
||
(string)($kandidat['umsatz_iban'] ?? ''),
|
||
$eigene
|
||
);
|
||
if ($urteil === false) {
|
||
continue;
|
||
}
|
||
if ($urteil === true) {
|
||
$gewaehlt = $kandidat;
|
||
break;
|
||
}
|
||
$gewaehlt ??= $kandidat;
|
||
}
|
||
if ($gewaehlt === null) {
|
||
return false;
|
||
}
|
||
|
||
// Bleibt die Gegenseite, wenn sie schon eine Umbuchung ist.
|
||
[$bleibt, $faellt] = $gewaehlt['gegenkonto_id'] !== null
|
||
? [(int)$gewaehlt['id'], $buchungId]
|
||
: [$buchungId, (int)$gewaehlt['id']];
|
||
|
||
Database::transaktion(static function () use ($bleibt, $faellt): void {
|
||
Database::execute(
|
||
'UPDATE bank_umsaetze SET buchung_id = ? WHERE buchung_id = ? AND benutzer_id = ?',
|
||
[$bleibt, $faellt, Mandant::id()]
|
||
);
|
||
Database::execute(
|
||
'UPDATE buchungen SET abgeglichen = 1
|
||
WHERE id = ? AND benutzer_id = ? AND EXISTS (SELECT 1 FROM bank_umsaetze WHERE buchung_id = ?)',
|
||
[$bleibt, Mandant::id(), $bleibt]
|
||
);
|
||
Buchung::loeschen($faellt);
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Führt Umbuchungen zusammen, die beide Seiten als eigene Buchung haben: Beide
|
||
* Umsätze sind schon abgerufen und einzeln gebucht worden, bevor der Abgleich
|
||
* sie als zusammengehörig erkannte.
|
||
*
|
||
* Kriterien wie bei gegenbuchung(); beide Buchungen ohne Kategorie und Klasse,
|
||
* nicht bewusst ohne Kategorie gebucht, ungesplittet, mit genau einem Umsatz. Bestätigen die IBANs ein Paar, gewinnt
|
||
* der zeitlich nächste Partner; ohne Bestätigung nur ein Paar, in dem beide
|
||
* einander als einziger Kandidat haben. Die ältere Buchung wird zur Umbuchung,
|
||
* die jüngere fällt weg und ihr Umsatz hängt sich an die ältere.
|
||
*
|
||
* @return array{zusammengefuehrt: int, fehler: list<string>}
|
||
*/
|
||
public static function umbuchungenZusammenfuehren(): array
|
||
{
|
||
$tage = max(0, Einstellung::getInt('banking_abgleich_tage', 5));
|
||
|
||
$paare = Database::fetchAll(
|
||
'SELECT a.id AS a_id, b.id AS b_id, a.konto_id AS a_konto, b.konto_id AS b_konto,
|
||
a.datum AS a_datum, a.betrag AS a_betrag,
|
||
ua.gegen_iban AS a_iban, ub.gegen_iban AS b_iban,
|
||
ABS(DATEDIFF(a.datum, b.datum)) AS abstand
|
||
FROM buchungen a
|
||
JOIN buchungen b ON b.benutzer_id = a.benutzer_id AND b.konto_id <> a.konto_id AND a.id < b.id
|
||
JOIN bank_umsaetze ua ON ua.buchung_id = a.id AND ua.konto_id = a.konto_id
|
||
JOIN bank_umsaetze ub ON ub.buchung_id = b.id AND ub.konto_id = b.konto_id
|
||
WHERE a.benutzer_id = ?
|
||
AND a.gegenkonto_id IS NULL AND b.gegenkonto_id IS NULL
|
||
AND a.gesplittet = 0 AND b.gesplittet = 0
|
||
AND a.kategorie_id IS NULL AND b.kategorie_id IS NULL
|
||
AND a.klasse_id IS NULL AND b.klasse_id IS NULL
|
||
AND a.bewusst_ohne_kategorie = 0 AND b.bewusst_ohne_kategorie = 0
|
||
AND ABS(a.betrag) >= 0.005 AND ABS(a.betrag + b.betrag) < 0.005
|
||
AND ABS(DATEDIFF(a.datum, b.datum)) <= ?
|
||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = a.id) = 1
|
||
AND (SELECT COUNT(*) FROM bank_umsaetze x WHERE x.buchung_id = b.id) = 1
|
||
ORDER BY abstand, a.id, b.id',
|
||
[Mandant::id(), $tage]
|
||
);
|
||
|
||
$kanten = [];
|
||
$grad = [];
|
||
foreach ($paare as $paar) {
|
||
$urteil = self::ibanUrteil(
|
||
(string)$paar['a_iban'],
|
||
self::kontoIbans((int)$paar['b_konto']),
|
||
(string)$paar['b_iban'],
|
||
self::kontoIbans((int)$paar['a_konto'])
|
||
);
|
||
if ($urteil === false) {
|
||
continue;
|
||
}
|
||
$paar['bestaetigt'] = $urteil === true;
|
||
$kanten[] = $paar;
|
||
$grad[(int)$paar['a_id']] = ($grad[(int)$paar['a_id']] ?? 0) + 1;
|
||
$grad[(int)$paar['b_id']] = ($grad[(int)$paar['b_id']] ?? 0) + 1;
|
||
}
|
||
|
||
// Bestätigte Paare zuerst; innerhalb davon bleibt die Sortierung nach Abstand.
|
||
usort($kanten, static fn (array $x, array $y): int => (int)$y['bestaetigt'] <=> (int)$x['bestaetigt']);
|
||
|
||
$benutzt = [];
|
||
$anzahl = 0;
|
||
$fehler = [];
|
||
foreach ($kanten as $kante) {
|
||
$a = (int)$kante['a_id'];
|
||
$b = (int)$kante['b_id'];
|
||
if (isset($benutzt[$a]) || isset($benutzt[$b])) {
|
||
continue;
|
||
}
|
||
if (!$kante['bestaetigt'] && ($grad[$a] !== 1 || $grad[$b] !== 1)) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
Buchung::zuordnen($a, null, (int)$kante['b_konto'], false);
|
||
try {
|
||
Buchung::verknuepfen((int)$kante['b_konto'], $a, $b);
|
||
} catch (\Throwable $e) {
|
||
Buchung::zuordnen($a, null, null, false);
|
||
throw $e;
|
||
}
|
||
$benutzt[$a] = $benutzt[$b] = true;
|
||
$anzahl++;
|
||
} catch (\Throwable $e) {
|
||
$fehler[] = sprintf(
|
||
'%s, %s: %s',
|
||
date('d.m.Y', strtotime((string)$kante['a_datum'])),
|
||
number_format((float)$kante['a_betrag'], 2, ',', '.') . ' €',
|
||
$e->getMessage()
|
||
);
|
||
}
|
||
}
|
||
|
||
return ['zusammengefuehrt' => $anzahl, 'fehler' => $fehler];
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* „Noch frei“ heißt: Auf dieser Kontoseite hängt noch kein anderer Umsatz an
|
||
* der Buchung. Eine Umbuchung erscheint auf zwei Kontoauszügen und nimmt
|
||
* deshalb je Kontoseite einen Umsatz auf – sonst legte der zweite Abruf die
|
||
* Gegenseite als eigene Buchung an, und der Betrag stünde doppelt im Kontoblatt.
|
||
*
|
||
* 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 <> ? AND u.konto_id = ?
|
||
)
|
||
ORDER BY ABS(DATEDIFF(b.datum, ?)) LIMIT 1',
|
||
[
|
||
Mandant::id(),
|
||
$kontoId,
|
||
$betrag,
|
||
$kontoId,
|
||
$betrag,
|
||
(string)$umsatz['buchungstag'],
|
||
$tage,
|
||
(int)($umsatz['id'] ?? 0),
|
||
$kontoId,
|
||
(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'],
|
||
'iban' => (string)($umsatz['gegen_iban'] ?? ''),
|
||
'bankname' => (string)($umsatz['gegen_bankname'] ?? ''),
|
||
'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);
|
||
|
||
// Eine Regel kann das Gegenkonto setzen: Dann steht die andere Seite der
|
||
// Umbuchung vielleicht schon einzeln da.
|
||
if (($zuordnung['gegenkonto_id'] ?? null) !== null && $splits === []) {
|
||
try {
|
||
self::gegenseiteZusammenfuehren($buchungId);
|
||
} catch (\Throwable) {
|
||
// Bleibt getrennt; der Umsatz ist trotzdem gebucht.
|
||
}
|
||
}
|
||
|
||
return $buchungId;
|
||
}
|
||
|
||
/** Verbindet den Umsatz mit einer bereits erfassten Buchung. */
|
||
public static function verbinden(int $id, int $buchungId): void
|
||
{
|
||
// Die Gegenseite einer einzeln erfassten Buchung auf einem anderen Konto:
|
||
// Die Buchung wird zur Umbuchung, sonst stünde der Betrag auf beiden Konten.
|
||
$vorab = self::find($id);
|
||
$buchung = Buchung::find($buchungId);
|
||
if (
|
||
$vorab !== null && $buchung !== null
|
||
&& (int)$vorab['konto_id'] !== 0
|
||
&& (int)$buchung['konto_id'] !== (int)$vorab['konto_id']
|
||
&& $buchung['gegenkonto_id'] === null
|
||
&& (int)$buchung['gesplittet'] === 0
|
||
) {
|
||
Buchung::zuordnen($buchungId, null, (int)$vorab['konto_id'], false);
|
||
}
|
||
|
||
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, ebenso IBAN und Bank der
|
||
// Gegenseite, falls die Buchung sie nicht kennt.
|
||
Database::execute(
|
||
'UPDATE buchungen SET abgeglichen = 1 WHERE id = ? AND benutzer_id = ?',
|
||
[$buchungId, Mandant::id()]
|
||
);
|
||
$umsatz = self::find($id);
|
||
if ($umsatz !== null) {
|
||
self::herkunftUebertragen(
|
||
$buchungId,
|
||
'',
|
||
(string)($umsatz['gegen_iban'] ?? ''),
|
||
(string)($umsatz['gegen_bankname'] ?? '')
|
||
);
|
||
}
|
||
|
||
// War der Ausgang einer Echtzeitüberweisung noch offen, klärt ihn der
|
||
// Umsatz: Das Geld ist abgegangen.
|
||
Zahlungsauftrag::echtzeitMitUmsatzAbgleichen($buchungId);
|
||
}
|
||
|
||
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)
|
||
);
|
||
}
|
||
}
|