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>
415 lines
17 KiB
PHP
415 lines
17 KiB
PHP
<?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),
|
||
];
|
||
}
|
||
}
|