'Entwurf', 'freigabe' => 'wartet auf Freigabe', 'gesendet' => 'gesendet', 'fehler' => 'fehlgeschlagen', 'storniert' => 'storniert', ]; /** @return list> */ public static function alle(string $status = ''): array { $params = [Mandant::id()]; $sql = 'SELECT z.*, k.name AS kontoname, kat.pfad AS kategorie_pfad, g.name AS gegenkontoname, (SELECT COUNT(*) FROM bank_protokolle p WHERE p.auftrag_id = z.id) AS protokolle FROM zahlungsauftraege z JOIN konten k ON k.id = z.konto_id LEFT JOIN kategorien kat ON kat.id = z.kategorie_id LEFT JOIN konten g ON g.id = z.gegenkonto_id WHERE z.benutzer_id = ?'; if ($status !== '' && array_key_exists($status, self::STATUS)) { $sql .= ' AND z.status = ?'; $params[] = $status; } return Database::fetchAll( $sql . ' ORDER BY z.status = \'gesendet\', z.ausfuehrung_am IS NULL, z.ausfuehrung_am, z.erstellt_am DESC', $params ); } public static function find(int $id): ?array { $zeile = Database::fetchOne( 'SELECT * FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()] ); return $zeile === false ? null : $zeile; } /** @param array $daten */ public static function anlegen(array $daten): int { $f = self::normalisiere($daten); Database::execute( 'INSERT INTO zahlungsauftraege (benutzer_id, konto_id, zugang_id, empfaenger, iban, bic, betrag, waehrung, verwendungszweck, ausfuehrung_am, echtzeit, kategorie_id, gegenkonto_id, klasse_id, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'], $f['klasse_id'], 'entwurf', ] ); return Database::lastInsertId(); } /** @param array $daten */ public static function speichern(int $id, array $daten): void { $auftrag = self::find($id); if ($auftrag === null) { throw new \RuntimeException('Den Zahlungsauftrag gibt es nicht.'); } if ($auftrag['status'] === 'gesendet') { throw new \RuntimeException('Ein gesendeter Auftrag lässt sich nicht mehr ändern.'); } $f = self::normalisiere($daten); Database::execute( 'UPDATE zahlungsauftraege SET konto_id = ?, zugang_id = ?, empfaenger = ?, iban = ?, bic = ?, betrag = ?, waehrung = ?, verwendungszweck = ?, ausfuehrung_am = ?, echtzeit = ?, kategorie_id = ?, gegenkonto_id = ?, klasse_id = ?, status = \'entwurf\', meldung = NULL, bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL WHERE id = ? AND benutzer_id = ?', [ $f['konto_id'], $f['zugang_id'], $f['empfaenger'], $f['iban'], $f['bic'], $f['betrag'], $f['waehrung'], $f['verwendungszweck'], $f['ausfuehrung_am'], $f['echtzeit'], $f['kategorie_id'], $f['gegenkonto_id'], $f['klasse_id'], $id, Mandant::id(), ] ); } public static function loeschen(int $id): void { $auftrag = self::find($id); if ($auftrag !== null && $auftrag['status'] === 'gesendet') { throw new \RuntimeException('Ein gesendeter Auftrag bleibt als Beleg stehen.'); } if ($auftrag !== null && self::hatProtokoll($id)) { // Was die Bank gesehen hat, bleibt nachweisbar: stornieren ja, löschen nein. throw new \RuntimeException( 'Zu diesem Auftrag gibt es ein Übermittlungsprotokoll – er bleibt als Beleg stehen und kann nur storniert werden.' ); } Database::execute('DELETE FROM zahlungsauftraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } public static function stornieren(int $id): void { Database::execute( "UPDATE zahlungsauftraege SET status = 'storniert' WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'", [$id, Mandant::id()] ); } /** * Vermerkt, dass die Bank den Auftrag angenommen hat, und bucht ihn auf dem * Auftragskonto. */ public static function alsGesendetVermerken(int $id, string $meldung = ''): void { $auftrag = self::find($id); if ($auftrag === null) { return; } Database::transaktion(static function () use ($auftrag, $id, $meldung): void { // Eine Terminüberweisung wird zum Ausführungstag gebucht, eine // sofortige heute. $datum = (string)($auftrag['ausfuehrung_am'] ?? '') ?: date('Y-m-d'); $buchungId = Buchung::anlegen([ 'konto_id' => (int)$auftrag['konto_id'], 'datum' => $datum, // Eine Überweisung geht ab: Der Betrag wird negativ gebucht. 'betrag' => -abs((float)$auftrag['betrag']), 'empfaenger' => (string)$auftrag['empfaenger'], 'buchungstext' => (string)$auftrag['verwendungszweck'], 'kategorie_id' => $auftrag['kategorie_id'], 'gegenkonto_id' => $auftrag['gegenkonto_id'] ?? null, 'klasse_id' => $auftrag['klasse_id'], 'quelle' => 'fints', ]); Database::execute( "UPDATE zahlungsauftraege SET status = 'gesendet', gesendet_am = NOW(), meldung = ?, buchung_id = ?, bank_auftrag_id = NULL, echtzeit_status = NULL, status_abfrage_ab = NULL WHERE id = ? AND benutzer_id = ?", [mb_substr($meldung, 0, 2000), $buchungId, $id, Mandant::id()] ); }); } /** Hat der Auftrag die Bank schon einmal erreicht? Dann gibt es ein Protokoll. */ public static function hatProtokoll(int $id): bool { return (int)Database::fetchValue( 'SELECT COUNT(*) FROM bank_protokolle WHERE auftrag_id = ? AND benutzer_id = ?', [$id, Mandant::id()], 0 ) > 0; } /** * Die Bank hat den Auftrag entgegengenommen, führt ihn aber erst nach der * Freigabe (TAN, Namensabgleich) aus. Bis dahin ist er weder Entwurf noch * gesendet. */ public static function alsWartendVermerken(int $id, string $meldung): void { Database::execute( "UPDATE zahlungsauftraege SET status = 'freigabe', meldung = ? WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'", [mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } public static function alsFehlerVermerken(int $id, string $meldung): void { Database::execute( "UPDATE zahlungsauftraege SET status = 'fehler', meldung = ? WHERE id = ? AND benutzer_id = ? AND status <> 'gesendet'", [mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } /** * Die Bank hat die Echtzeitüberweisung entgegengenommen, kennt ihren Ausgang * aber noch nicht (Rückmeldung 3045). Der Auftrag bleibt gesendet und * gebucht; mit der Auftragsidentifikation der Bank lässt sich der Status * nachfragen – frühestens nach der Mindestwartezeit der Bank. */ public static function echtzeitOffenVermerken(int $id, ?string $bankAuftragId, int $wartezeit, string $meldung): void { Database::execute( "UPDATE zahlungsauftraege SET echtzeit_status = 'offen', bank_auftrag_id = COALESCE(?, bank_auftrag_id), status_abfrage_ab = ?, meldung = ? WHERE id = ? AND benutzer_id = ? AND status = 'gesendet'", // In PHP gerechnet wie der Vergleich in sekundenBisStatusabfrage() – // so stört keine abweichende Zeitzone der Datenbank. [$bankAuftragId, date('Y-m-d H:i:s', time() + max(0, $wartezeit)), mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } /** Die Bank bestätigt die Ausführung – oder der Umsatz ist schon da. */ public static function echtzeitAusgefuehrtVermerken(int $id, string $meldung): void { Database::execute( "UPDATE zahlungsauftraege SET echtzeit_status = 'ausgefuehrt', status_abfrage_ab = NULL, meldung = ? WHERE id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'", [mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } /** * Die Bank meldet die Echtzeitüberweisung als abgelehnt oder fehlgeschlagen. * Das Geld ist nicht abgegangen: Die Buchung verschwindet wieder, und der * Auftrag lässt sich korrigieren und erneut senden. */ public static function echtzeitAbgelehntVermerken(int $id, string $meldung): void { $auftrag = self::find($id); if ($auftrag === null || $auftrag['echtzeit_status'] !== 'offen') { return; } $buchungId = $auftrag['buchung_id'] === null ? null : (int)$auftrag['buchung_id']; if ($buchungId !== null) { try { Buchung::loeschen($buchungId); $buchungId = null; } catch (\Throwable $e) { $meldung .= ' Die Buchung ließ sich nicht entfernen (' . $e->getMessage() . ') – bitte von Hand löschen.'; } } Database::execute( "UPDATE zahlungsauftraege SET status = 'fehler', echtzeit_status = 'abgelehnt', status_abfrage_ab = NULL, buchung_id = ?, meldung = ? WHERE id = ? AND benutzer_id = ?", [$buchungId, mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } /** * Ein abgerufener Umsatz wurde mit der Buchung einer Echtzeitüberweisung * verbunden, deren Status noch offen war: Das Geld ist abgegangen. */ public static function echtzeitMitUmsatzAbgleichen(int $buchungId): void { Database::execute( "UPDATE zahlungsauftraege SET echtzeit_status = 'ausgefuehrt', status_abfrage_ab = NULL, meldung = 'Die Echtzeitüberweisung ist im Kontoumsatz angekommen.' WHERE buchung_id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'", [$buchungId, Mandant::id()] ); } /** * Keine weitere automatische Statusabfrage: Der Ausgang bleibt offen, bis * ihn der Kontoumsatz klärt. */ public static function echtzeitAbfrageEinstellen(int $id, string $meldung): void { Database::execute( "UPDATE zahlungsauftraege SET status_abfrage_ab = NULL, meldung = ? WHERE id = ? AND benutzer_id = ? AND echtzeit_status = 'offen'", [mb_substr($meldung, 0, 2000), $id, Mandant::id()] ); } /** * Offene Echtzeitüberweisungen, deren Statusabfrage fällig ist – etwa weil * ein Neustart des Containers den Hintergrundprozess beendet hat. * * @return list */ public static function faelligeStatusabfragen(): array { return array_map('intval', array_column(Database::fetchAll( "SELECT id FROM zahlungsauftraege WHERE benutzer_id = ? AND echtzeit_status = 'offen' AND bank_auftrag_id IS NOT NULL AND status_abfrage_ab IS NOT NULL AND status_abfrage_ab <= ?", [Mandant::id(), date('Y-m-d H:i:s')] ), 'id')); } /** * Ob der Status einer offenen Echtzeitüberweisung schon wieder nachgefragt * werden darf. Liefert die Sekunden bis dahin, 0 = jetzt, null = nichts offen * oder die automatische Abfrage eingestellt. * * @param array $auftrag */ public static function sekundenBisStatusabfrage(array $auftrag): ?int { if (($auftrag['echtzeit_status'] ?? null) !== 'offen' || ($auftrag['bank_auftrag_id'] ?? '') === '') { return null; } // Ohne Zeitpunkt ist die automatische Abfrage eingestellt. $ab = strtotime((string)($auftrag['status_abfrage_ab'] ?? '')); return $ab === false ? null : max(0, $ab - time()); } public static function anzahlOffen(): int { return (int)Database::fetchValue( "SELECT COUNT(*) FROM zahlungsauftraege WHERE benutzer_id = ? AND status IN ('entwurf', 'freigabe')", [Mandant::id()], 0 ); } /** * @param array $daten * @return array */ private static function normalisiere(array $daten): array { $kontoId = (int)($daten['konto_id'] ?? 0); if ($kontoId === 0 || Konto::find($kontoId) === null) { throw new \InvalidArgumentException('Es fehlt ein gültiges Auftragskonto.'); } $empfaenger = trim((string)($daten['empfaenger'] ?? '')); if ($empfaenger === '') { throw new \InvalidArgumentException('Es fehlt der Empfänger.'); } $iban = strtoupper(str_replace(' ', '', (string)($daten['iban'] ?? ''))); if (!Adresse::ibanIstGueltig($iban)) { throw new \InvalidArgumentException('Die IBAN des Empfängers ist nicht gültig.'); } $betrag = round((float)($daten['betrag'] ?? 0), 2); if ($betrag <= 0.0) { throw new \InvalidArgumentException('Der Betrag muss größer als null sein.'); } $termin = trim((string)($daten['ausfuehrung_am'] ?? '')); if ($termin !== '' && !Buchung::istDatum($termin)) { throw new \InvalidArgumentException('Der Ausführungstermin muss als JJJJ-MM-TT vorliegen.'); } if ($termin !== '' && $termin < date('Y-m-d')) { throw new \InvalidArgumentException('Der Ausführungstermin liegt in der Vergangenheit.'); } // Eine Echtzeitüberweisung ist binnen Sekunden beim Empfänger – einen // Termin kennt sie nicht. Der heutige Tag ist ohnehin „sofort“. $echtzeit = !empty($daten['echtzeit']); if ($echtzeit && $termin !== '' && $termin > date('Y-m-d')) { throw new \InvalidArgumentException( 'Eine Echtzeitüberweisung wird sofort ausgeführt und kann keinen Ausführungstermin haben.' ); } $id = static fn(mixed $wert): ?int => ($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert; // Erst beim Senden entsteht die Buchung – ein Gegenkonto, das sie // ablehnen würde, fällt besser schon beim Erfassen auf. $gegenkonto = $id($daten['gegenkonto_id'] ?? null); if ($gegenkonto !== null && $gegenkonto === $kontoId) { throw new \InvalidArgumentException('Eine Umbuchung braucht zwei verschiedene Konten.'); } if ($gegenkonto !== null && Konto::find($gegenkonto) === null) { throw new \InvalidArgumentException('Das Gegenkonto gibt es nicht.'); } return [ 'konto_id' => $kontoId, 'zugang_id' => $id($daten['zugang_id'] ?? null), 'empfaenger' => mb_substr($empfaenger, 0, 255), 'iban' => $iban, 'bic' => mb_substr(strtoupper(trim((string)($daten['bic'] ?? ''))), 0, 11), 'betrag' => $betrag, 'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3), // 140 Zeichen sind das SEPA-Maß für den Verwendungszweck; was // darüber steht, würde die Bank ohnehin abschneiden. 'verwendungszweck' => mb_substr(trim((string)($daten['verwendungszweck'] ?? '')), 0, 140), 'ausfuehrung_am' => $termin !== '' && !$echtzeit ? $termin : null, 'echtzeit' => $echtzeit ? 1 : 0, // Kategorie oder Gegenkonto – nie beides, wie bei der Buchung. 'kategorie_id' => $gegenkonto === null ? $id($daten['kategorie_id'] ?? null) : null, 'gegenkonto_id' => $gegenkonto, 'klasse_id' => $id($daten['klasse_id'] ?? null), ]; } }