Finanzmanager/src/Models/Zahlungsauftrag.php
fds2610-bot c58837dee8 Fix #124: Umbuchungskonten in jeder Kategorieauswahl
Unter den Kategorien stehen überall die aktiven Umbuchungskonten, je
Kontengruppe gruppiert und in deren Reihenfolge (Konto::umbuchungsgruppen()):
Regeln samt Splitzeilen, Umsatzabgleich, Buchungsvorlagen, Regelmäßige
Buchungen, Zahlungsverkehr, Verträge sowie die Filter von Buchungsbericht
und Export. Die Masken nutzen dafür die gemeinsame Liste Kategorie /
Gegenkonto der Buchungsmaske; das getrennte Gegenkonto-Feld der Regel
entfällt.

Regel-Splitzeilen können auf ein Umbuchungskonto zeigen. Zahlungsaufträge
und Verträge bekommen ein Gegenkonto (Migration 030). Ein schon gewähltes
inaktives Konto bleibt sichtbar, damit keine Zuordnung verloren geht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:30:44 +02:00

415 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Finanzmanager\Models;
use Finanzmanager\Database;
use Finanzmanager\Mandant;
/**
* Überweisungen: erfasst, geprüft, über FinTS abgesendet.
*
* Der Auftrag bleibt als Beleg stehen, auch wenn die Bank ihn ablehnt – mit der
* Meldung daneben. Eine Überweisung geht als gewöhnliche Überweisung,
* Terminüberweisung oder Echtzeitüberweisung hinaus; die Echtzeitüberweisung
* behandelt SEPA als eigenen Geschäftsvorfall und kennt keinen Termin. Erst wenn die Bank ihn angenommen hat, entsteht die
* zugehörige Buchung auf dem Auftragskonto; vorher wäre sie eine Behauptung.
*/
class Zahlungsauftrag
{
public const STATUS = [
'entwurf' => 'Entwurf',
'freigabe' => 'wartet auf Freigabe',
'gesendet' => 'gesendet',
'fehler' => 'fehlgeschlagen',
'storniert' => 'storniert',
];
/** @return list<array<string,mixed>> */
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<string,mixed> $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<string,mixed> $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<int>
*/
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<string,mixed> $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<string,mixed> $daten
* @return array<string,mixed>
*/
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),
];
}
}