'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 $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 $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 $filter status, zugang_id, konto_id * @return list> */ 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 $umsatz * @param list>|null $regeln * @return array{ * art: string, * buchung: array|null, * regel: array|null, * zuordnung: array|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 */ 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 $ibansA IBANs des Kontos von Umsatz A * @param list $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 $umsatz * @return array{buchung: array, 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} */ 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 $umsatz * @return array|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 $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 $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) ); } }