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>
328 lines
12 KiB
PHP
328 lines
12 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Models;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
|
||
/**
|
||
* Eine Depotbuchung: Kauf, Verkauf, Dividende, Ein- oder Auslieferung.
|
||
*
|
||
* **Zwei Seiten.** Die Wertpapierseite steht hier (Stück, Kurs, Gebühren), die
|
||
* Geldseite als gewöhnliche Buchung auf dem Verrechnungskonto – verbunden über
|
||
* `buchung_id`. So bleibt das Girokonto stimmig, ohne dass die Kontoblätter
|
||
* Stückzahlen führen müssten.
|
||
*
|
||
* **Vorzeichen.** `stueck` trägt die Richtung: Kauf und Einlieferung positiv,
|
||
* Verkauf und Auslieferung negativ. `betrag` ist immer der Bruttowert des
|
||
* Geschäfts ohne Vorzeichen – ob er zu- oder abfließt, sagt der Vorgang.
|
||
*
|
||
* **Was in `betrag` steht.** Der Kurswert (Stück × Kurs) beziehungsweise bei
|
||
* Dividenden der Bruttoertrag – Gebühren und Steuern stehen *daneben*, nicht
|
||
* darin. Daraus folgt der Geldfluss auf dem Verrechnungskonto:
|
||
*
|
||
* Kauf → −(Betrag + Gebühren + Steuern)
|
||
* Verkauf → +(Betrag − Gebühren − Steuern)
|
||
* Dividende → +(Betrag − Steuern)
|
||
*
|
||
* Am Export nachgerechnet: Ein Verkauf über 4 × 797,00 € mit 15,37 € Gebühren
|
||
* und 69,46 € Steuern ergibt die Gutschrift von 3.103,17 €, die im QIF im Feld
|
||
* `$` steht. Genauso beim Kauf. Diese Trennung ist nötig, damit der Einstand
|
||
* die Nebenkosten genau einmal enthält.
|
||
*/
|
||
class Depotbuchung
|
||
{
|
||
/** Vorgang → Beschriftung. */
|
||
public const VORGAENGE = [
|
||
'kauf' => 'Kauf',
|
||
'verkauf' => 'Verkauf',
|
||
'dividende' => 'Dividende',
|
||
'einlage' => 'Einlieferung',
|
||
'entnahme' => 'Auslieferung',
|
||
];
|
||
|
||
/**
|
||
* Vorgang des FinanzManagers → eigener Vorgang.
|
||
*
|
||
* `DivIn` und `DivAus` landen beide auf „dividende“: Beide führen keine
|
||
* Stückzahl und schreiben dem Verrechnungskonto einen Ertrag gut. Die
|
||
* ursprüngliche Bezeichnung bleibt in `vorgang_qif` erhalten.
|
||
*/
|
||
public const QIF_VORGAENGE = [
|
||
'Kauf' => 'kauf',
|
||
'Verkauf' => 'verkauf',
|
||
'DivIn' => 'dividende',
|
||
'DivAus' => 'dividende',
|
||
'ReinvDiv' => 'dividende',
|
||
'WP-Einlage' => 'einlage',
|
||
'WP-Entnahme' => 'entnahme',
|
||
];
|
||
|
||
/** Vorgänge, die den Bestand vergrößern beziehungsweise verkleinern. */
|
||
public const ZUGAENGE = ['kauf', 'einlage'];
|
||
public const ABGAENGE = ['verkauf', 'entnahme'];
|
||
|
||
public static function vorgangAusQif(string $vorgang): string
|
||
{
|
||
return self::QIF_VORGAENGE[trim($vorgang)] ?? 'kauf';
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM depot_buchungen WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $filter konto_id, wertpapier_id, von, bis, vorgang
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function liste(array $filter = []): array
|
||
{
|
||
$params = [Mandant::id()];
|
||
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot,
|
||
v.name AS verrechnungskonto
|
||
FROM depot_buchungen d
|
||
JOIN wertpapiere w ON w.id = d.wertpapier_id
|
||
JOIN konten k ON k.id = d.konto_id
|
||
LEFT JOIN konten v ON v.id = d.verrechnungskonto_id
|
||
WHERE d.benutzer_id = ?';
|
||
|
||
foreach ([
|
||
'konto_id' => 'd.konto_id = ?',
|
||
'wertpapier_id' => 'd.wertpapier_id = ?',
|
||
'vorgang' => 'd.vorgang = ?',
|
||
] as $feld => $bedingung) {
|
||
if (!empty($filter[$feld])) {
|
||
$sql .= ' AND ' . $bedingung;
|
||
$params[] = $filter[$feld];
|
||
}
|
||
}
|
||
|
||
if (!empty($filter['von'])) {
|
||
$sql .= ' AND d.datum >= ?';
|
||
$params[] = $filter['von'];
|
||
}
|
||
if (!empty($filter['bis'])) {
|
||
$sql .= ' AND d.datum <= ?';
|
||
$params[] = $filter['bis'];
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params);
|
||
}
|
||
|
||
/**
|
||
* Legt eine Depotbuchung an und – wenn ein Verrechnungskonto angegeben ist –
|
||
* die zugehörige Geldbuchung gleich mit.
|
||
*
|
||
* @param array<string,mixed> $daten
|
||
*/
|
||
public static function anlegen(array $daten): int
|
||
{
|
||
$f = self::normalisiere($daten);
|
||
|
||
return (int)Database::transaktion(static function () use ($f, $daten): int {
|
||
$buchungId = null;
|
||
|
||
if ($f['verrechnungskonto_id'] !== null && abs($f['geldbetrag']) > 0.005) {
|
||
$buchungId = Buchung::anlegen([
|
||
'konto_id' => $f['verrechnungskonto_id'],
|
||
'datum' => $f['datum'],
|
||
'valuta' => $f['valuta'],
|
||
'betrag' => $f['geldbetrag'],
|
||
'empfaenger' => mb_substr((string)($daten['wertpapiername'] ?? 'Wertpapiergeschäft'), 0, 255),
|
||
'buchungstext' => (string)$f['notiz'],
|
||
'kategorie_id' => ($daten['kategorie_id'] ?? null) ?: null,
|
||
'quelle' => (string)$f['quelle'],
|
||
'abgeglichen' => !empty($daten['abgeglichen']) ? 1 : 0,
|
||
// Eigener Hash für die Geldseite: Er erkennt Buchungen
|
||
// wieder, die ein früherer Import ohne Depotmodul angelegt
|
||
// hat, und verhindert Dubletten im Kontoblatt.
|
||
'import_hash' => ($daten['buchung_hash'] ?? null) ?: null,
|
||
]);
|
||
}
|
||
|
||
Database::execute(
|
||
'INSERT INTO depot_buchungen
|
||
(benutzer_id, konto_id, wertpapier_id, datum, valuta, vorgang, vorgang_qif,
|
||
stueck, kurs, betrag, gebuehren, steuern, waehrung,
|
||
verrechnungskonto_id, buchung_id, notiz, quelle, import_hash)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
[
|
||
Mandant::id(), $f['konto_id'], $f['wertpapier_id'], $f['datum'], $f['valuta'],
|
||
$f['vorgang'], $f['vorgang_qif'], $f['stueck'], $f['kurs'], $f['betrag'],
|
||
$f['gebuehren'], $f['steuern'], $f['waehrung'], $f['verrechnungskonto_id'],
|
||
$buchungId, $f['notiz'], $f['quelle'], $f['import_hash'],
|
||
]
|
||
);
|
||
|
||
return Database::lastInsertId();
|
||
});
|
||
}
|
||
|
||
public static function loeschen(int $id): void
|
||
{
|
||
$buchung = self::find($id);
|
||
|
||
Database::transaktion(static function () use ($id, $buchung): void {
|
||
Database::execute(
|
||
'DELETE FROM depot_buchungen WHERE id = ? AND benutzer_id = ?',
|
||
[$id, Mandant::id()]
|
||
);
|
||
|
||
// Die Geldbuchung gehört zum Geschäft und geht mit – sie stünde
|
||
// sonst ohne Zusammenhang im Kontoblatt.
|
||
if ($buchung !== null && $buchung['buchung_id'] !== null) {
|
||
Buchung::loeschen((int)$buchung['buchung_id']);
|
||
}
|
||
});
|
||
}
|
||
|
||
public static function findeNachHash(string $hash): ?array
|
||
{
|
||
$zeile = Database::fetchOne(
|
||
'SELECT * FROM depot_buchungen WHERE import_hash = ? AND benutzer_id = ?',
|
||
[$hash, Mandant::id()]
|
||
);
|
||
return $zeile === false ? null : $zeile;
|
||
}
|
||
|
||
public static function hash(
|
||
int $kontoId,
|
||
string $datum,
|
||
string $vorgang,
|
||
float $stueck,
|
||
float $betrag,
|
||
string $isin
|
||
): string {
|
||
return sha1(implode('|', [
|
||
$kontoId,
|
||
$datum,
|
||
$vorgang,
|
||
number_format($stueck, 6, '.', ''),
|
||
number_format($betrag, 2, '.', ''),
|
||
strtoupper($isin),
|
||
]));
|
||
}
|
||
|
||
public static function anzahl(): int
|
||
{
|
||
return (int)Database::fetchValue(
|
||
'SELECT COUNT(*) FROM depot_buchungen WHERE benutzer_id = ?',
|
||
[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('Die Depotbuchung braucht ein gültiges Depot.');
|
||
}
|
||
|
||
$wertpapierId = (int)($daten['wertpapier_id'] ?? 0);
|
||
if ($wertpapierId === 0) {
|
||
throw new \InvalidArgumentException('Die Depotbuchung braucht ein Wertpapier.');
|
||
}
|
||
|
||
$datum = trim((string)($daten['datum'] ?? ''));
|
||
if (!Buchung::istDatum($datum)) {
|
||
throw new \InvalidArgumentException('Die Depotbuchung braucht ein gültiges Datum (JJJJ-MM-TT).');
|
||
}
|
||
|
||
$vorgang = (string)($daten['vorgang'] ?? 'kauf');
|
||
if (!array_key_exists($vorgang, self::VORGAENGE)) {
|
||
throw new \InvalidArgumentException('Unbekannter Vorgang: ' . $vorgang);
|
||
}
|
||
|
||
$stueck = round(abs((float)($daten['stueck'] ?? 0)), 6);
|
||
$betrag = round(abs((float)($daten['betrag'] ?? 0)), 2);
|
||
$gebuehren = round(abs((float)($daten['gebuehren'] ?? 0)), 2);
|
||
$steuern = round(abs((float)($daten['steuern'] ?? 0)), 2);
|
||
|
||
// Abgänge zählen negativ, damit sich der Bestand einfach aufsummieren
|
||
// lässt. Eine Dividende bewegt keine Stücke.
|
||
$stueck = match (true) {
|
||
in_array($vorgang, self::ABGAENGE, true) => -$stueck,
|
||
$vorgang === 'dividende' => 0.0,
|
||
default => $stueck,
|
||
};
|
||
|
||
$valuta = trim((string)($daten['valuta'] ?? ''));
|
||
|
||
$verrechnung = $daten['verrechnungskonto_id'] ?? null;
|
||
$verrechnung = ($verrechnung === null || $verrechnung === '' || (int)$verrechnung === 0)
|
||
? null : (int)$verrechnung;
|
||
|
||
return [
|
||
'konto_id' => $kontoId,
|
||
'wertpapier_id' => $wertpapierId,
|
||
'datum' => $datum,
|
||
'valuta' => Buchung::istDatum($valuta) ? $valuta : null,
|
||
'vorgang' => $vorgang,
|
||
'vorgang_qif' => mb_substr(trim((string)($daten['vorgang_qif'] ?? '')), 0, 24),
|
||
'stueck' => $stueck,
|
||
'kurs' => ($daten['kurs'] ?? null) === null || $daten['kurs'] === ''
|
||
? null : round((float)$daten['kurs'], 6),
|
||
'betrag' => $betrag,
|
||
'gebuehren' => $gebuehren,
|
||
'steuern' => $steuern,
|
||
'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3),
|
||
'verrechnungskonto_id' => $verrechnung,
|
||
'notiz' => trim((string)($daten['notiz'] ?? '')),
|
||
'quelle' => isset(Buchung::QUELLEN[(string)($daten['quelle'] ?? '')])
|
||
? (string)$daten['quelle'] : 'manuell',
|
||
'import_hash' => ($daten['import_hash'] ?? null) ?: null,
|
||
// Was auf dem Verrechnungskonto passiert: Ein Kauf nimmt Geld weg,
|
||
// Verkauf und Dividende bringen welches. Ein- und Auslieferungen
|
||
// rühren kein Konto an.
|
||
'geldbetrag' => self::geldbetrag(
|
||
$vorgang,
|
||
$betrag,
|
||
$gebuehren,
|
||
$steuern,
|
||
(float)($daten['geldbetrag'] ?? 0)
|
||
),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Der Betrag auf dem Verrechnungskonto.
|
||
*
|
||
* Steht im QIF ein eigener Wert (Feld `$`), gilt der: Er ist das, was die
|
||
* Bank tatsächlich gebucht hat, und kann in Sonderfällen von der Rechnung
|
||
* abweichen – etwa wenn eine Teilsteuer separat erstattet wurde. Sonst wird
|
||
* er aus Bruttowert und Nebenkosten gebildet.
|
||
*/
|
||
private static function geldbetrag(
|
||
string $vorgang,
|
||
float $betrag,
|
||
float $gebuehren,
|
||
float $steuern,
|
||
float $vorgabe
|
||
): float {
|
||
if (!in_array($vorgang, ['kauf', 'verkauf', 'dividende'], true)) {
|
||
return 0.0;
|
||
}
|
||
|
||
if (abs($vorgabe) > 0.005) {
|
||
return $vorgang === 'kauf' ? -abs($vorgabe) : abs($vorgabe);
|
||
}
|
||
|
||
return match ($vorgang) {
|
||
'kauf' => -($betrag + $gebuehren + $steuern),
|
||
'verkauf' => $betrag - $gebuehren - $steuern,
|
||
default => $betrag - $gebuehren - $steuern,
|
||
};
|
||
}
|
||
}
|