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>
158 lines
5.3 KiB
PHP
158 lines
5.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Sparziele: ein Betrag, ein Termin, ein Konto.
|
||
*
|
||
* Der erreichte Stand wird **nicht gespeichert**, sondern aus dem Kontosaldo
|
||
* gerechnet – ein zweiter, gepflegter Wert liefe früher oder später von den
|
||
* Buchungen weg. Der Startbetrag zieht ab, was zu Beginn schon dalag.
|
||
*/
|
||
class Sparziel
|
||
{
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(): array
|
||
{
|
||
$ziele = Database::fetchAll(
|
||
'SELECT s.*, k.name AS kontoname
|
||
FROM sparziele s
|
||
LEFT JOIN konten k ON k.id = s.konto_id
|
||
WHERE s.benutzer_id = ?
|
||
ORDER BY s.erreicht, s.zieldatum IS NULL, s.zieldatum, s.name',
|
||
[Mandant::id()]
|
||
);
|
||
|
||
foreach ($ziele as $nummer => $ziel) {
|
||
$ziele[$nummer] = self::mitStand($ziel);
|
||
}
|
||
|
||
return $ziele;
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM sparziele WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
|
||
return $zeile === false ? null : self::mitStand($zeile);
|
||
}
|
||
|
||
/**
|
||
* Ergänzt Stand, Fortschritt und die Rate, die bis zum Ziel nötig wäre.
|
||
*
|
||
* @param array<string,mixed> $ziel
|
||
* @return array<string,mixed>
|
||
*/
|
||
public static function mitStand(array $ziel, ?string $heute = null): array
|
||
{
|
||
$heute = $heute ?? date('Y-m-d');
|
||
$ziel['stand'] = $ziel['konto_id'] === null
|
||
? 0.0
|
||
: round(Konto::saldo((int)$ziel['konto_id'], $heute) - (float)$ziel['startbetrag'], 2);
|
||
|
||
$zielbetrag = (float)$ziel['zielbetrag'];
|
||
$ziel['fehlt'] = round(max(0.0, $zielbetrag - $ziel['stand']), 2);
|
||
$ziel['fortschritt'] = $zielbetrag > 0.0
|
||
? max(0.0, min(1.0, $ziel['stand'] / $zielbetrag))
|
||
: 0.0;
|
||
|
||
// Wie viel monatlich noch nötig wäre, um den Termin zu halten.
|
||
$ziel['noetige_rate'] = null;
|
||
$zieldatum = (string)($ziel['zieldatum'] ?? '');
|
||
if ($zieldatum !== '' && $zieldatum > $heute && $ziel['fehlt'] > 0.0) {
|
||
$monate = self::monateBis($heute, $zieldatum);
|
||
$ziel['noetige_rate'] = $monate > 0 ? round($ziel['fehlt'] / $monate, 2) : $ziel['fehlt'];
|
||
}
|
||
|
||
return $ziel;
|
||
}
|
||
|
||
/** @param array<string,mixed> $daten */
|
||
public static function anlegen(array $daten): int
|
||
{
|
||
$f = self::normalisiere($daten);
|
||
|
||
Database::execute(
|
||
'INSERT INTO sparziele
|
||
(benutzer_id, name, konto_id, zielbetrag, startbetrag, zieldatum, monatsrate, notiz)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[
|
||
Mandant::id(), $f['name'], $f['konto_id'], $f['zielbetrag'], $f['startbetrag'],
|
||
$f['zieldatum'], $f['monatsrate'], $f['notiz'],
|
||
]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
}
|
||
|
||
/** @param array<string,mixed> $daten */
|
||
public static function speichern(int $id, array $daten): void
|
||
{
|
||
$f = self::normalisiere($daten);
|
||
|
||
Database::execute(
|
||
'UPDATE sparziele SET name = ?, konto_id = ?, zielbetrag = ?, startbetrag = ?,
|
||
zieldatum = ?, monatsrate = ?, notiz = ?, erreicht = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[
|
||
$f['name'], $f['konto_id'], $f['zielbetrag'], $f['startbetrag'], $f['zieldatum'],
|
||
$f['monatsrate'], $f['notiz'], $f['erreicht'], $id, Mandant::id(),
|
||
]
|
||
);
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM sparziele WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
}
|
||
|
||
/** Volle Monate zwischen zwei Daten, mindestens einer. */
|
||
private static function monateBis(string $von, string $bis): int
|
||
{
|
||
$a = new \DateTimeImmutable(substr($von, 0, 7) . '-01');
|
||
$b = new \DateTimeImmutable(substr($bis, 0, 7) . '-01');
|
||
|
||
$differenz = $a->diff($b);
|
||
|
||
return max(1, $differenz->y * 12 + $differenz->m);
|
||
}
|
||
|
||
/**
|
||
* @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('Das Sparziel braucht einen Namen.');
|
||
}
|
||
|
||
$zielbetrag = round((float)($daten['zielbetrag'] ?? 0), 2);
|
||
if ($zielbetrag <= 0.0) {
|
||
throw new \InvalidArgumentException('Der Zielbetrag muss größer als null sein.');
|
||
}
|
||
|
||
$zieldatum = trim((string)($daten['zieldatum'] ?? ''));
|
||
$rate = $daten['monatsrate'] ?? null;
|
||
|
||
return [
|
||
'name' => mb_substr($name, 0, 255),
|
||
'konto_id' => ($daten['konto_id'] ?? null) ?: null,
|
||
'zielbetrag' => $zielbetrag,
|
||
'startbetrag' => round((float)($daten['startbetrag'] ?? 0), 2),
|
||
'zieldatum' => Buchung::istDatum($zieldatum) ? $zieldatum : null,
|
||
'monatsrate' => ($rate === null || $rate === '') ? null : round((float)$rate, 2),
|
||
'notiz' => trim((string)($daten['notiz'] ?? '')),
|
||
'erreicht' => !empty($daten['erreicht']) ? 1 : 0,
|
||
];
|
||
}
|
||
}
|