'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 $filter konto_id, wertpapier_id, von, bis, vorgang * @return list> */ 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 $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 $daten * @return array */ 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, }; } }