Finanzmanager/src/Views/einstellungen/index.php
fds2610-bot eb08cd6b83 Kursabruf: Yahoo Finanzen als dritter Anbieter, FMP-Adressen berichtigt
Financial Modeling Prep hat die /api/v3/-Adressen zum 31.08.2025 abgeschaltet;
sie antworten mit HTTP 403. Der Abruf nutzt jetzt /stable/quote und
/stable/search-isin und meldet den Klartext des Dienstes, statt jeden 403 als
abgelehnten Schlüssel auszugeben. HTTP 402 ist ein eigener Fall – im
Gratistarif fehlen dort ISIN-Suche und deutsche Börsenplätze.

Neu ist Yahoo Finanzen: ohne Schlüssel, ohne Kontingent, dafür muss das Kürzel
samt Handelsplatz in den Stammdaten stehen. Eine offene Schnittstelle gibt es
nicht mehr (query1.finance.yahoo.com antwortet ohne Sitzung mit HTTP 429, auch
mit Cookie und Crumb), gelesen wird deshalb die Kursseite selbst.

yahooKurs() liest beide Ausprägungen der Seite – <fin-streamer> und den
angezeigten Kurs in deutscher Schreibweise – und prüft am Seitentitel, dass die
Seite zum Kürzel gehört. Das eingebettete JSON bleibt ungenutzt: Es gehört zu
einem Fremd-Widget und liefert auf jeder Seite denselben papierfremden Wert.
Eine Sekunde Abstand zwischen zwei Seiten, bis zu drei Versuche bei HTTP 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:08:27 +02:00

