'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. */ 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 $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 * } */ 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 $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 <> ? ) 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 $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 $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) ); } }