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>
344 lines
12 KiB
PHP
344 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Regeln, die abgerufenen Umsätzen ihre Kategorie geben.
|
||
*
|
||
* Eine Regel prüft ein Muster gegen Empfänger und/oder Verwendungszweck und
|
||
* hängt bei einem Treffer Kategorie, Klasse, Gegenkonto oder ein festes
|
||
* Split-Schema an. Die erste passende Regel gewinnt – sortiert nach Priorität,
|
||
* bei Gleichstand nach Name. Kein Sammeln über mehrere Regeln: Wenn zwei Regeln
|
||
* verschiedene Kategorien setzen wollten, wäre nicht mehr nachvollziehbar,
|
||
* warum am Ende die eine dasteht.
|
||
*/
|
||
class AbgleichRegel
|
||
{
|
||
public const FELDER = [
|
||
'beides' => 'Empfänger und Verwendungszweck',
|
||
'empfaenger' => 'Nur Empfänger',
|
||
'verwendungszweck' => 'Nur Verwendungszweck',
|
||
];
|
||
|
||
public const VERGLEICHE = [
|
||
'enthaelt' => 'enthält',
|
||
'beginnt' => 'beginnt mit',
|
||
'endet' => 'endet auf',
|
||
'regex' => 'regulärer Ausdruck',
|
||
];
|
||
|
||
/** @return list<array<string,mixed>> */
|
||
public static function alle(bool $nurAktive = false): array
|
||
{
|
||
$sql = 'SELECT r.*, k.pfad AS kategorie_pfad, kl.name AS klassenname, g.name AS gegenkontoname
|
||
FROM abgleich_regeln r
|
||
LEFT JOIN kategorien k ON k.id = r.kategorie_id
|
||
LEFT JOIN klassen kl ON kl.id = r.klasse_id
|
||
LEFT JOIN konten g ON g.id = r.gegenkonto_id
|
||
WHERE r.benutzer_id = ?';
|
||
if ($nurAktive) {
|
||
$sql .= ' AND r.aktiv = 1';
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY r.prioritaet, r.name', [Mandant::id()]);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM abgleich_regeln 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 abgleich_regeln
|
||
(benutzer_id, name, feld, vergleich, muster, betrag_von, betrag_bis,
|
||
kategorie_id, klasse_id, gegenkonto_id, splits, prioritaet, aktiv)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[
|
||
Mandant::id(), $f['name'], $f['feld'], $f['vergleich'], $f['muster'],
|
||
$f['betrag_von'], $f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'],
|
||
$f['gegenkonto_id'], $f['splits'], $f['prioritaet'], $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 abgleich_regeln SET name = ?, feld = ?, vergleich = ?, muster = ?,
|
||
betrag_von = ?, betrag_bis = ?, kategorie_id = ?, klasse_id = ?,
|
||
gegenkonto_id = ?, splits = ?, prioritaet = ?, aktiv = ?
|
||
WHERE id = ? AND benutzer_id = ?',
|
||
[
|
||
$f['name'], $f['feld'], $f['vergleich'], $f['muster'], $f['betrag_von'],
|
||
$f['betrag_bis'], $f['kategorie_id'], $f['klasse_id'], $f['gegenkonto_id'],
|
||
$f['splits'], $f['prioritaet'], $f['aktiv'], $id, Mandant::id(),
|
||
]
|
||
);
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM abgleich_regeln WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]);
|
||
}
|
||
|
||
/**
|
||
* Die erste Regel, die auf den Umsatz passt.
|
||
*
|
||
* @param array<string,mixed> $umsatz
|
||
* @param list<array<string,mixed>>|null $regeln vorgeladene Regeln, sonst werden sie geholt
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
public static function passendeRegel(array $umsatz, ?array $regeln = null): ?array
|
||
{
|
||
foreach ($regeln ?? self::alle(true) as $regel) {
|
||
if (self::passt($regel, $umsatz)) {
|
||
return $regel;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $regel
|
||
* @param array<string,mixed> $umsatz
|
||
*/
|
||
public static function passt(array $regel, array $umsatz): bool
|
||
{
|
||
$betrag = round((float)($umsatz['betrag'] ?? 0), 2);
|
||
|
||
// Betragsgrenzen vergleichen den Wert mit Vorzeichen: „von −60 bis −40“
|
||
// trifft Lastschriften zwischen 40 und 60 Euro.
|
||
if ($regel['betrag_von'] !== null && $betrag < (float)$regel['betrag_von']) {
|
||
return false;
|
||
}
|
||
if ($regel['betrag_bis'] !== null && $betrag > (float)$regel['betrag_bis']) {
|
||
return false;
|
||
}
|
||
|
||
$muster = (string)$regel['muster'];
|
||
if ($muster === '') {
|
||
return false;
|
||
}
|
||
|
||
foreach (self::felderZumVergleich($regel, $umsatz) as $text) {
|
||
if (self::trifft((string)$regel['vergleich'], $muster, $text)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Was eine Regel dem Umsatz mitgibt. Splits werden erst hier aus dem
|
||
* Prozentschema in Beträge umgerechnet – dieselbe Regel passt so auf
|
||
* Rechnungen jeder Höhe.
|
||
*
|
||
* @param array<string,mixed> $regel
|
||
* @param array<string,mixed> $umsatz
|
||
* @return array{kategorie_id: ?int, klasse_id: ?int, gegenkonto_id: ?int, splits: list<array<string,mixed>>}
|
||
*/
|
||
public static function anwenden(array $regel, array $umsatz): array
|
||
{
|
||
$betrag = round((float)($umsatz['betrag'] ?? 0), 2);
|
||
$splits = [];
|
||
|
||
$schema = json_decode((string)($regel['splits'] ?? ''), true);
|
||
if (is_array($schema) && $schema !== []) {
|
||
$summe = 0.0;
|
||
foreach ($schema as $nummer => $zeile) {
|
||
$anteil = (float)($zeile['anteil'] ?? 0);
|
||
$teil = round($betrag * $anteil, 2);
|
||
|
||
// Die letzte Zeile bekommt den Rest, damit die Summe der Splits
|
||
// auf den Cent zum Buchungsbetrag passt – bei 1/3 zu 2/3 ginge
|
||
// sonst ein Cent verloren, und die Buchung ließe sich nicht
|
||
// speichern.
|
||
if ($nummer === array_key_last($schema)) {
|
||
$teil = round($betrag - $summe, 2);
|
||
}
|
||
$summe += $teil;
|
||
|
||
$splits[] = [
|
||
'kategorie_id' => ($zeile['kategorie_id'] ?? null) ?: null,
|
||
'klasse_id' => ($zeile['klasse_id'] ?? null) ?: null,
|
||
'betrag' => $teil,
|
||
'notiz' => (string)($zeile['notiz'] ?? ''),
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'kategorie_id' => $regel['kategorie_id'] === null ? null : (int)$regel['kategorie_id'],
|
||
'klasse_id' => $regel['klasse_id'] === null ? null : (int)$regel['klasse_id'],
|
||
'gegenkonto_id' => $regel['gegenkonto_id'] === null ? null : (int)$regel['gegenkonto_id'],
|
||
'splits' => $splits,
|
||
];
|
||
}
|
||
|
||
public static function zaehleTreffer(int $id): void
|
||
{
|
||
Database::execute('UPDATE abgleich_regeln SET treffer = treffer + 1 WHERE id = ?', [$id]);
|
||
}
|
||
|
||
/**
|
||
* Prüft ein Muster, bevor es gespeichert wird. Ein ungültiger regulärer
|
||
* Ausdruck brächte sonst jeden Abruf zum Stehen.
|
||
*/
|
||
public static function musterIstGueltig(string $vergleich, string $muster): bool
|
||
{
|
||
if ($vergleich !== 'regex') {
|
||
return $muster !== '';
|
||
}
|
||
|
||
return @preg_match(self::alsRegex($muster), '') !== false;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $regel
|
||
* @param array<string,mixed> $umsatz
|
||
* @return list<string>
|
||
*/
|
||
private static function felderZumVergleich(array $regel, array $umsatz): array
|
||
{
|
||
$empfaenger = (string)($umsatz['empfaenger'] ?? '');
|
||
$zweck = (string)($umsatz['verwendungszweck'] ?? '');
|
||
|
||
return match ((string)$regel['feld']) {
|
||
'empfaenger' => [$empfaenger],
|
||
'verwendungszweck' => [$zweck],
|
||
default => [$empfaenger, $zweck],
|
||
};
|
||
}
|
||
|
||
private static function trifft(string $vergleich, string $muster, string $text): bool
|
||
{
|
||
if ($text === '') {
|
||
return false;
|
||
}
|
||
|
||
// Groß- und Kleinschreibung spielt keine Rolle: Banken schreiben denselben
|
||
// Empfänger mal in Versalien, mal gemischt.
|
||
$textKlein = mb_strtolower($text);
|
||
$musterKlein = mb_strtolower($muster);
|
||
|
||
return match ($vergleich) {
|
||
'beginnt' => str_starts_with($textKlein, $musterKlein),
|
||
'endet' => str_ends_with($textKlein, $musterKlein),
|
||
'regex' => @preg_match(self::alsRegex($muster), $text) === 1,
|
||
default => str_contains($textKlein, $musterKlein),
|
||
};
|
||
}
|
||
|
||
/** Das Muster wird als Ausdruck ohne Trenner erwartet – die setzt erst diese Methode. */
|
||
private static function alsRegex(string $muster): string
|
||
{
|
||
return '/' . str_replace('/', '\/', $muster) . '/iu';
|
||
}
|
||
|
||
/**
|
||
* @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('Die Regel braucht einen Namen.');
|
||
}
|
||
|
||
$feld = (string)($daten['feld'] ?? 'beides');
|
||
$vergleich = (string)($daten['vergleich'] ?? 'enthaelt');
|
||
$muster = trim((string)($daten['muster'] ?? ''));
|
||
|
||
if ($muster === '') {
|
||
throw new \InvalidArgumentException('Die Regel braucht ein Muster.');
|
||
}
|
||
if (!self::musterIstGueltig($vergleich, $muster)) {
|
||
throw new \InvalidArgumentException('Das Muster ist kein gültiger regulärer Ausdruck.');
|
||
}
|
||
|
||
$grenze = static function (mixed $wert): ?float {
|
||
return ($wert === null || $wert === '') ? null : round((float)$wert, 2);
|
||
};
|
||
|
||
$id = static function (mixed $wert): ?int {
|
||
return ($wert === null || $wert === '' || (int)$wert === 0) ? null : (int)$wert;
|
||
};
|
||
|
||
$splits = $daten['splits'] ?? null;
|
||
if (is_array($splits)) {
|
||
$splits = self::splitschema($splits);
|
||
} elseif (is_string($splits) && trim($splits) !== '') {
|
||
$splits = self::splitschema((array)json_decode($splits, true));
|
||
} else {
|
||
$splits = null;
|
||
}
|
||
|
||
return [
|
||
'name' => mb_substr($name, 0, 128),
|
||
'feld' => array_key_exists($feld, self::FELDER) ? $feld : 'beides',
|
||
'vergleich' => array_key_exists($vergleich, self::VERGLEICHE) ? $vergleich : 'enthaelt',
|
||
'muster' => mb_substr($muster, 0, 255),
|
||
'betrag_von' => $grenze($daten['betrag_von'] ?? null),
|
||
'betrag_bis' => $grenze($daten['betrag_bis'] ?? null),
|
||
'kategorie_id' => $id($daten['kategorie_id'] ?? null),
|
||
'klasse_id' => $id($daten['klasse_id'] ?? null),
|
||
'gegenkonto_id' => $id($daten['gegenkonto_id'] ?? null),
|
||
'splits' => $splits,
|
||
'prioritaet' => (int)($daten['prioritaet'] ?? 100),
|
||
'aktiv' => !empty($daten['aktiv']) ? 1 : 0,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Bringt ein Split-Schema in Form: je Zeile eine Kategorie und ein Anteil
|
||
* zwischen 0 und 1. Zeilen ohne Kategorie oder ohne Anteil fallen weg.
|
||
*
|
||
* @param array<int|string, mixed> $roh
|
||
*/
|
||
private static function splitschema(array $roh): ?string
|
||
{
|
||
$zeilen = [];
|
||
foreach ($roh as $zeile) {
|
||
if (!is_array($zeile)) {
|
||
continue;
|
||
}
|
||
$kategorie = (int)($zeile['kategorie_id'] ?? 0);
|
||
$anteil = (float)($zeile['anteil'] ?? 0);
|
||
|
||
if ($kategorie === 0 || $anteil <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
$zeilen[] = [
|
||
'kategorie_id' => $kategorie,
|
||
'klasse_id' => (int)($zeile['klasse_id'] ?? 0) ?: null,
|
||
'anteil' => round(min($anteil, 1.0), 4),
|
||
'notiz' => mb_substr(trim((string)($zeile['notiz'] ?? '')), 0, 255),
|
||
];
|
||
}
|
||
|
||
return $zeilen === [] ? null : json_encode($zeilen, JSON_UNESCAPED_UNICODE);
|
||
}
|
||
}
|