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>
137 lines
4.9 KiB
PHP
137 lines
4.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Models\Erinnerung;
|
||
use Finanzmanager\Models\RegelmaessigeBuchung;
|
||
use Finanzmanager\Models\Vertrag;
|
||
use Finanzmanager\Models\Zahlungsauftrag;
|
||
use Finanzmanager\View;
|
||
|
||
/**
|
||
* Der Finanzkalender: was in den nächsten Wochen ansteht.
|
||
*
|
||
* Zusammengetragen aus vier Quellen – fällige regelmäßige Buchungen,
|
||
* Erinnerungen, Kündigungsfristen von Verträgen und terminierte Überweisungen.
|
||
* Nichts davon ist vorausberechnet gespeichert; die Termine entstehen bei
|
||
* jedem Aufruf neu aus den Reihen. Eine Tabelle künftiger Termine wäre bei
|
||
* jeder Änderung neu zu erzeugen und liefe doch irgendwann weg.
|
||
*/
|
||
class Finanzkalender
|
||
{
|
||
/** Die Arten von Einträgen, mit ihrer Beschriftung. */
|
||
public const ARTEN = [
|
||
'buchung' => 'Regelmäßige Buchung',
|
||
'erinnerung' => 'Erinnerung',
|
||
'vertrag' => 'Kündigungsfrist',
|
||
'ueberweisung' => 'Terminüberweisung',
|
||
];
|
||
|
||
/**
|
||
* Alle Termine bis `$tage` in der Zukunft, chronologisch. Überfällige
|
||
* stehen vorn – sie sind das, was zuerst Aufmerksamkeit braucht.
|
||
*
|
||
* @return list<array{
|
||
* datum: string, art: string, titel: string, betrag: ?float,
|
||
* pfad: string, ueberfaellig: bool, id: int
|
||
* }>
|
||
*/
|
||
public static function termine(int $tage = 30, ?string $heute = null): array
|
||
{
|
||
$heute = $heute ?? date('Y-m-d');
|
||
$bis = (new \DateTimeImmutable($heute))->modify('+' . max(1, $tage) . ' days')->format('Y-m-d');
|
||
|
||
$termine = [];
|
||
|
||
foreach (RegelmaessigeBuchung::alle(true) as $reihe) {
|
||
foreach (RegelmaessigeBuchung::kommendeTermine($reihe, $bis) as $datum) {
|
||
$termine[] = [
|
||
'id' => (int)$reihe['id'],
|
||
'datum' => $datum,
|
||
'art' => 'buchung',
|
||
'titel' => (string)$reihe['name'],
|
||
'betrag' => (float)$reihe['betrag'],
|
||
'pfad' => '/termine',
|
||
'ueberfaellig' => $datum < $heute,
|
||
];
|
||
}
|
||
}
|
||
|
||
foreach (Erinnerung::alle(true) as $erinnerung) {
|
||
$datum = (string)$erinnerung['faellig_am'];
|
||
if ($datum > $bis) {
|
||
continue;
|
||
}
|
||
$termine[] = [
|
||
'id' => (int)$erinnerung['id'],
|
||
'datum' => $datum,
|
||
'art' => 'erinnerung',
|
||
'titel' => (string)$erinnerung['text'],
|
||
'betrag' => null,
|
||
'pfad' => '/termine/erinnerungen',
|
||
'ueberfaellig' => $datum < $heute,
|
||
];
|
||
}
|
||
|
||
foreach (Vertrag::fristenLaufenAb($tage, $heute) as $vertrag) {
|
||
$termine[] = [
|
||
'id' => (int)$vertrag['id'],
|
||
'datum' => (string)$vertrag['kuendigen_bis'],
|
||
'art' => 'vertrag',
|
||
'titel' => 'Kündigungsfrist: ' . $vertrag['name'],
|
||
'betrag' => (float)$vertrag['betrag'],
|
||
'pfad' => '/vertraege',
|
||
'ueberfaellig' => false,
|
||
];
|
||
}
|
||
|
||
foreach (Zahlungsauftrag::alle('entwurf') as $auftrag) {
|
||
$datum = (string)($auftrag['ausfuehrung_am'] ?? '');
|
||
if ($datum === '' || $datum > $bis) {
|
||
continue;
|
||
}
|
||
$termine[] = [
|
||
'id' => (int)$auftrag['id'],
|
||
'datum' => $datum,
|
||
'art' => 'ueberweisung',
|
||
'titel' => 'Überweisung an ' . $auftrag['empfaenger'],
|
||
'betrag' => -abs((float)$auftrag['betrag']),
|
||
'pfad' => '/zahlungsverkehr',
|
||
'ueberfaellig' => $datum < $heute,
|
||
];
|
||
}
|
||
|
||
usort($termine, static fn(array $a, array $b): int => strcmp($a['datum'], $b['datum']));
|
||
|
||
return $termine;
|
||
}
|
||
|
||
/**
|
||
* Dieselben Termine, nach Monaten gebündelt – so liest sich der Kalender
|
||
* wie ein Kalender und nicht wie eine Liste.
|
||
*
|
||
* @return list<array{monat: string, titel: string, termine: list<array<string,mixed>>}>
|
||
*/
|
||
public static function nachMonat(int $tage = 60, ?string $heute = null): array
|
||
{
|
||
$monate = [];
|
||
|
||
foreach (self::termine($tage, $heute) as $termin) {
|
||
$schluessel = substr($termin['datum'], 0, 7);
|
||
|
||
if (!isset($monate[$schluessel])) {
|
||
$monate[$schluessel] = [
|
||
'monat' => $schluessel,
|
||
'titel' => View::monat((int)substr($schluessel, 5, 2)) . ' ' . substr($schluessel, 0, 4),
|
||
'termine' => [],
|
||
];
|
||
}
|
||
|
||
$monate[$schluessel]['termine'][] = $termin;
|
||
}
|
||
|
||
return array_values($monate);
|
||
}
|
||
}
|