Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
7 KiB
PHP
187 lines
7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Verträge: was laufend kostet und wann es kündbar ist.
|
||
*
|
||
* Der Nutzen liegt in der **Kündigungsfrist**: Die Übersicht zeigt, bis wann
|
||
* gekündigt werden muss, damit ein Vertrag nicht stillschweigend weiterläuft.
|
||
* Ein Vertrag kann mit einer regelmäßigen Buchung verbunden sein, die seine
|
||
* Zahlung erledigt.
|
||
*/
|
||
class Vertrag
|
||
{
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(bool $nurAktive = false): array
|
||
{
|
||
$sql = 'SELECT v.*, a.name AS adressname, k.pfad AS kategorie_pfad,
|
||
r.name AS buchungsname
|
||
FROM vertraege v
|
||
LEFT JOIN adressen a ON a.id = v.adresse_id
|
||
LEFT JOIN kategorien k ON k.id = v.kategorie_id
|
||
LEFT JOIN regelmaessige_buchungen r ON r.id = v.regelmaessige_id
|
||
WHERE v.benutzer_id = ?';
|
||
if ($nurAktive) {
|
||
$sql .= ' AND v.aktiv = 1';
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY v.ende IS NULL, v.ende, v.name', [Mandant::id()]);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM vertraege 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 vertraege
|
||
(benutzer_id, name, partner, adresse_id, vertragsnummer, kategorie_id, betrag,
|
||
intervall, beginn, ende, kuendigungsfrist_tage, regelmaessige_id, notiz, aktiv)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[
|
||
Mandant::id(), $f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'],
|
||
$f['kategorie_id'], $f['betrag'], $f['intervall'], $f['beginn'], $f['ende'],
|
||
$f['kuendigungsfrist_tage'], $f['regelmaessige_id'], $f['notiz'], $f['aktiv'],
|
||
]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
/** @param array<string,mixed> $daten */
|
||
public static function speichern(int $id, array $daten): void
|
||
{
|
||
$f = self::normalisiere($daten);
|
||
|
||
Database::execute(
|
||
'UPDATE vertraege SET name = ?, partner = ?, adresse_id = ?, vertragsnummer = ?,
|
||
kategorie_id = ?, betrag = ?, intervall = ?, beginn = ?, ende = ?,
|
||
kuendigungsfrist_tage = ?, regelmaessige_id = ?, notiz = ?, aktiv = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[
|
||
$f['name'], $f['partner'], $f['adresse_id'], $f['vertragsnummer'], $f['kategorie_id'],
|
||
$f['betrag'], $f['intervall'], $f['beginn'], $f['ende'], $f['kuendigungsfrist_tage'],
|
||
$f['regelmaessige_id'], $f['notiz'], $f['aktiv'], $id, Mandant::id(),
|
||
]
|
||
);
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM vertraege WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
}
|
||
|
||
/**
|
||
* Der letzte Tag, an dem noch gekündigt werden kann: Vertragsende minus
|
||
* Frist. Ohne Ende oder ohne Frist gibt es keinen.
|
||
*/
|
||
public static function kuendigenBis(array $vertrag): ?string
|
||
{
|
||
$ende = (string)($vertrag['ende'] ?? '');
|
||
$tage = (int)($vertrag['kuendigungsfrist_tage'] ?? 0);
|
||
|
||
if ($ende === '' || $tage <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return (new \DateTimeImmutable($ende))->modify('-' . $tage . ' days')->format('Y-m-d');
|
||
}
|
||
|
||
/**
|
||
* Verträge, deren Kündigungsfrist innerhalb der nächsten Tage abläuft –
|
||
* genau die, die man leicht verpasst.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function fristenLaufenAb(int $tage = 60, ?string $heute = null): array
|
||
{
|
||
$heute = $heute ?? date('Y-m-d');
|
||
$grenze = (new \DateTimeImmutable($heute))->modify('+' . $tage . ' days')->format('Y-m-d');
|
||
|
||
$treffer = [];
|
||
foreach (self::alle(true) as $vertrag) {
|
||
$stichtag = self::kuendigenBis($vertrag);
|
||
if ($stichtag === null || $stichtag < $heute || $stichtag > $grenze) {
|
||
continue;
|
||
}
|
||
|
||
$vertrag['kuendigen_bis'] = $stichtag;
|
||
$treffer[] = $vertrag;
|
||
}
|
||
|
||
usort($treffer, static fn(array $a, array $b): int => strcmp($a['kuendigen_bis'], $b['kuendigen_bis']));
|
||
|
||
return $treffer;
|
||
}
|
||
|
||
/** Was der Vertrag im Jahr kostet – zum Vergleich verschieden getakteter Verträge. */
|
||
public static function jahresbetrag(array $vertrag): float
|
||
{
|
||
$betrag = (float)($vertrag['betrag'] ?? 0);
|
||
|
||
return round($betrag * match ((string)($vertrag['intervall'] ?? 'monatlich')) {
|
||
'taeglich' => 365,
|
||
'woechentlich' => 52,
|
||
'zweiwoechentlich' => 26,
|
||
'monatlich' => 12,
|
||
'zweimonatlich' => 6,
|
||
'vierteljaehrlich' => 4,
|
||
'halbjaehrlich' => 2,
|
||
default => 1,
|
||
}, 2);
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $daten
|
||
* @return array<string,mixed>
|
||
*/
|
||
private static function normalisiere(array $daten): array
|
||
{
|
||
$name = trim((string)($daten['name'] ?? ''));
|
||
if ($name === '') {
|
||
throw new \InvalidArgumentException('Der Vertrag braucht einen Namen.');
|
||
}
|
||
|
||
$datum = static function (mixed $wert): ?string {
|
||
$wert = trim((string)$wert);
|
||
return Buchung::istDatum($wert) ? $wert : null;
|
||
};
|
||
|
||
$id = static fn(mixed $wert): ?int =>
|
||
($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
|
||
|
||
$intervall = (string)($daten['intervall'] ?? 'monatlich');
|
||
|
||
return [
|
||
'name' => mb_substr($name, 0, 255),
|
||
'partner' => mb_substr(trim((string)($daten['partner'] ?? '')), 0, 255),
|
||
'adresse_id' => $id($daten['adresse_id'] ?? null),
|
||
'vertragsnummer' => mb_substr(trim((string)($daten['vertragsnummer'] ?? '')), 0, 64),
|
||
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
|
||
'betrag' => round((float)($daten['betrag'] ?? 0), 2),
|
||
'intervall' => array_key_exists($intervall, RegelmaessigeBuchung::INTERVALLE)
|
||
? $intervall : 'monatlich',
|
||
'beginn' => $datum($daten['beginn'] ?? ''),
|
||
'ende' => $datum($daten['ende'] ?? ''),
|
||
'kuendigungsfrist_tage' => max(0, min(999, (int)($daten['kuendigungsfrist_tage'] ?? 0))),
|
||
'regelmaessige_id' => $id($daten['regelmaessige_id'] ?? null),
|
||
'notiz' => trim((string)($daten['notiz'] ?? '')),
|
||
'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1,
|
||
];
|
||
}
|
||
}
|