274 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use Finanzmanager\Models\Einstellung;
use Finanzmanager\Services\Cockpit;
use Finanzmanager\Services\Kursabruf;
use Finanzmanager\Services\Wertpapierkosten;
use Finanzmanager\View;
/** @var string $bereich */
/** @var array<string,string|null> $werte */
/** @var list<array{name:string,pfad:string,groesse:int,geaendert:int}> $logDateien */
/** @var list<string> $kacheln */
/** @var list<array<string,mixed>> $kategorien */
/** @var string $csrfToken */
$wert = static fn(string $k, string $standard = ''): string => (string)($werte[$k] ?? $standard);
include __DIR__ . '/../layout/kopf.php';
?>
<div class="tableiste">
<?php foreach (Einstellung::BEREICHE as $schluessel => $beschriftung): ?>
<a href="/einstellungen?bereich=<?= View::h($schluessel) ?>"
class="tab <?= $bereich === $schluessel ? 'aktiv' : '' ?>"><?= View::h($beschriftung) ?></a>
<?php endforeach; ?>
</div>
<form method="post" action="/einstellungen" class="formular breit">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="hidden" name="bereich" value="<?= View::h($bereich) ?>">
<?php if ($bereich === 'allgemein'): ?>
<label for="mandant_name">Bezeichnung</label>
<input type="text" id="mandant_name" name="mandant_name" value="<?= View::h($wert('mandant_name', 'Privat')) ?>">
<p class="feldhinweis">Erscheint im Kopf von Berichten und Ausdrucken.</p>
<label for="waehrung">Währung</label>
<input type="text" id="waehrung" name="waehrung" value="<?= View::h($wert('waehrung', 'EUR')) ?>" size="6">
<?php elseif ($bereich === 'darstellung'): ?>
<label for="darstellung_thema">Farbschema</label>
<select id="darstellung_thema" name="darstellung_thema">
<?php foreach (['system' => 'Wie das Betriebssystem', 'hell' => 'Hell', 'dunkel' => 'Dunkel'] as $k => $v): ?>
<option value="<?= View::h($k) ?>" <?= $wert('darstellung_thema', 'system') === $k ? 'selected' : '' ?>>
<?= View::h($v) ?>
</option>
<?php endforeach; ?>
</select>
<label for="zeilen_pro_seite">Zeilen je Seite</label>
<input type="number" id="zeilen_pro_seite" name="zeilen_pro_seite" min="20" max="1000" step="10"
value="<?= View::h($wert('zeilen_pro_seite', '100')) ?>">
<p class="feldhinweis">Gilt für Kontoblatt und Berichtslisten.</p>
<h3>Kacheln des Cockpits</h3>
<p class="feldhinweis">
Was abgewählt ist, wird auch nicht berechnet – das Cockpit lädt dann schneller.
Die Reihenfolge im Raster ist die dieser Liste.
</p>
<?php foreach (Cockpit::KACHELN as $schluessel => $kachel): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="<?= View::h(Cockpit::EINSTELLUNG) ?>[]"
value="<?= View::h($schluessel) ?>"
<?= in_array($schluessel, $kacheln, true) ? 'checked' : '' ?>>
<?= View::h($kachel['name']) ?>
<span class="feldhinweis inline"><?= View::h($kachel['hinweis']) ?></span>
</label>
<?php endforeach; ?>
<?php elseif ($bereich === 'buchhaltung'): ?>
<label for="erster_monat">Erster Monat des Haushaltsjahres</label>
<select id="erster_monat" name="erster_monat">
<?php foreach (range(1, 12) as $m): ?>
<option value="<?= $m ?>" <?= (int)$wert('erster_monat', '1') === $m ? 'selected' : '' ?>>
<?= View::h(View::monat($m)) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">Bestimmt, wo Budgetplaner und Jahresauswertungen beginnen.</p>
<?php elseif ($bereich === 'import'): ?>
<label for="import_qif_verzeichnis">Importverzeichnis</label>
<input type="text" id="import_qif_verzeichnis" name="import_qif_verzeichnis"
value="<?= View::h($wert('import_qif_verzeichnis', 'data/import')) ?>">
<p class="feldhinweis">
Relativ zum Projektverzeichnis. Hochgeladene QIF-Dateien landen hier und bleiben
liegen, damit derselbe Export erst im Trockenlauf geprüft und dann übernommen
werden kann.
</p>
<label for="import_stichtag">Stichtag für Buchungen</label>
<input type="date" id="import_stichtag" name="import_stichtag"
value="<?= View::h($wert('import_stichtag', '2026-01-01')) ?>">
<p class="feldhinweis">
Buchungen ab diesem Tag werden übernommen. Alles davor fließt je Konto in den
Anfangssaldo – die Salden stimmen also, ohne dass die ganze Historie mitkommt.
Buchungen mit einem Datum nach dem Importtag sind Vorausberechnungen des
FinanzManagers und bleiben draußen.
</p>
<div class="knopfleiste">
<a href="/verwaltung/import" class="knopf">QIF-Import öffnen</a>
</div>
<?php elseif ($bereich === 'banking'): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="banking_automatisch_buchen" value="1"
<?= $wert('banking_automatisch_buchen', '1') === '1' ? 'checked' : '' ?>>
Abgerufene Umsätze selbst buchen
</label>
<p class="feldhinweis">
Nach jedem Abruf landen die Umsätze im zugeordneten Konto: verbunden mit einer
schon erfassten Buchung, wenn eine passt, sonst als neue Buchung – mit der
Kategorie aus der Regel, sonst ohne. Konten, in denen noch Buchungen ohne
Kategorie liegen, stehen in der Kontenübersicht fett. Abgeschaltet wandert
jeder Umsatz stattdessen in die Vorschlagsliste und wartet dort.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="banking_regeln_beim_abruf" value="1"
<?= $wert('banking_regeln_beim_abruf', '1') === '1' ? 'checked' : '' ?>>
Regeln schon beim Abruf anwenden
</label>
<p class="feldhinweis">
Die Vorschlagsliste zeigt dann gleich die Kategorie aus der passenden Regel.
Gebucht wird trotzdem erst, wenn der Umsatz übernommen wird.
</p>
<label for="banking_abgleich_tage">Toleranz beim Abgleich (Tage)</label>
<input type="number" id="banking_abgleich_tage" name="banking_abgleich_tage"
min="0" max="30" value="<?= View::h($wert('banking_abgleich_tage', '5')) ?>">
<p class="feldhinweis">
Wie weit Buchungsdatum und Banktag auseinanderliegen dürfen, damit ein Umsatz
einer schon erfassten Buchung zugeordnet wird. Bei 0 muss der Tag genau stimmen.
</p>
<label for="banking_produkt_id">Produktkennung <span class="optional">(optional)</span></label>
<input type="text" id="banking_produkt_id" name="banking_produkt_id"
value="<?= View::h($wert('banking_produkt_id')) ?>">
<p class="feldhinweis">
Kennung, mit der sich die Anwendung bei der Bank meldet. Leer lassen genügt in
aller Regel; eine eigene Kennung vergibt die Deutsche Kreditwirtschaft auf Antrag.
</p>
<div class="knopfleiste">
<a href="/banking" class="knopf">Bankverbindungen verwalten</a>
</div>
<?php elseif ($bereich === 'wertpapiere'): ?>
<label for="wertpapiere_kursdienst">Kursdienst</label>
<select id="wertpapiere_kursdienst" name="wertpapiere_kursdienst">
<?php foreach (Kursabruf::ANBIETER as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $wert('wertpapiere_kursdienst', 'leeway') === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Leeway kennt ISINs und hat ein kostenloses Tageskontingent, das für rund zwanzig
Positionen reicht. Financial Modeling Prep führt ISIN-Suche und deutsche
Börsenplätze nur im Bezahltarif. Yahoo Finanzen braucht keinen Schlüssel, dafür
muss bei jedem Wertpapier das Kürzel samt Handelsplatz stehen (etwa
<code>ALV.DE</code> für Xetra).
</p>
<label for="wertpapiere_api_key">API-Schlüssel</label>
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
<p class="feldhinweis">
Nach kostenloser Anmeldung beim gewählten Dienst. Bei Yahoo Finanzen bleibt das Feld
leer; ohne Schlüssel lassen sich Kurse sonst nur von Hand eintragen.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="wertpapiere_kursabruf_aktiv" value="1"
<?= $wert('wertpapiere_kursabruf_aktiv') === '1' ? 'checked' : '' ?>>
Kurse täglich automatisch abrufen
</label>
<p class="feldhinweis">
Der Lauf gehört in einen Cron-Eintrag:
<code>docker compose exec -u www-data php php scripts/kursabruf.php</code>.
Ohne diesen Haken bricht das Skript ab – der Knopf auf der Wertpapierseite
funktioniert weiterhin.
</p>
<h3>Kategorien für die Kosten eines Wertpapiergeschäfts</h3>
<p class="feldhinweis">
Wer eine Wertpapierabrechnung aus dem Kontoblatt heraus bucht, gibt im
Kostendialog nur noch Beträge ein – in welche Kategorie eine Gebühr oder
Steuer gehört, steht hier. Jede belegte Art wird dann zu einer Splitzeile
auf dem Verrechnungskonto. Ohne Kategorie bleibt die Zeile unzugeordnet.
</p>
<?php foreach (Wertpapierkosten::arten() as $art => $name): ?>
<?php $feld = Wertpapierkosten::schluessel($art); ?>
<label for="<?= View::h($feld) ?>"><?= View::h($name) ?></label>
<select id="<?= View::h($feld) ?>" name="<?= View::h($feld) ?>">
<option value="">— keine —</option>
<?php foreach ($kategorien as $kategorie): ?>
<option value="<?= (int)$kategorie['id'] ?>"
<?= $wert($feld) === (string)$kategorie['id'] ? 'selected' : '' ?>>
<?= View::h($kategorie['pfad']) ?>
</option>
<?php endforeach; ?>
</select>
<?php endforeach; ?>
<p class="feldhinweis">
Für <em>Sonstige Kosten</em> ist das nur die Vorgabe – sie lässt sich im
Kostendialog überschreiben.
</p>
<div class="knopfleiste">
<a href="/wertpapiere/stammdaten" class="knopf">Wertpapiere verwalten</a>
</div>
<?php elseif ($bereich === 'diagnose'): ?>
<label class="ankreuzfeld">
<input type="checkbox" name="debug_log_aktiv" value="1"
<?= $wert('debug_log_aktiv') === '1' ? 'checked' : '' ?>>
Debug-Log einschalten
</label>
<p class="feldhinweis">
Protokolliert Requests, SQL-Abfragen mit Laufzeit und – ab Phase 5 – die
FinTS-Dialogschritte nach <code>data/logs/</code>. Zugangsdaten, PINs und
TANs werden dabei maskiert. Im Normalbetrieb ausgeschaltet lassen: das Log
wächst schnell.
</p>
<h3>Vorhandene Logdateien</h3>
<?php if ($logDateien === []): ?>
<p class="platzhalter">Keine Logdateien vorhanden.</p>
<?php else: ?>
<table class="liste">
<thead>
<tr><th>Datei</th><th class="rechts">Größe</th><th>Geändert</th></tr>
</thead>
<tbody>
<?php foreach ($logDateien as $datei): ?>
<tr>
<td><code><?= View::h($datei['name']) ?></code></td>
<td class="rechts"><?= View::h(number_format($datei['groesse'] / 1024, 1, ',', '.')) ?> kB</td>
<td><?= View::h(date('d.m.Y H:i', $datei['geaendert'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php else: ?>
<p class="platzhalter">
Dieser Bereich füllt sich in einer späteren Phase:
<?= View::h('noch offen') ?>.
</p>
<?php endif; ?>
<?php if (in_array($bereich, ['allgemein', 'darstellung', 'buchhaltung', 'banking', 'import', 'wertpapiere', 'diagnose'], true)): ?>
<div class="knopfleiste">
<button type="submit" class="knopf primaer">Speichern</button>
</div>
<?php endif; ?>
</form>
<?php if ($bereich === 'diagnose' && $logDateien !== []): ?>
<form method="post" action="/einstellungen/log-leeren" class="formular breit"
data-bestaetigen="Alle Logdateien löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="knopfleiste">
<button type="submit" class="knopf">Logdateien löschen</button>
</div>
</form>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>