'Aktie', 'fonds' => 'Fonds', 'anleihe' => 'Anleihe', 'zertifikat' => 'Zertifikat', 'sonstiges' => 'Sonstiges', ]; /** QIF-Feld „&“ → eigene Art. Der FinanzManager zählt schlicht durch. */ public const QIF_ARTEN = [ '1' => 'aktie', '2' => 'fonds', '3' => 'anleihe', '4' => 'zertifikat', ]; /** @return list> */ public static function alle(bool $nurAktive = false): array { $sql = 'SELECT * FROM wertpapiere WHERE benutzer_id = ?'; if ($nurAktive) { $sql .= ' AND aktiv = 1'; } return Database::fetchAll($sql . ' ORDER BY name', [Mandant::id()]); } public static function find(int $id): ?array { $zeile = Database::fetchOne( 'SELECT * FROM wertpapiere WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()] ); return $zeile === false ? null : $zeile; } /** * Sucht über ISIN, sonst über WKN, sonst über den Namen. Die ISIN ist der * verlässliche Schlüssel; sie fehlt aber bei manchen Altbeständen. * * @return array|null */ public static function finde(string $isin, string $wkn = '', string $name = ''): ?array { $isin = strtoupper(trim($isin)); $wkn = strtoupper(trim($wkn)); $name = trim($name); foreach ([ ['isin = ?', $isin], ['wkn = ?', $wkn], ['name = ?', $name], ] as [$bedingung, $wert]) { if ($wert === '') { continue; } $zeile = Database::fetchOne( 'SELECT * FROM wertpapiere WHERE benutzer_id = ? AND ' . $bedingung . ' LIMIT 1', [Mandant::id(), $wert] ); if ($zeile !== false) { return $zeile; } } return null; } /** * ISIN und WKN aus einem Verwendungszweck. * * Wertpapierabrechnungen führen beides im Text mit („WKN LYX01W … ISIN * LU1834983477“). Wer ein Geschäft aus dem Kontoblatt heraus bucht, muss das * Papier dann nicht heraussuchen – die Maske schlägt es vor. * * Die ISIN ist an ihrer Form erkennbar (zwei Buchstaben Länderkennung, neun * Zeichen, eine Prüfziffer) und wird auch ohne Beschriftung gefunden. Die * WKN ist bloß sechsstellig alphanumerisch – zu wenig, um sie ohne das Wort * „WKN“ davor von einer Belegnummer zu unterscheiden. * * @return array{isin:string,wkn:string} */ public static function ausText(string $text): array { $gross = strtoupper($text); $isin = preg_match('/\b([A-Z]{2}[A-Z0-9]{9}[0-9])\b/', $gross, $treffer) === 1 ? $treffer[1] : ''; $wkn = preg_match('/\bWKN[\s:.]*([A-Z0-9]{6})\b/', $gross, $treffer) === 1 ? $treffer[1] : ''; return ['isin' => $isin, 'wkn' => $wkn]; } /** @param array $daten */ public static function findenOderAnlegen(array $daten): int { $vorhanden = self::finde( (string)($daten['isin'] ?? ''), (string)($daten['wkn'] ?? ''), (string)($daten['name'] ?? '') ); if ($vorhanden !== null) { // Was beim ersten Mal fehlte, wird nachgetragen – eine Buchung // führt die ISIN, die nächste vielleicht zusätzlich die WKN. self::ergaenze((int)$vorhanden['id'], $daten); return (int)$vorhanden['id']; } return self::anlegen($daten); } /** @param array $daten */ public static function anlegen(array $daten): int { $felder = self::normalisiere($daten); Database::execute( 'INSERT INTO wertpapiere (benutzer_id, name, wkn, isin, art, waehrung, symbol, aktiv, notiz) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [ Mandant::id(), $felder['name'], $felder['wkn'], $felder['isin'], $felder['art'], $felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'], ] ); return Database::lastInsertId(); } /** @param array $daten */ public static function speichern(int $id, array $daten): void { $felder = self::normalisiere($daten); Database::execute( 'UPDATE wertpapiere SET name = ?, wkn = ?, isin = ?, art = ?, waehrung = ?, symbol = ?, aktiv = ?, notiz = ? WHERE id = ? AND benutzer_id = ?', [ $felder['name'], $felder['wkn'], $felder['isin'], $felder['art'], $felder['waehrung'], $felder['symbol'], $felder['aktiv'], $felder['notiz'], $id, Mandant::id(), ] ); } public static function loeschen(int $id): void { Database::execute('DELETE FROM wertpapiere WHERE id = ? AND benutzer_id = ?', [$id, Mandant::id()]); } public static function anzahl(): int { return (int)Database::fetchValue( 'SELECT COUNT(*) FROM wertpapiere WHERE benutzer_id = ?', [Mandant::id()], 0 ); } public static function artAusQif(string $wert): string { return self::QIF_ARTEN[trim($wert)] ?? 'sonstiges'; } // ── Kurse ──────────────────────────────────────────────────────────────── public static function kursSetzen( int $wertpapierId, string $datum, float $kurs, string $quelle = 'manuell', string $waehrung = 'EUR' ): void { Database::execute( 'INSERT INTO kurse (wertpapier_id, datum, kurs, waehrung, quelle) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE kurs = VALUES(kurs), quelle = VALUES(quelle), waehrung = VALUES(waehrung)', [$wertpapierId, $datum, $kurs, $waehrung, $quelle] ); } /** * Der letzte bekannte Kurs bis zu einem Stichtag. Ohne Stichtag der * aktuellste überhaupt. * * @return array|null */ public static function letzterKurs(int $wertpapierId, ?string $bis = null): ?array { $sql = 'SELECT * FROM kurse WHERE wertpapier_id = ?'; $params = [$wertpapierId]; if ($bis !== null) { $sql .= ' AND datum <= ?'; $params[] = $bis; } $zeile = Database::fetchOne($sql . ' ORDER BY datum DESC LIMIT 1', $params); return $zeile === false ? null : $zeile; } /** * Die letzten Kurse aller Wertpapiere in einer Abfrage – die Depotübersicht * braucht sie für jede Position und soll nicht je Zeile fragen. * * @return array */ public static function letzteKurse(?string $bis = null): array { $params = [Mandant::id()]; $grenze = ''; if ($bis !== null) { $grenze = ' AND k.datum <= ?'; $params[] = $bis; $params[] = $bis; } $zeilen = Database::fetchAll( 'SELECT k.wertpapier_id, k.kurs, k.datum FROM kurse k JOIN wertpapiere w ON w.id = k.wertpapier_id WHERE w.benutzer_id = ?' . $grenze . ' AND k.datum = ( SELECT MAX(k2.datum) FROM kurse k2 WHERE k2.wertpapier_id = k.wertpapier_id' . ($bis === null ? '' : ' AND k2.datum <= ?') . ' )', $params ); $kurse = []; foreach ($zeilen as $zeile) { $kurse[(int)$zeile['wertpapier_id']] = [ 'kurs' => (float)$zeile['kurs'], 'datum' => (string)$zeile['datum'], ]; } return $kurse; } /** * Hoch und Tief eines Zeitraums, dazu die Zahl der bekannten Kurse – die * Detailseite stellt sie neben den letzten Kurs. Ohne einen einzigen Kurs * im Zeitraum gibt es nichts zu zeigen: null. * * @return array{tief: float, hoch: float, anzahl: int}|null */ public static function kurseckwerte(int $wertpapierId, string $von): ?array { $zeile = Database::fetchOne( 'SELECT MIN(kurs) AS tief, MAX(kurs) AS hoch, COUNT(*) AS anzahl FROM kurse WHERE wertpapier_id = ? AND datum >= ?', [$wertpapierId, $von] ); if ($zeile === false || (int)$zeile['anzahl'] === 0) { return null; } return [ 'tief' => (float)$zeile['tief'], 'hoch' => (float)$zeile['hoch'], 'anzahl' => (int)$zeile['anzahl'], ]; } /** @return list> */ public static function kursverlauf(int $wertpapierId, int $tage = 365): array { return Database::fetchAll( 'SELECT * FROM kurse WHERE wertpapier_id = ? AND datum >= DATE_SUB(CURDATE(), INTERVAL ? DAY) ORDER BY datum', [$wertpapierId, $tage] ); } /** * @param array $daten * @return array */ private static function normalisiere(array $daten): array { $name = trim((string)($daten['name'] ?? '')); if ($name === '') { throw new \InvalidArgumentException('Das Wertpapier braucht einen Namen.'); } $art = (string)($daten['art'] ?? 'aktie'); return [ 'name' => mb_substr($name, 0, 255), 'wkn' => mb_substr(strtoupper(trim((string)($daten['wkn'] ?? ''))), 0, 16), 'isin' => mb_substr(strtoupper(str_replace(' ', '', (string)($daten['isin'] ?? ''))), 0, 16), 'art' => array_key_exists($art, self::ARTEN) ? $art : 'sonstiges', 'waehrung' => mb_substr(strtoupper(trim((string)($daten['waehrung'] ?? 'EUR'))) ?: 'EUR', 0, 3), 'symbol' => mb_substr(trim((string)($daten['symbol'] ?? '')), 0, 32), 'aktiv' => array_key_exists('aktiv', $daten) && empty($daten['aktiv']) ? 0 : 1, 'notiz' => mb_substr(trim((string)($daten['notiz'] ?? '')), 0, 255), ]; } /** Trägt fehlende Felder nach, ohne vorhandene zu überschreiben. */ private static function ergaenze(int $id, array $daten): void { $vorhanden = self::find($id); if ($vorhanden === null) { return; } $nachtrag = []; foreach (['wkn', 'isin'] as $feld) { $neu = strtoupper(trim((string)($daten[$feld] ?? ''))); if ($neu !== '' && (string)$vorhanden[$feld] === '') { $nachtrag[$feld] = $neu; } } foreach ($nachtrag as $feld => $wert) { Database::execute( 'UPDATE wertpapiere SET ' . $feld . ' = ? WHERE id = ? AND benutzer_id = ?', [$wert, $id, Mandant::id()] ); } } }