From dc66f75eef8fc9dc08e26e7478bfc268cc256dde Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 22 Jun 2026 23:29:03 +0200 Subject: [PATCH] Boersenchecker --- docs/README.md | 1 + modules/boersenchecker/api/chart_data.php | 139 +++ modules/boersenchecker/api/index.php | 30 + .../boersenchecker/assets/boersenchecker.css | 341 ++++++ .../boersenchecker/assets/boersenchecker.js | 152 +++ modules/boersenchecker/bootstrap.php | 995 ++++++++++++++++++ modules/boersenchecker/config/module.php | 30 + modules/boersenchecker/design.json | 15 + modules/boersenchecker/desktop.php | 25 + modules/boersenchecker/docs/README.md | 20 + modules/boersenchecker/module.json | 51 + modules/boersenchecker/pages/index.php | 272 +++++ modules/boersenchecker/partials/dashboard.php | 513 +++++++++ modules/boersenchecker/partials/home.php | 211 ++++ .../boersenchecker/partials/instruments.php | 188 ++++ .../src/Support/DashboardPage.php | 898 ++++++++++++++++ .../boersenchecker/src/Support/HomePage.php | 364 +++++++ .../src/Support/InstrumentPage.php | 351 ++++++ .../src/Support/InstrumentRegistry.php | 190 ++++ modules/fx-rates/bootstrap.php | 27 + public/api/boersenchecker/index.php | 5 + public/apps/boersenchecker/index.php | 5 + src/App/ModuleConfigException.php | 22 + src/ModulesCore/LegacyCompat.php | 311 +++++- 24 files changed, 5151 insertions(+), 5 deletions(-) create mode 100644 modules/boersenchecker/api/chart_data.php create mode 100644 modules/boersenchecker/api/index.php create mode 100644 modules/boersenchecker/assets/boersenchecker.css create mode 100644 modules/boersenchecker/assets/boersenchecker.js create mode 100644 modules/boersenchecker/bootstrap.php create mode 100644 modules/boersenchecker/config/module.php create mode 100644 modules/boersenchecker/design.json create mode 100644 modules/boersenchecker/desktop.php create mode 100644 modules/boersenchecker/docs/README.md create mode 100644 modules/boersenchecker/module.json create mode 100644 modules/boersenchecker/pages/index.php create mode 100644 modules/boersenchecker/partials/dashboard.php create mode 100644 modules/boersenchecker/partials/home.php create mode 100644 modules/boersenchecker/partials/instruments.php create mode 100644 modules/boersenchecker/src/Support/DashboardPage.php create mode 100644 modules/boersenchecker/src/Support/HomePage.php create mode 100644 modules/boersenchecker/src/Support/InstrumentPage.php create mode 100644 modules/boersenchecker/src/Support/InstrumentRegistry.php create mode 100644 public/api/boersenchecker/index.php create mode 100644 public/apps/boersenchecker/index.php create mode 100644 src/App/ModuleConfigException.php diff --git a/docs/README.md b/docs/README.md index c1ac1471..f3599b2a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ Aktuell wichtig: - der Mining-Checker ist das erste als echtes Desktop-Modul angebundene Modul unter `modules/mining-checker/` - der Waehrungs-Checker ist als zweites echtes Desktop-Modul unter `modules/fx-rates/` angebunden - der Salesforce Translation Import ist als weitere Desktop-App unter `modules/salesforce-translation-import/` angebunden +- der Boersenchecker ist als weiteres importiertes Desktop-Modul unter `modules/boersenchecker/` angebunden - Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden - die API bleibt vorerst auf demselben Host und wird nicht auf `api.desktop.kusche.berlin` ausgelagert - das gemeinsame Admin-Debug-Widget neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps diff --git a/modules/boersenchecker/api/chart_data.php b/modules/boersenchecker/api/chart_data.php new file mode 100644 index 00000000..3f72a5db --- /dev/null +++ b/modules/boersenchecker/api/chart_data.php @@ -0,0 +1,139 @@ + false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$pdo = module_fn('boersenchecker', 'pdo'); +module_fn('boersenchecker', 'ensure_schema'); +$instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); +$positionTable = module_fn('boersenchecker', 'table', 'positions'); +$quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + +$stmt = $pdo->prepare( + 'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency + FROM ' . $instrumentTable . ' i + INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id + WHERE i.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' +); +$stmt->execute([ + 'id' => $instrumentId, + 'owner_sub' => $ownerSub, +]); +$instrument = $stmt->fetch(PDO::FETCH_ASSOC); + +header('Content-Type: application/json; charset=utf-8'); +if (!is_array($instrument)) { + echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$quoteStmt = $pdo->prepare( + 'SELECT id, price, currency, quoted_at, source, created_at + FROM ' . $quoteTable . ' + WHERE instrument_id = :instrument_id + ORDER BY quoted_at ASC, created_at ASC, id ASC' +); +$quoteStmt->execute([ + 'instrument_id' => $instrumentId, +]); +$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + +if ($quotes === []) { + echo json_encode([ + 'ok' => false, + 'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$dailyMap = []; +foreach ($quotes as $quote) { + $localDate = trim((string) module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($quote['quoted_at'] ?? ''), + (string) ($quote['source'] ?? ''), + 'Y-m-d' + )); + $localDateTime = trim((string) module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($quote['quoted_at'] ?? ''), + (string) ($quote['source'] ?? ''), + 'Y-m-d H:i:s' + )); + if ($localDate === '' || !is_numeric($quote['price'] ?? null)) { + continue; + } + + $point = [ + 'date' => $localDate, + 'close' => (float) $quote['price'], + 'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))), + 'quoted_at' => $localDateTime, + 'source' => (string) ($quote['source'] ?? ''), + ]; + + if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) { + $dailyMap[$localDate] = $point; + } +} + +$daily = array_values($dailyMap); +usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + +if ($daily === []) { + echo json_encode([ + 'ok' => false, + 'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$aggregate = static function (array $points, string $format): array { + $result = []; + $timezone = new DateTimeZone(nexus_display_timezone_name()); + foreach ($points as $point) { + $date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone); + if (!$date instanceof DateTimeImmutable) { + continue; + } + $bucket = $date->format($format); + $result[$bucket] = $point; + } + + return array_values($result); +}; + +echo json_encode([ + 'ok' => true, + 'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))), + 'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))), + 'instrument_name' => (string) ($instrument['name'] ?? ''), + 'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))), + 'daily' => $daily, + 'weekly' => $aggregate($daily, 'o-W'), + 'monthly' => $aggregate($daily, 'Y-m'), + 'source' => 'database:quotes', + 'source_label' => 'Lokale Kurshistorie', +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +exit; diff --git a/modules/boersenchecker/api/index.php b/modules/boersenchecker/api/index.php new file mode 100644 index 00000000..428674b8 --- /dev/null +++ b/modules/boersenchecker/api/index.php @@ -0,0 +1,30 @@ + false, + 'message' => 'Ressource nicht gefunden.', +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/modules/boersenchecker/assets/boersenchecker.css b/modules/boersenchecker/assets/boersenchecker.css new file mode 100644 index 00000000..da5ab8dd --- /dev/null +++ b/modules/boersenchecker/assets/boersenchecker.css @@ -0,0 +1,341 @@ +.bc-page { + --bc-accent: var(--brand-accent); + --bc-accent-strong: var(--brand-accent-2); + --bc-ink: var(--text); + --bc-text: var(--text); + --bc-muted: var(--muted); + --bc-line: var(--line); + --bc-panel: rgba(255, 255, 255, 0.78); + --bc-panel-soft: rgba(248, 252, 252, 0.92); + --bc-positive: #137333; + --bc-negative: #c62828; + color: var(--bc-text); +} + +.bc-page { + display: grid; + gap: 16px; +} + +.bc-text { + color: var(--bc-muted); + margin: 0; +} + +.bc-section-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.bc-section-title { + margin: 0; + font-size: 1.45rem; + line-height: 1.15; +} + +.bc-section-head p, +.bc-section-copy { + color: var(--bc-muted); + margin: 8px 0 0; +} + +.bc-form-card, +.bc-panel, +.bc-stat, +.bc-chart-card, +.bc-position-row { + border: 1px solid var(--bc-line); + border-radius: 22px; + background: var(--bc-panel); + box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06); +} + +.bc-form-card, +.bc-panel, +.bc-chart-card { + padding: 18px; +} + +.bc-stat { + padding: 16px; +} + +.bc-field-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bc-muted); +} + +.bc-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.bc-button, +.bc-tabs a, +.bc-page button, +.bc-page input, +.bc-page select, +.bc-page textarea { + font: inherit; +} + +.bc-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 16px; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + transition: 160ms ease; +} + +.bc-button:hover { + transform: translateY(-1px); +} + +.bc-button--tab { + background: rgba(255, 255, 255, 0.94); + color: var(--bc-ink); + font-weight: 700; +} + +.bc-button--tab-active { + background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3)); + color: #fff7fb; + font-weight: 700; +} + +.bc-button--primary { + background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3)); + color: #fff7fb; + font-weight: 700; +} + +.bc-button--secondary { + background: rgba(255, 255, 255, 0.94); + color: var(--bc-ink); + font-weight: 700; +} + +.bc-button--ghost { + background: color-mix(in srgb, var(--bc-accent) 14%, transparent); + border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent); + color: var(--bc-accent); + font-weight: 700; +} + +.bc-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bc-alert { + padding: 16px 18px; + border-radius: 20px; + border: 1px solid transparent; +} + +.bc-alert--error { + background: rgba(127, 29, 29, 0.28); + border-color: rgba(252, 165, 165, 0.24); + color: #fecaca; +} + +.bc-alert--success { + background: rgba(6, 78, 59, 0.28); + border-color: rgba(134, 239, 172, 0.24); + color: #bbf7d0; +} + +.bc-toolbar, +.bc-overview-grid, +.bc-card-grid { + display: grid; + gap: 14px; +} + +.bc-toolbar { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bc-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.bc-card-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bc-stat-value { + margin-top: 8px; + font-size: 1.42rem; + font-weight: 700; +} + +.bc-chart-shell { + position: relative; + min-height: 360px; + overflow: hidden; +} + +.bc-chart-svg { + width: 100%; + height: 340px; + display: block; +} + +.bc-chart-path { + fill: none; + stroke: var(--bc-accent); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent)); +} + +.bc-chart-area { + fill: url(#bc-chart-fill); +} + +.bc-chart-grid line { + stroke: rgba(16, 33, 43, 0.08); + stroke-dasharray: 4 6; +} + +.bc-range-list { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bc-range-button { + border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent); + background: rgba(255, 255, 255, 0.76); + color: var(--bc-text); + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: transform .18s ease, background .18s ease, border-color .18s ease; +} + +.bc-range-button:hover, +.bc-range-button[aria-pressed="true"] { + transform: translateY(-1px); + background: color-mix(in srgb, var(--bc-accent) 18%, transparent); + border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent); +} + +.bc-panel-fade { + animation: bcPanelFade .35s ease; +} + +@keyframes bcPanelFade { + from { opacity: 0; transform: translateY(8px) scale(.99); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.bc-position-list { + display: grid; + gap: 14px; +} + +.bc-position-row { + display: grid; + grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr)); + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.bc-performance { + font-weight: 700; +} + +.bc-performance.is-positive { + color: var(--bc-positive); +} + +.bc-performance.is-negative { + color: var(--bc-negative); +} + +.bc-pill-soft { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--bc-accent) 10%, transparent); + color: var(--bc-text); + font-size: .85rem; +} + +.bc-table-shell { + overflow: auto; + border-radius: 18px; + border: 1px solid var(--bc-line); +} + +.bc-table { + width: 100%; + border-collapse: collapse; +} + +.bc-table th, +.bc-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid rgba(255,255,255,0.06); + vertical-align: top; +} + +.bc-table thead { + background: rgba(255,255,255,0.04); +} + +.bc-page .setup-field { + display: grid; + gap: 6px; +} + +.bc-page input, +.bc-page select, +.bc-page textarea { + width: 100%; + border: 1px solid var(--bc-line); + border-radius: 14px; + padding: 10px 12px; + background: var(--surface-strong); + color: var(--bc-text); +} + +.bc-page input::placeholder, +.bc-page textarea::placeholder { + color: color-mix(in srgb, var(--bc-muted) 70%, transparent); +} + +.bc-page a { + color: inherit; +} + +.bc-page .muted { + color: var(--bc-muted); +} + +@media (max-width: 980px) { + .bc-hero-top { + grid-template-columns: 1fr; + } + + .bc-position-row { + grid-template-columns: 1fr; + } +} diff --git a/modules/boersenchecker/assets/boersenchecker.js b/modules/boersenchecker/assets/boersenchecker.js new file mode 100644 index 00000000..b53063f2 --- /dev/null +++ b/modules/boersenchecker/assets/boersenchecker.js @@ -0,0 +1,152 @@ +(function () { + const app = document.querySelector('[data-bc-home]'); + if (!app) return; + + const chartShell = app.querySelector('[data-bc-chart]'); + const instrumentSelect = app.querySelector('[data-bc-instrument]'); + const instrumentNameNode = app.querySelector('[data-bc-instrument-name]'); + const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]'); + const rangeButtons = Array.from(app.querySelectorAll('[data-range]')); + const statusNode = app.querySelector('[data-bc-chart-status]'); + const summaryNode = app.querySelector('[data-bc-chart-summary]'); + const endpoint = app.getAttribute('data-chart-endpoint') || ''; + const instrumentsScript = app.querySelector('[data-bc-instruments-json]'); + const instrumentMap = new Map(); + if (instrumentsScript?.textContent) { + try { + const items = JSON.parse(instrumentsScript.textContent); + if (Array.isArray(items)) { + items.forEach((item) => instrumentMap.set(String(item.instrument_id), item)); + } + } catch (_error) {} + } + let activeRange = '1m'; + let currentPayload = null; + + function pointsForRange(payload, range) { + if (!payload) return []; + const daily = payload.daily || []; + const weekly = payload.weekly || []; + const monthly = payload.monthly || []; + switch (range) { + case '1d': return daily.slice(-2); + case '5d': return daily.slice(-5); + case '1m': return daily.slice(-22); + case '3m': return daily.slice(-66); + case '6m': return weekly.slice(-26); + case '1y': return weekly.slice(-52); + case '5y': return monthly.slice(-60); + default: return daily.slice(-22); + } + } + + function renderChart(points) { + if (!chartShell) return; + chartShell.classList.remove('bc-panel-fade'); + void chartShell.offsetWidth; + chartShell.classList.add('bc-panel-fade'); + + if (!points || points.length === 0) { + chartShell.innerHTML = '
Keine Chartdaten verfuegbar.
'; + return; + } + + const values = points.map((point) => Number(point.close || 0)); + const min = Math.min(...values); + const max = Math.max(...values); + const width = 920; + const height = 340; + const paddingX = 24; + const paddingY = 28; + const usableWidth = width - paddingX * 2; + const usableHeight = height - paddingY * 2; + const spread = max - min || 1; + + const coords = points.map((point, index) => { + const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1)); + const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight; + return { x, y }; + }); + + const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' '); + const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`; + const grid = [0, 1, 2, 3].map((step) => { + const y = paddingY + (usableHeight * step / 3); + return ``; + }).join(''); + + chartShell.innerHTML = ` + + + + + + + + ${grid} + + + + `; + + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + const percent = first !== 0 ? (delta / first) * 100 : 0; + if (summaryNode) { + summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`; + } + } + + async function loadChart() { + const instrumentId = instrumentSelect ? instrumentSelect.value : ''; + if (!instrumentId || !endpoint) { + if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.'; + if (summaryNode) summaryNode.textContent = '-'; + if (chartShell) chartShell.innerHTML = '
Keine Chartdaten verfuegbar.
'; + return; + } + if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...'; + try { + const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } }); + const payload = await response.json(); + if (!payload.ok) { + throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.'); + } + currentPayload = payload; + renderChart(pointsForRange(payload, activeRange)); + if (statusNode) { + const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie'; + const instrumentRef = payload.symbol || payload.isin || ''; + statusNode.textContent = instrumentRef + ? `Quelle: ${sourceLabel} | ${instrumentRef}` + : `Quelle: ${sourceLabel}`; + } + } catch (error) { + currentPayload = null; + chartShell.innerHTML = `
${error.message}
`; + if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.'; + } + } + + rangeButtons.forEach((button) => { + button.addEventListener('click', () => { + activeRange = button.getAttribute('data-range') || '1m'; + rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false')); + renderChart(pointsForRange(currentPayload, activeRange)); + }); + }); + + if (instrumentSelect) { + instrumentSelect.addEventListener('change', () => { + const meta = instrumentMap.get(String(instrumentSelect.value)); + if (meta) { + if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt'; + if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} ยท ${meta.isin || '-'}`; + } + loadChart(); + }); + } + + loadChart(); +})(); diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php new file mode 100644 index 00000000..cea0bba7 --- /dev/null +++ b/modules/boersenchecker/bootstrap.php @@ -0,0 +1,995 @@ +registerFunction($moduleName, 'table', static function (string $name): string { + $prefix = 'boersencheck_'; + $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return $prefix . $sanitized; +}); + +$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO { + $settings = modules()->settings($moduleName); + $useSeparate = !empty($settings['use_separate_db']); + + if ($useSeparate) { + $module = modules()->get($moduleName); + $fallback = $module['db_defaults'] ?? []; + return modules()->modulePdo($moduleName, $fallback); + } + + $base = app()->basePdo(); + if ($base instanceof \PDO) { + return $base; + } + + throw new ModuleConfigException( + $moduleName, + 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.' + ); +}); + +$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void { + $pdo = module_fn($moduleName, 'pdo'); + $table = static fn (string $name): string => module_fn($moduleName, 'table', $name); + $driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + + $portfolioTable = $table('portfolios'); + $instrumentTable = $table('instruments'); + $positionTable = $table('positions'); + $quoteTable = $table('quotes'); + + if ($driver === 'pgsql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id SERIAL PRIMARY KEY, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity NUMERIC(20,6) NOT NULL, + purchase_price NUMERIC(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees NUMERIC(20,8) NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id SERIAL PRIMARY KEY, + instrument_id INTEGER NOT NULL, + price NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at TIMESTAMP NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } elseif ($driver === 'mysql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$portfolioTable}_owner_idx (owner_sub) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY {$instrumentTable}_isin_uniq (isin), + KEY {$instrumentTable}_symbol_idx (symbol) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$positionTable}_owner_idx (owner_sub), + KEY {$positionTable}_portfolio_idx (portfolio_id), + KEY {$positionTable}_instrument_idx (instrument_id) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at) + )"); + } else { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } +}); + +$mm->registerFunction($moduleName, 'fx_service', static function (): ?object { + if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) { + try { + return module_fn('fx-rates', 'service'); + } catch (\Throwable) { + return null; + } + } + + return null; +}); + +$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', + ]; + } + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR'); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) + ? 'Vorhandene FX-Daten weiterverwendet.' + : 'FX-Daten aktualisiert.', + 'result' => $result, + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function ( + string $baseCurrency = 'EUR', + array $currencies = [], + float $maxAgeHours = 6.0 +): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', + ]; + } + + $baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR'; + $currencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + $currencies + ), static fn (string $code): bool => $code !== ''))); + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.', + 'result' => $result, + 'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null, + 'reused' => !empty($result['reused']), + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string { + $source = trim($source) !== '' ? trim($source) : 'manual'; + if ($fetchId === null || $fetchId <= 0) { + return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + } + + $source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + return $source . '|fx_fetch:' . $fetchId; +}); + +$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int { + $source = trim((string) $source); + if ($source === '') { + return null; + } + + if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) { + $fetchId = (int) ($matches[1] ?? 0); + return $fetchId > 0 ? $fetchId : null; + } + + return null; +}); + +$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function ( + ?float $amount, + ?string $fromCurrency, + ?string $toCurrency, + ?int $fetchId = null +): ?float { + if ($amount === null) { + return null; + } + + $from = strtoupper(trim((string) $fromCurrency)); + $to = strtoupper(trim((string) $toCurrency)); + if ($from === '' || $to === '') { + return null; + } + if ($from === $to) { + return $amount; + } + + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service) { + return null; + } + + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) { + try { + $snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]); + if (is_array($snapshot)) { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null); + $toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null); + if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) { + return $amount * ($toRate / $fromRate); + } + } + } catch (\Throwable) { + } + } + + if (!method_exists($service, 'convert')) { + return null; + } + + try { + $value = $service->convert($amount, $from, $to); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_request', static function ( + string $functionName, + array $params = [] +): array { + $settings = modules()->settings('boersenchecker'); + $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); + $timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12); + $timeout = $timeout > 0 ? $timeout : 12; + + if ($apiKey === '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'params' => $params, + ], + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + ]; + } + + $url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([ + 'function' => $functionName, + 'apikey' => $apiKey, + ], $params), '', '&', PHP_QUERY_RFC3986); + + $responseBody = null; + $httpCode = 0; + $curlError = ''; + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch !== false) { + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => min(5, $timeout), + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $responseBody = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + } + } + + if (!is_string($responseBody) || $responseBody === '') { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeout, + 'header' => "Accept: application/json\r\n", + ], + ]); + $responseBody = @file_get_contents($url, false, $context); + } + + if (!is_string($responseBody) || $responseBody === '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'curl_error' => $curlError, + 'body' => null, + ], + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.' + . ($curlError !== '' ? ' ' . $curlError : '') + . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), + ]; + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body_preview' => substr($responseBody, 0, 4000), + ], + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', + 'raw_body' => $responseBody, + ]; + } + + foreach (['Error Message', 'Information', 'Note'] as $errorKey) { + if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body' => $decoded, + ], + 'message' => trim((string) $decoded[$errorKey]), + ]); + return [ + 'ok' => false, + 'message' => trim((string) $decoded[$errorKey]), + 'raw' => $decoded, + ]; + } + } + + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:response', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body' => $decoded, + ], + ]); + + return [ + 'ok' => true, + 'data' => $decoded, + ]; +}); + +$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone { + return new \DateTimeZone(nexus_display_timezone_name()); +}); + +$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string { + if (is_numeric($value)) { + return gmdate('Y-m-d H:i:s', (int) $value); + } + + $raw = trim((string) $value); + if ($raw === '') { + return gmdate('Y-m-d H:i:s'); + } + + try { + $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); + return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + $timestamp = strtotime($raw); + return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s'); + } +}); + +$mm->registerFunction($moduleName, 'format_datetime_for_display', static function ( + ?string $value, + ?string $source = null, + string $format = 'Y-m-d H:i:s' +): string { + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + $displayTimezone = new \DateTimeZone(nexus_display_timezone_name()); + $source = trim((string) $source); + + if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) { + $date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC')); + if (!$date instanceof \DateTimeImmutable) { + try { + $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); + } catch (\Throwable) { + return $raw; + } + } + return $date->setTimezone($displayTimezone)->format($format); + } + + if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) { + try { + return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format); + } catch (\Throwable) { + return $raw; + } + } + + return str_replace('T', ' ', $raw); +}); + +$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string { + return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i'); +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array { + $quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry; + $price = $quote['05. price'] ?? null; + if (!is_numeric($price)) { + return null; + } + + return [ + 'symbol' => trim((string) ($quote['01. symbol'] ?? '')), + 'price' => (float) $price, + 'currency' => '', + 'fetched_at' => gmdate('Y-m-d H:i:s'), + 'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')), + 'source' => 'alphavantage:global_quote', + 'raw' => $quote, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array { + $symbol = strtoupper(trim($symbol)); + if ($symbol === '') { + return [ + 'ok' => false, + 'message' => 'Kein Symbol hinterlegt.', + ]; + } + + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [ + 'symbol' => $symbol, + ]); + if (empty($response['ok'])) { + return $response; + } + + $quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? [])); + if (!is_array($quote)) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', + ]; + } + + return ['ok' => true] + $quote; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array { + $quotes = []; + $errors = []; + foreach ($instruments as $instrument) { + if (!is_array($instrument)) { + continue; + } + $instrumentId = (int) ($instrument['id'] ?? 0); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + continue; + } + + $result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($result['ok'])) { + $errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.'); + continue; + } + + $quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId]; + } + + return [ + 'ok' => true, + 'quotes' => $quotes, + 'errors' => $errors, + 'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.', + ]; +}); + +$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array { + $pdo = module_fn('boersenchecker', 'pdo'); + $settings = modules()->settings('boersenchecker'); + $instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); + $positionTable = module_fn('boersenchecker', 'table', 'positions'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + + $defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($minIntervalMinutes <= 0) { + $minIntervalMinutes = 60; + } + + $stmt = $pdo->query( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.quote_currency + FROM ' . $positionTable . ' p + INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id + WHERE i.symbol IS NOT NULL + AND i.symbol <> \'\' + ORDER BY i.name ASC' + ); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + return [ + 'ok' => true, + 'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.', + ]; + } + + $instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows)); + $instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0)); + $latestQuotes = []; + if ($instrumentIds !== []) { + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $latestStmt = $pdo->prepare( + 'SELECT * + FROM ' . $quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + AND source LIKE ? + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $latestStmt->execute([...$instrumentIds, 'alphavantage:%']); + foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $instrumentId = (int) ($row['instrument_id'] ?? 0); + if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $row; + } + } + } + + $reused = 0; + $candidates = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $latest = $latestQuotes[$instrumentId] ?? null; + $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) { + $reused++; + continue; + } + $candidates[] = $row; + } + + if ($candidates === []) { + return [ + 'ok' => true, + 'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.', + ]; + } + + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))), + $candidates + ), static fn (string $code): bool => $code !== ''))); + $fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6)); + if (empty($fxResult['ok'])) { + return $fxResult; + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates); + if (empty($bulkResult['ok'])) { + return [ + 'ok' => false, + 'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'), + ]; + } + + $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + $errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : []; + $updated = 0; + foreach ($candidates as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quote = $quotes[$instrumentId] ?? null; + if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { + continue; + } + + $storeResult = module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $quote['price'], + strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency, + (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), + (string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $updated++; + } else { + $reused++; + } + } + + $message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.'; + if ($errors !== []) { + $message .= ' ' . implode(' | ', array_slice($errors, 0, 3)); + } + + module_debug_push('boersenchecker', [ + 'label' => 'Intervall-Aufgabe', + 'type' => 'scheduler:run', + 'task' => 'auto_refresh_quotes', + 'context' => $context, + 'message' => $message, + ]); + + return [ + 'ok' => $errors === [], + 'message' => $message, + 'updated' => $updated, + 'reused' => $reused, + 'errors' => $errors, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { + $keywords = trim($keywords); + if ($keywords === '') { + return [ + 'ok' => false, + 'message' => 'Bitte Suchbegriff angeben.', + 'results' => [], + ]; + } + + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [ + 'keywords' => $keywords, + ]); + if (empty($response['ok'])) { + return $response + ['results' => []]; + } + + $data = is_array($response['data'] ?? null) ? $response['data'] : []; + $items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : []; + $results = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $symbol = trim((string) ($item['1. symbol'] ?? '')); + $name = trim((string) ($item['2. name'] ?? '')); + $type = trim((string) ($item['3. type'] ?? '')); + $region = trim((string) ($item['4. region'] ?? '')); + $currency = strtoupper(trim((string) ($item['8. currency'] ?? ''))); + $matchScore = trim((string) ($item['9. matchScore'] ?? '')); + if ($symbol === '' && $name === '') { + continue; + } + + $results[] = [ + 'symbol' => $symbol, + 'name' => $name, + 'isin' => '', + 'type' => $type, + 'region' => $region, + 'currency' => $currency, + 'match_score' => $matchScore, + 'raw' => $item, + ]; + } + + return [ + 'ok' => true, + 'message' => count($results) . ' Treffer gefunden.', + 'results' => $results, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array { + $symbol = strtoupper(trim($symbol)); + if ($symbol === '') { + return ['ok' => false, 'message' => 'Kein Symbol angegeben.']; + } + + $cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage'; + if (!is_dir($cacheDir)) { + @mkdir($cacheDir, 0775, true); + } + $cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json'; + + $decoded = null; + if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) { + $cached = file_get_contents($cachePath); + $decoded = is_string($cached) ? json_decode($cached, true) : null; + } + + if (!is_array($decoded)) { + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [ + 'symbol' => $symbol, + 'outputsize' => 'full', + ]); + if (empty($response['ok'])) { + return $response; + } + $decoded = is_array($response['data'] ?? null) ? $response['data'] : []; + @file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $rows = is_array($decoded['Time Series (Daily)'] ?? null) + ? $decoded['Time Series (Daily)'] + : (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []); + + $daily = []; + foreach ($rows as $date => $row) { + if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) { + continue; + } + $close = $row['5. adjusted close'] ?? $row['4. close'] ?? null; + if (!is_numeric($close)) { + continue; + } + $daily[] = [ + 'date' => $date, + 'close' => (float) $close, + ]; + } + usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + if ($daily === []) { + return [ + 'ok' => false, + 'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.', + ]; + } + + $aggregate = static function (array $points, string $format): array { + $result = []; + foreach ($points as $point) { + $bucket = date($format, strtotime((string) $point['date']) ?: time()); + $result[$bucket] = $point; + } + return array_values($result); + }; + + return [ + 'ok' => true, + 'symbol' => $symbol, + 'daily' => $daily, + 'weekly' => $aggregate($daily, 'o-W'), + 'monthly' => $aggregate($daily, 'Y-m'), + 'source' => 'alphavantage:time_series_daily_adjusted', + ]; +}); + +$mm->registerFunction($moduleName, 'store_market_quote', static function ( + int $instrumentId, + float $price, + string $currency, + string $quotedAt, + string $source +): array { + $pdo = module_fn('boersenchecker', 'pdo'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + + $quotedAt = trim($quotedAt); + $currency = strtoupper(trim($currency)) ?: 'EUR'; + $source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote'; + + $checkStmt = $pdo->prepare( + 'SELECT id + FROM ' . $quoteTable . ' + WHERE instrument_id = :instrument_id + AND price = :price + AND currency = :currency + AND quoted_at = :quoted_at + AND source = :source + LIMIT 1' + ); + $checkStmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + $existingId = (int) $checkStmt->fetchColumn(); + if ($existingId > 0) { + module_debug_push('boersenchecker', [ + 'label' => 'Quote Store', + 'type' => 'quote:reuse', + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + 'message' => 'Identischer Snapshot bereits vorhanden.', + ]); + return ['ok' => true, 'inserted' => false, 'id' => $existingId]; + } + + $insertStmt = $pdo->prepare( + 'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $insertStmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + + $insertedId = (int) $pdo->lastInsertId(); + module_debug_push('boersenchecker', [ + 'label' => 'Quote Store', + 'type' => 'quote:insert', + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + 'inserted_id' => $insertedId, + 'message' => 'Neuer Snapshot gespeichert.', + ]); + + return ['ok' => true, 'inserted' => true, 'id' => $insertedId]; +}); diff --git a/modules/boersenchecker/config/module.php b/modules/boersenchecker/config/module.php new file mode 100644 index 00000000..0443f670 --- /dev/null +++ b/modules/boersenchecker/config/module.php @@ -0,0 +1,30 @@ + filter_var(getenv('BOERSENCHECKER_USE_SEPARATE_DB') ?: 'false', FILTER_VALIDATE_BOOL), + 'db' => [ + 'driver' => getenv('BOERSENCHECKER_DB_DRIVER') ?: (string) ($baseDbConfig['driver'] ?? 'pgsql'), + 'host' => getenv('BOERSENCHECKER_DB_HOST') ?: (string) ($baseDbConfig['host'] ?? 'localhost'), + 'port' => (int) (getenv('BOERSENCHECKER_DB_PORT') ?: (int) ($baseDbConfig['port'] ?? 5432)), + 'dbname' => getenv('BOERSENCHECKER_DB_NAME') ?: (string) ($baseDbConfig['dbname'] ?? ''), + 'schema' => getenv('BOERSENCHECKER_DB_SCHEMA') ?: (string) ($baseDbConfig['schema'] ?? 'public'), + 'user' => getenv('BOERSENCHECKER_DB_USER') ?: (string) ($baseDbConfig['user'] ?? ''), + 'password' => getenv('BOERSENCHECKER_DB_PASSWORD') ?: (string) ($baseDbConfig['password'] ?? ''), + ], + 'report_currency' => getenv('BOERSENCHECKER_REPORT_CURRENCY') ?: 'EUR', + 'fx_max_age_hours' => (float) (getenv('BOERSENCHECKER_FX_MAX_AGE_HOURS') ?: 6), + 'alpha_vantage_api_key' => getenv('BOERSENCHECKER_ALPHA_VANTAGE_API_KEY') ?: (getenv('ALPHA_VANTAGE_API_KEY') ?: ''), + 'alpha_vantage_timeout_sec' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_TIMEOUT_SEC') ?: 12), + 'alpha_vantage_min_interval_minutes' => (int) (getenv('BOERSENCHECKER_ALPHA_VANTAGE_MIN_INTERVAL_MINUTES') ?: 60), + 'auto_refresh_quotes_enabled' => filter_var(getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_ENABLED') ?: 'false', FILTER_VALIDATE_BOOL), + 'auto_refresh_quotes_interval_hours' => (int) (getenv('BOERSENCHECKER_AUTO_REFRESH_QUOTES_INTERVAL_HOURS') ?: 6), +]; diff --git a/modules/boersenchecker/design.json b/modules/boersenchecker/design.json new file mode 100644 index 00000000..c7aa23cd --- /dev/null +++ b/modules/boersenchecker/design.json @@ -0,0 +1,15 @@ +{ + "eyebrow": "Modul", + "title": "Boersenchecker", + "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", + "actions": [ + { "label": "Desktop", "href": "/" }, + { "label": "Setup", "href": "/apps/boersenchecker?view=setup" } + ], + "tabs": [ + { "label": "Ueberblick", "href": "/apps/boersenchecker?view=overview", "view": "overview" }, + { "label": "Depotverwaltung", "href": "/apps/boersenchecker?view=depotverwaltung", "view": "depotverwaltung" }, + { "label": "Aktienverwaltung", "href": "/apps/boersenchecker?view=aktienverwaltung", "view": "aktienverwaltung" }, + { "label": "Setup", "href": "/apps/boersenchecker?view=setup", "view": "setup" } + ] +} diff --git a/modules/boersenchecker/desktop.php b/modules/boersenchecker/desktop.php new file mode 100644 index 00000000..feba66df --- /dev/null +++ b/modules/boersenchecker/desktop.php @@ -0,0 +1,25 @@ + 'boersenchecker', + 'title' => (string) ($manifest['title'] ?? 'Bรถrsenchecker'), + 'icon' => 'BC', + 'entry_route' => '/apps/boersenchecker', + 'window_mode' => 'window', + 'content_mode' => 'iframe', + 'default_width' => 1220, + 'default_height' => 860, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'finance', + 'summary' => (string) ($manifest['description'] ?? 'Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.'), +]; diff --git a/modules/boersenchecker/docs/README.md b/modules/boersenchecker/docs/README.md new file mode 100644 index 00000000..fbfca4c2 --- /dev/null +++ b/modules/boersenchecker/docs/README.md @@ -0,0 +1,20 @@ +# Bรถrsenchecker + +Importiertes Altmodul fuer Depotverwaltung, Aktienstammdaten, Kursverlauf, Alpha-Vantage-Abrufe und FX-Umrechnung. + +## Einbindung + +- Desktop-App ueber `modules/boersenchecker/desktop.php` +- oeffentlicher App-Einstieg ueber `public/apps/boersenchecker/index.php` +- API-Endpunkt fuer Chartdaten ueber `public/api/boersenchecker/index.php?path=v1/chart-data` + +## Konfiguration + +- Standardwerte liegen in `config/module.php` +- gespeicherte Modul-Settings liegen unter `data/module-settings/boersenchecker.json` +- das Setup innerhalb der App bildet die alten Nexus-Setup-Felder ab + +## Altlogik + +- Fachlogik, Partials und Alpha-Vantage-/FX-Helfer stammen aus dem alten Nexus-Modul +- die Legacy-Kompatibilitaet wird ueber `src/ModulesCore/LegacyCompat.php` bereitgestellt diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json new file mode 100644 index 00000000..cf66a08e --- /dev/null +++ b/modules/boersenchecker/module.json @@ -0,0 +1,51 @@ +{ + "title": "Bรถrsenchecker", + "version": "0.2.0", + "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", + "enabled_by_default": true, + "setup": { + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." }, + { "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." }, + { "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." }, + { "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, + { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." }, + { "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." }, + { "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." } + ] + }, + "interval_tasks": [ + { + "name": "auto_refresh_quotes", + "label": "Automatischer Kursabruf", + "callback": "scheduled_refresh_quotes", + "enabled_setting": "auto_refresh_quotes_enabled", + "interval_setting": "auto_refresh_quotes_interval_hours", + "default_enabled": false, + "default_interval_hours": 6, + "lock_minutes": 20 + } + ], + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/modules/boersenchecker/pages/index.php b/modules/boersenchecker/pages/index.php new file mode 100644 index 00000000..95c8dac7 --- /dev/null +++ b/modules/boersenchecker/pages/index.php @@ -0,0 +1,272 @@ +settings($moduleName); + $notice = null; + $error = null; + + $getValue = static function (array $source, string $path): mixed { + $value = $source; + foreach (explode('.', $path) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return null; + } + $value = $value[$segment]; + } + + return $value; + }; + + $setValue = static function (array &$target, string $path, mixed $value): void { + $segments = explode('.', $path); + $cursor = &$target; + foreach ($segments as $index => $segment) { + if ($index === count($segments) - 1) { + $cursor[$segment] = $value; + return; + } + if (!isset($cursor[$segment]) || !is_array($cursor[$segment])) { + $cursor[$segment] = []; + } + $cursor = &$cursor[$segment]; + } + }; + + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'save_setup') { + try { + $nextSettings = $settings; + foreach ($fields as $field) { + if (!is_array($field)) { + continue; + } + $name = trim((string) ($field['name'] ?? '')); + $type = trim((string) ($field['type'] ?? 'text')); + if ($name === '') { + continue; + } + $raw = $_POST['settings'][$name] ?? null; + $value = match ($type) { + 'checkbox' => $raw !== null, + 'number' => ($raw === null || $raw === '') ? null : (is_numeric((string) $raw) ? 0 + $raw : null), + default => is_string($raw) ? trim($raw) : '', + }; + $setValue($nextSettings, $name, $value); + } + modules()->saveSettings($moduleName, $nextSettings); + $settings = modules()->settings($moduleName); + $notice = 'Setup gespeichert.'; + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + echo module_shell_header('boersenchecker', [ + 'title' => 'Setup', + 'description' => 'Modulkonfiguration, API-Zugang und optionale separate Datenbank.', + ]); + ?> +
+ +
+ +
+ + +
+
+
+

Modul-Setup

+

Die Felder entsprechen dem alten Nexus-Modul und werden lokal in `data/module-settings/boersenchecker.json` gespeichert.

+
+
+
+ +
+ + + + +
+
+ +
+
+
+
+ + + + + + + Bรถrsenchecker + + + + +
+ handle()); + } elseif ($view === 'aktienverwaltung') { + $page = new \Modules\Boersenchecker\Support\InstrumentPage(); + module_tpl('boersenchecker', 'instruments', $page->handle()); + } else { + $page = new \Modules\Boersenchecker\Support\HomePage(); + module_tpl('boersenchecker', 'home', $page->handle()); + } + ?> +
+ + + + diff --git a/modules/boersenchecker/partials/dashboard.php b/modules/boersenchecker/partials/dashboard.php new file mode 100644 index 00000000..d5284d5d --- /dev/null +++ b/modules/boersenchecker/partials/dashboard.php @@ -0,0 +1,513 @@ + + 'Depotverwaltung', +]) ?> +
+ +
+ +
+ + + +
+
+
+

Benutzer-Scope

+

Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.

+
+
+
+ + + +
+
+ + +
+
+
+
+

+

Stammdaten und Berichtswahrung fuer ein Depot pflegen.

+
+
+
+ + + + + + +
+ + + Abbrechen + +
+
+
+ +
+
+
+

API / FX

+

Kurs- und Waehrungsdaten zentral aktualisieren.

+
+
+

+ Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell + angestossen und respektiert die dortige Max-Age- und Reuse-Logik. +

+

+ Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten. +

+
+
+ + + +
+
+ + + +
+
+
+ Alpha Vantage Mindestabstand: Min. +
+
+ API-Key und Timeout fuer Aktienkurse werden ueber dieses Modul-Setup gepflegt. +
+
+ FX-Provider, API-Key und Waehrungskatalog werden im Modul fx-rates gepflegt. +
+
+ Standard-Berichtswahrung: ยท Max. Alter: h +
+
+
+ +
+
+
+

+

Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.

+
+
+ +
Bitte zuerst ein Depot anlegen.
+ +
+ + + + + +
+ + + + + + +
+
+ + + + + +
+ +
+ + + Abbrechen + +
+
+ +
+ +
+
+
+

Wertpapiersuche

+

Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.

+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
SymbolNameTypRegionWaehrungMatchAktion
+ + In Formular uebernehmen + +
+ +
+
Noch keine Symbolsuche ausgefuehrt.
+
+ +
+ +
+
+
+

Manuellen Kurs erfassen

+

Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.

+
+
+ +
Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.
+ +
+ + +
+ + + + + +
+
+ +
+
+ +
+ +
+
+
+

Depots

+

Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.

+
+
+ +
Noch keine Depots vorhanden.
+ +
+ + 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false]; + ?> +
+
+
+ +
ยท Position(en)
+
+
+ Bearbeiten +
+ + + + +
+
+
+ +
+ +
+
+
Investiert
+ +
+
+
Aktuell
+ +
+
+
Gewinn / Verlust
+ +
+
+
+ +
+ +
+ +
+
+
+

Positionen

+

Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.

+
+
+ +
+
Noch keine Positionen vorhanden.
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepotAktieISIN / WKNStueckKaufLetzter KursWertDeltaAktion
+ + +
+ +
+ +
+
+ +
+
+ + +
+ + kein Kurs + +
+ + + + n/a + + + + + + n/a + + +
+ Bearbeiten + Kurs erfassen +
+ + + + +
+
+ + + + +
+
+
+ +
+ +
+
+
+

Kursverlauf

+

Historische Kurse pro Aktie mit Zeitstempel und Quelle.

+
+
+ +
Noch keine Kursdaten vorhanden.
+ +
+ $instrument): ?> + +
+
+
+ +
+ ยท +
+
+ Neuen Kurs erfassen +
+ +
Noch keine historischen Kurse vorhanden.
+ +
+ + + + + + + + + + + + + + + + + + + +
ZeitpunktKursQuelleAktion
+
+ + + + +
+
+
+ +
+ +
+ +
+
+ diff --git a/modules/boersenchecker/partials/home.php b/modules/boersenchecker/partials/home.php new file mode 100644 index 00000000..b0370570 --- /dev/null +++ b/modules/boersenchecker/partials/home.php @@ -0,0 +1,211 @@ + 'Depot-Ueberblick', +]) ?> +
+ + + +
+ +
+ + +
+
+
+

Marktueberblick

+

Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.

+
+
+
+
+ +
Depotauswahl
+ +
Keine Depots vorhanden.
+ + + +
+ +
+ + +
Aktienauswahl
+ +
Keine Aktien im ausgewaehlten Depot.
+ + + +
+ +
+ + +
Marktdaten
+

Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.

+
+ +
+
+
+
+ +
+
+
Positionen
+
+
Aktien im aktuell gewaehlten Depot
+
+
+
Investiert
+
+
In Berichtswahrung bewertet
+
+
+
Aktueller Wert
+
+
Basierend auf dem letzten gespeicherten Kurs
+
+
+
Performance
+
+
+
+
+ +
+
+
Bester Wert
+ +
+
%
+ +
Noch keine Performance verfuegbar.
+ +
+ +
+
Schwaechster Wert
+ +
+
%
+ +
Noch keine Performance verfuegbar.
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+

Kursverlauf

+

Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.

+
+
+
+
+
+
Aktie
+
+ +
ยท
+ +
+
+ + + + + + + +
+
+
Chartdaten werden geladen...
+
-
+
+
+
+ +
+
+
+

Aktien im Depot

+

Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.

+
+
+
+ +
Keine Aktien im ausgewaehlten Depot.
+ +
+ + = 0) ? 'is-positive' : 'is-negative'; ?> +
+
+ +
ยท
+ +
+ +
+
+
Stueckzahl
+
+
+
+
Kaufpreis
+
+
+
+
Letzter Kurs
+
+
+
+
Performance
+
+ +
+
+
+ +
+ +
+
+
+ diff --git a/modules/boersenchecker/partials/instruments.php b/modules/boersenchecker/partials/instruments.php new file mode 100644 index 00000000..ba68905b --- /dev/null +++ b/modules/boersenchecker/partials/instruments.php @@ -0,0 +1,188 @@ + 'Aktienverwaltung', +]) ?> +
+ +
+ +
+ + +
+
+
+
+

Aktie waehlen

+

Systemweit vorhandene Aktie aus allen Depots auswaehlen.

+
+
+
+ + +
+
+ +
+
+
+

Wertpapiersuche

+

Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.

+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
SymbolNameRegionWaehrungAktion
+ + Uebernehmen + +
+ +
+
Noch keine Symbolsuche ausgefuehrt.
+
+ +
+
+ +
+
+
+

Aktie bearbeiten

+

Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.

+
+
+ +
Keine Aktie vorhanden.
+ +
+ + +
+ + + + + + +
+
+ +
+
+
+ + + +
+ +
+ +
+
+
+

Manuellen Kurs eingeben

+

Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.

+
+
+ +
Keine Aktie vorhanden.
+ +
+ + +
+ + + + +
+
+ +
+
+ +
+ +
+
+
+

Kursverlauf

+

Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.

+
+
+ +
+
Keine Kursdaten vorhanden.
+
+ + + + + + + + + + + + + + + + + + + + +
ZeitpunktKursQuelleAktion
+
+ + + + +
+
+ +
+
+ diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php new file mode 100644 index 00000000..325c3ec6 --- /dev/null +++ b/modules/boersenchecker/src/Support/DashboardPage.php @@ -0,0 +1,898 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + + $this->user = \auth_user() ?? []; + $this->isAdmin = \auth_is_admin(); + $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local')); + $this->availableOwners = $this->buildAvailableOwners(); + if ($this->isAdmin) { + $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? '')); + if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) { + $this->ownerSub = $requestedOwner; + } + } + $this->moduleSettings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR')); + $this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + + $this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($this->marketDataMinIntervalMinutes <= 0) { + $this->marketDataMinIntervalMinutes = 60; + } + + $this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0); + $this->editPositionId = (int) ($_GET['edit_position'] ?? 0); + + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->portfolioTable = $table('portfolios'); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + $this->instrumentRegistry = new InstrumentRegistry( + $this->pdo, + $this->instrumentTable, + $this->positionTable, + $this->quoteTable, + ); + } + + public function handle(): array + { + $notice = null; + $error = null; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $notice = $this->handlePost(); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $state = $this->loadState(); + + if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) { + $notice = $state['notice_override']; + } + if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) { + $error = $state['error_override']; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'isAdmin' => $this->isAdmin, + 'ownerSub' => $this->ownerSub, + 'availableOwners' => array_values($this->availableOwners), + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'fxMaxAgeHours' => $this->fxMaxAgeHours, + 'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes, + 'symbolSearchKeywords' => $this->symbolSearchKeywords, + 'symbolSearchResults' => $this->symbolSearchResults, + 'editPortfolio' => $state['editPortfolio'], + 'editPosition' => $state['editPosition'], + 'portfolios' => $state['portfolios'], + 'portfolioById' => $state['portfolioById'], + 'portfolioStats' => $state['portfolioStats'], + 'positions' => $state['positions'], + 'instrumentList' => $state['instrumentList'], + 'quoteHistory' => $state['quoteHistory'], + 'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'], + 'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'], + 'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale), + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + 'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'), + ]; + } + + private function handlePost(): string + { + $action = trim((string) ($_POST['action'] ?? '')); + + return match ($action) { + 'save_portfolio' => $this->savePortfolio(), + 'delete_portfolio' => $this->deletePortfolio(), + 'save_position' => $this->savePosition(), + 'delete_position' => $this->deletePosition(), + 'save_quote' => $this->saveQuote(), + 'refresh_market_data_position' => $this->refreshMarketDataPosition(), + 'refresh_market_data_all' => $this->refreshMarketDataAll(), + 'search_symbol' => $this->searchSymbol(), + 'delete_quote' => $this->deleteQuote(), + 'refresh_fx' => $this->refreshFx(), + default => '', + }; + } + + private function loadState(): array + { + $portfolios = $this->fetchPortfolios(); + $positions = $this->fetchPositions(); + $instrumentList = $this->buildInstrumentList($positions); + [$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList)); + $portfolioById = $this->buildPortfolioById($portfolios); + [$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes); + + $editPortfolio = null; + if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) { + $editPortfolio = $portfolioById[$this->editPortfolioId]; + } + + $editPosition = null; + if ($this->editPositionId > 0) { + foreach ($positions as $position) { + if ((int) $position['id'] === $this->editPositionId) { + $editPosition = $position; + break; + } + } + } + + $selectedInstrumentForQuote = $editPosition + ? (int) $editPosition['instrument_id'] + : (int) ($_GET['instrument_id'] ?? 0); + $selectedInstrumentQuoteCurrency = $this->defaultReportCurrency; + if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) { + $selectedInstrumentQuoteCurrency = $this->normalizeCurrency( + (string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency) + ); + } + + $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? '')); + $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? '')); + $candidateIsin = trim((string) ($_GET['isin_candidate'] ?? '')); + $candidateMarket = trim((string) ($_GET['market_candidate'] ?? '')); + $candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency)); + + if ($editPosition === null) { + $editPosition = [ + 'instrument_name' => $candidateName, + 'symbol' => $candidateSymbol, + 'isin' => $candidateIsin, + 'market' => $candidateMarket, + 'quote_currency' => $candidateCurrency, + 'purchase_currency' => $this->defaultReportCurrency, + 'purchase_date' => date('Y-m-d'), + ]; + } else { + if ($candidateName !== '') { + $editPosition['instrument_name'] = $candidateName; + } + if ($candidateSymbol !== '') { + $editPosition['symbol'] = $candidateSymbol; + } + if ($candidateIsin !== '') { + $editPosition['isin'] = $candidateIsin; + } + if ($candidateMarket !== '') { + $editPosition['market'] = $candidateMarket; + } + if ($candidateCurrency !== '') { + $editPosition['quote_currency'] = $candidateCurrency; + } + } + + return [ + 'portfolios' => $portfolios, + 'portfolioById' => $portfolioById, + 'portfolioStats' => $portfolioStats, + 'positions' => $positions, + 'instrumentList' => $instrumentList, + 'quoteHistory' => $quoteHistory, + 'editPortfolio' => $editPortfolio, + 'editPosition' => $editPosition, + 'selectedInstrumentForQuote' => $selectedInstrumentForQuote, + 'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency, + ]; + } + + private function savePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $name = trim((string) ($_POST['portfolio_name'] ?? '')); + $baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency)); + $notes = trim((string) ($_POST['portfolio_notes'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Bitte einen Depotnamen angeben.'); + } + + if ($portfolioId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->portfolioTable . ' + SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes) + VALUES (:owner_sub, :name, :base_currency, :notes)' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot angelegt.'; + } + + private function deletePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub'); + $countStmt->execute([ + 'portfolio_id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ((int) $countStmt->fetchColumn() > 0) { + throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.'); + } + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Depot geloescht.'; + } + + private function savePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $quantity = (float) ($_POST['quantity'] ?? 0); + $purchasePrice = (float) ($_POST['purchase_price'] ?? 0); + $purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency)); + $purchaseDate = trim((string) ($_POST['purchase_date'] ?? '')); + $fees = trim((string) ($_POST['fees'] ?? '')); + $notes = trim((string) ($_POST['position_notes'] ?? '')); + + if ($portfolioId <= 0) { + throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); + } + if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') { + throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.'); + } + + $portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1'); + $portfolioOwnerStmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ($portfolioOwnerStmt->fetchColumn() === false) { + throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.'); + } + + $instrumentId = $this->upsertInstrument([ + 'id' => (int) ($_POST['instrument_id'] ?? 0), + 'isin' => $_POST['isin'] ?? '', + 'wkn' => $_POST['wkn'] ?? '', + 'symbol' => $_POST['symbol'] ?? '', + 'name' => $_POST['instrument_name'] ?? '', + 'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency, + 'market' => $_POST['market'] ?? '', + ]); + + $payload = [ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + 'instrument_id' => $instrumentId, + 'quantity' => $quantity, + 'purchase_price' => $purchasePrice, + 'purchase_currency' => $purchaseCurrency, + 'purchase_date' => $purchaseDate, + 'fees' => $fees !== '' ? (float) $fees : null, + 'notes' => $notes !== '' ? $notes : null, + ]; + + if ($positionId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->positionTable . ' + SET portfolio_id = :portfolio_id, + instrument_id = :instrument_id, + quantity = :quantity, + purchase_price = :purchase_price, + purchase_currency = :purchase_currency, + purchase_date = :purchase_date, + fees = :fees, + notes = :notes, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute($payload + ['id' => $positionId]); + return 'Position aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->positionTable . ' ( + owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes + ) VALUES ( + :owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes + )' + ); + $stmt->execute($payload); + return 'Position gespeichert.'; + } + + private function deletePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Position geloescht.'; + } + + private function saveQuote(): string + { + $instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0); + $price = (float) ($_POST['quote_price'] ?? 0); + $currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency)); + $quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')); + $source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual'; + + if ($instrumentId <= 0 || $price <= 0) { + throw new RuntimeException('Bitte Aktie und Kurs angeben.'); + } + + $this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source); + return 'Kurs gespeichert.'; + } + + private function refreshMarketDataPosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'SELECT + p.instrument_id, + i.name AS instrument_name, + i.symbol, + i.isin, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' + ); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + throw new RuntimeException('Position nicht gefunden.'); + } + + $instrumentId = (int) $row['instrument_id']; + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.'); + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.'; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; + } + return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + } + + private function refreshMarketDataAll(): string + { + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.isin, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY i.name ASC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.'); + } + + $fetched = 0; + $reused = 0; + $stale = 0; + $skipped = 0; + $failed = 0; + $errors = []; + + $bulkRows = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + $skipped++; + continue; + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $bulkRows[] = $row; + } + + if ($bulkRows !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkRows + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows); + if (empty($bulkResult['ok'])) { + throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.')); + } + + $bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + foreach ($bulkRows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + $apiResult = $bulkQuotes[$instrumentId] ?? null; + if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) { + $failed++; + $errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.'; + continue; + } + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $stale++; + continue; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $fetched++; + } else { + $reused++; + } + } + } + + if ($errors !== []) { + throw new RuntimeException( + 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. ' + . implode(' | ', array_slice($errors, 0, 3)) + ); + } + + return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.'; + } + + private function searchSymbol(): string + { + $keywords = trim((string) ($_POST['search_keywords'] ?? '')); + $this->symbolSearchKeywords = $keywords; + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords); + $this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : []; + + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); + } + + return (string) ($result['message'] ?? 'Suche abgeschlossen.'); + } + + private function deleteQuote(): string + { + $quoteId = (int) ($_POST['quote_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->quoteTable . ' + WHERE id = :id + AND instrument_id IN ( + SELECT DISTINCT instrument_id + FROM ' . $this->positionTable . ' + WHERE owner_sub = :owner_sub + )' + ); + $stmt->execute([ + 'id' => $quoteId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Kurseintrag geloescht.'; + } + + private function refreshFx(): string + { + $result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours); + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + return (string) ($result['message'] ?? 'FX-Daten aktualisiert.'); + } + + private function fetchPortfolios(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC'); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchPositions(): array + { + $stmt = $this->pdo->prepare( + 'SELECT + p.*, + i.isin, + i.wkn, + i.symbol, + i.name AS instrument_name, + i.quote_currency, + i.market + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY p.purchase_date DESC, p.id DESC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function buildInstrumentList(array $positions): array + { + $instrumentList = []; + foreach ($positions as $position) { + $instrumentId = (int) $position['instrument_id']; + if (!isset($instrumentList[$instrumentId])) { + $instrumentList[$instrumentId] = [ + 'id' => $instrumentId, + 'name' => (string) $position['instrument_name'], + 'symbol' => (string) ($position['symbol'] ?? ''), + 'isin' => (string) ($position['isin'] ?? ''), + 'quote_currency' => (string) ($position['quote_currency'] ?? ''), + ]; + } + } + return $instrumentList; + } + + private function fetchQuotes(array $instrumentIds): array + { + $quoteHistory = []; + $latestQuotes = []; + if ($instrumentIds === []) { + return [$quoteHistory, $latestQuotes]; + } + + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $stmt->execute($instrumentIds); + $quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($quotes as $quote) { + $instrumentId = (int) $quote['instrument_id']; + $quoteHistory[$instrumentId][] = $quote; + if (!isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $quote; + } + } + + return [$quoteHistory, $latestQuotes]; + } + + private function buildPortfolioById(array $portfolios): array + { + $portfolioById = []; + foreach ($portfolios as $portfolio) { + $portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency)); + $portfolioById[(int) $portfolio['id']] = $portfolio; + } + return $portfolioById; + } + + private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array + { + $portfolioStats = []; + foreach ($portfolioById as $portfolioId => $portfolio) { + $portfolioStats[$portfolioId] = [ + 'invested' => 0.0, + 'current' => 0.0, + 'gain' => 0.0, + 'positions' => 0, + 'has_invested' => false, + 'has_current' => false, + ]; + } + + foreach ($positions as &$position) { + $portfolioId = (int) $position['portfolio_id']; + $baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency); + $quantity = (float) $position['quantity']; + $purchasePrice = (float) $position['purchase_price']; + $fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0; + $purchaseTotal = ($quantity * $purchasePrice) + $fees; + $purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency); + $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; + $currentTotalBase = null; + + if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) { + $currentOriginal = $quantity * (float) $latestQuote['price']; + $currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? '')); + $position['latest_price'] = (float) $latestQuote['price']; + $position['latest_currency'] = (string) $latestQuote['currency']; + $position['latest_quoted_at'] = (string) $latestQuote['quoted_at']; + $position['latest_source'] = (string) ($latestQuote['source'] ?? ''); + $position['current_total_base'] = $currentTotalBase; + } else { + $position['latest_price'] = null; + $position['latest_currency'] = null; + $position['latest_quoted_at'] = null; + $position['latest_source'] = null; + $position['current_total_base'] = null; + } + + $position['purchase_total'] = $purchaseTotal; + $position['purchase_total_base'] = $purchaseTotalBase; + $position['base_currency'] = $baseCurrency; + $position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null + ? $currentTotalBase - $purchaseTotalBase + : null; + + if (isset($portfolioStats[$portfolioId])) { + $portfolioStats[$portfolioId]['positions']++; + if ($purchaseTotalBase !== null) { + $portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase; + $portfolioStats[$portfolioId]['has_invested'] = true; + } + if ($currentTotalBase !== null) { + $portfolioStats[$portfolioId]['current'] += $currentTotalBase; + $portfolioStats[$portfolioId]['has_current'] = true; + } + } + } + unset($position); + + foreach ($portfolioStats as &$stats) { + $stats['gain'] = ($stats['has_invested'] && $stats['has_current']) + ? $stats['current'] - $stats['invested'] + : null; + } + unset($stats); + + return [$positions, $portfolioStats]; + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + + private function upsertInstrument(array $payload): int + { + return $this->instrumentRegistry->save($payload); + } + + private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + } + + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float + { + if ($amount === null) { + return null; + } + + $from = $this->normalizeCurrency($from); + $to = $this->normalizeCurrency($to); + if ($from === $to) { + return $amount; + } + + try { + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string + { + $normalized = strtoupper(trim((string) $value)); + return $normalized !== '' ? $normalized : $fallback; + } + + private function normalizeDateTimeLocal(?string $value): string + { + $timezone = new \DateTimeZone(nexus_display_timezone_name()); + $value = trim((string) $value); + if ($value === '') { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->format('Y-m-d H:i:s'); + } + + try { + return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + } + + private function formatNumber(?float $value, int $scale = 2): string + { + if ($value === null) { + return 'n/a'; + } + + return number_format($value, $scale, ',', '.'); + } + + private function buildAvailableOwners(): array + { + $owners = []; + $currentSub = trim((string) ($this->user['sub'] ?? 'local')); + $owners[$currentSub] = [ + 'sub' => $currentSub, + 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub, + ]; + + if (!$this->isAdmin) { + return $owners; + } + + foreach (\modules()->knownAuthUsers() as $knownUser) { + $sub = trim((string) ($knownUser['sub'] ?? '')); + if ($sub === '') { + continue; + } + $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub)); + $owners[$sub] = [ + 'sub' => $sub, + 'label' => $label !== '' ? $label : $sub, + ]; + } + + uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label'])); + return $owners; + } +} diff --git a/modules/boersenchecker/src/Support/HomePage.php b/modules/boersenchecker/src/Support/HomePage.php new file mode 100644 index 00000000..f4fa0a50 --- /dev/null +++ b/modules/boersenchecker/src/Support/HomePage.php @@ -0,0 +1,364 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + $user = \auth_user() ?? []; + $this->ownerSub = trim((string) ($user['sub'] ?? 'local')); + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + $this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($this->marketDataMinIntervalMinutes <= 0) { + $this->marketDataMinIntervalMinutes = 60; + } + + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->portfolioTable = $table('portfolios'); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + } + + public function handle(): array + { + $notice = null; + $error = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') { + try { + $notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0)); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $portfolios = $this->fetchPortfolios(); + $selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0)); + if ($selectedPortfolioId <= 0 && $portfolios !== []) { + $selectedPortfolioId = (int) $portfolios[0]['id']; + } + + $positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : []; + $selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0); + if ($selectedInstrumentId <= 0 && $positions !== []) { + $selectedInstrumentId = (int) $positions[0]['instrument_id']; + } + + $latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions)))); + foreach ($positions as &$position) { + $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; + $position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null; + $position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : ''; + $position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : ''; + $position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : ''; + $position['current_total_report'] = null; + $position['gain_report'] = null; + $position['gain_percent'] = null; + if ($position['latest_price'] !== null) { + $currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0); + $currentReport = $this->convertAmount( + $currentNative, + (string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)), + $this->defaultReportCurrency, + (string) ($position['latest_source'] ?? '') + ); + $position['current_total_report'] = $currentReport; + if ($position['purchase_total_report'] !== null && $currentReport !== null) { + $gain = $currentReport - (float) $position['purchase_total_report']; + $position['gain_report'] = $gain; + $base = (float) $position['purchase_total_report']; + $position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null; + } + } + } + unset($position); + + $selectedInstrument = null; + foreach ($positions as $position) { + if ((int) $position['instrument_id'] === $selectedInstrumentId) { + $selectedInstrument = $position; + break; + } + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'portfolios' => $portfolios, + 'selectedPortfolioId' => $selectedPortfolioId, + 'positions' => $positions, + 'selectedInstrumentId' => $selectedInstrumentId, + 'selectedInstrument' => $selectedInstrument, + 'summary' => $this->buildSummary($positions), + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'chartEndpoint' => '/api/boersenchecker/index.php?path=v1/chart-data', + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + ]; + } + + private function refreshCurrentQuotesForPortfolio(int $portfolioId): string + { + if ($portfolioId <= 0) { + throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + ]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.'); + } + + $updated = 0; + $reused = 0; + $stale = 0; + $bulkCandidates = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + continue; + } + + $latest = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $bulkCandidates[] = $row; + } + + if ($bulkCandidates !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkCandidates + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates); + $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + foreach ($bulkCandidates as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quote = $quotes[$instrumentId] ?? null; + if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { + continue; + } + $latest = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) { + $stale++; + continue; + } + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $quote['price'], + strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $updated++; + } else { + $reused++; + } + } + } + + return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.'; + } + + private function fetchPortfolios(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC'); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchPortfolioPositions(int $portfolioId): array + { + $stmt = $this->pdo->prepare( + 'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id + ORDER BY i.name ASC' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + ]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as &$row) { + $quantity = (float) ($row['quantity'] ?? 0); + $purchasePrice = (float) ($row['purchase_price'] ?? 0); + $fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0; + $purchaseTotal = ($quantity * $purchasePrice) + $fees; + $row['purchase_total'] = $purchaseTotal; + $row['purchase_total_report'] = $this->convertAmount( + $purchaseTotal, + (string) ($row['purchase_currency'] ?? $this->defaultReportCurrency), + $this->defaultReportCurrency + ); + } + unset($row); + return $rows; + } + + private function fetchLatestQuotes(array $instrumentIds): array + { + $result = []; + if ($instrumentIds === []) { + return $result; + } + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $stmt->execute($instrumentIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $instrumentId = (int) $row['instrument_id']; + if (!isset($result[$instrumentId])) { + $result[$instrumentId] = $row; + } + } + return $result; + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + + private function buildSummary(array $positions): array + { + $invested = 0.0; + $current = 0.0; + $hasInvested = false; + $hasCurrent = false; + $best = null; + $worst = null; + + foreach ($positions as $position) { + if (is_numeric($position['purchase_total_report'] ?? null)) { + $invested += (float) $position['purchase_total_report']; + $hasInvested = true; + } + if (is_numeric($position['current_total_report'] ?? null)) { + $current += (float) $position['current_total_report']; + $hasCurrent = true; + } + if (is_numeric($position['gain_percent'] ?? null)) { + if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) { + $best = $position; + } + if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) { + $worst = $position; + } + } + } + + return [ + 'positions' => count($positions), + 'invested' => $hasInvested ? $invested : null, + 'current' => $hasCurrent ? $current : null, + 'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null, + 'best' => $best, + 'worst' => $worst, + ]; + } + + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float + { + if ($amount === null) { + return null; + } + + $from = $this->normalizeCurrency($from, $this->defaultReportCurrency); + $to = $this->normalizeCurrency($to, $this->defaultReportCurrency); + if ($from === $to) { + return $amount; + } + + try { + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string + { + $normalized = strtoupper(trim((string) $value)); + return $normalized !== '' ? $normalized : $fallback; + } +} diff --git a/modules/boersenchecker/src/Support/InstrumentPage.php b/modules/boersenchecker/src/Support/InstrumentPage.php new file mode 100644 index 00000000..4a65ccbe --- /dev/null +++ b/modules/boersenchecker/src/Support/InstrumentPage.php @@ -0,0 +1,351 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + $user = \auth_user() ?? []; + $this->ownerSub = trim((string) ($user['sub'] ?? 'local')); + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + $this->instrumentRegistry = new InstrumentRegistry( + $this->pdo, + $this->instrumentTable, + $this->positionTable, + $this->quoteTable, + ); + } + + public function handle(): array + { + $notice = null; + $error = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $notice = $this->handlePost(); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $instruments = $this->fetchInstruments(); + $selectedInstrumentId = $this->selectedInstrumentOverrideId > 0 + ? $this->selectedInstrumentOverrideId + : (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0)); + if ($selectedInstrumentId <= 0 && $instruments !== []) { + $selectedInstrumentId = (int) $instruments[0]['id']; + } + + $selectedInstrument = null; + foreach ($instruments as $instrument) { + if ((int) $instrument['id'] === $selectedInstrumentId) { + $selectedInstrument = $instrument; + break; + } + } + + $quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : []; + + $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? '')); + $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? '')); + $candidateIsin = trim((string) ($_GET['isin_candidate'] ?? '')); + $candidateMarket = trim((string) ($_GET['market_candidate'] ?? '')); + $candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; + if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) { + $selectedInstrument = [ + 'id' => 0, + 'name' => $candidateName, + 'symbol' => $candidateSymbol, + 'isin' => $candidateIsin, + 'market' => $candidateMarket, + 'quote_currency' => $candidateCurrency, + 'wkn' => '', + ]; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'instruments' => $instruments, + 'selectedInstrument' => $selectedInstrument, + 'selectedInstrumentId' => $selectedInstrumentId, + 'quotes' => $quotes, + 'searchKeywords' => $this->searchKeywords, + 'searchResults' => $this->searchResults, + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + 'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'), + ]; + } + + private function handlePost(): string + { + $action = trim((string) ($_POST['action'] ?? '')); + return match ($action) { + 'save_instrument' => $this->saveInstrument(), + 'save_quote' => $this->saveQuote(), + 'delete_quote' => $this->deleteQuote(), + 'refresh_market_data_instrument' => $this->refreshInstrumentQuote(), + 'search_symbol' => $this->searchSymbol(), + default => '', + }; + } + + private function fetchInstruments(): array + { + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.* + FROM ' . $this->instrumentTable . ' i + INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id + WHERE p.owner_sub = :owner_sub + ORDER BY i.name ASC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchQuotes(int $instrumentId): array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 30' + ); + $stmt->execute(['instrument_id' => $instrumentId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function saveInstrument(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + if ($instrumentId <= 0) { + throw new RuntimeException('Bitte eine Aktie auswaehlen.'); + } + $this->assertInstrumentAccessible($instrumentId); + + $resolvedId = $this->instrumentRegistry->save([ + 'id' => $instrumentId, + 'name' => $_POST['instrument_name'] ?? '', + 'symbol' => $_POST['symbol'] ?? '', + 'isin' => $_POST['isin'] ?? '', + 'wkn' => $_POST['wkn'] ?? '', + 'market' => $_POST['market'] ?? '', + 'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency, + ]); + $this->selectedInstrumentOverrideId = $resolvedId; + + return $resolvedId === $instrumentId + ? 'Aktie aktualisiert.' + : 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.'; + } + + private function saveQuote(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + $price = (float) ($_POST['quote_price'] ?? 0); + if ($instrumentId <= 0 || $price <= 0) { + throw new RuntimeException('Bitte Aktie und Kurs angeben.'); + } + $this->assertInstrumentAccessible($instrumentId); + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + 'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')), + 'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual', + ]); + return 'Kurs gespeichert.'; + } + + private function deleteQuote(): string + { + $quoteId = (int) ($_POST['quote_id'] ?? 0); + if ($quoteId <= 0) { + throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT q.instrument_id + FROM ' . $this->quoteTable . ' q + WHERE q.id = :id + LIMIT 1' + ); + $stmt->execute(['id' => $quoteId]); + $instrumentId = (int) $stmt->fetchColumn(); + $this->assertInstrumentAccessible($instrumentId); + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id'); + $stmt->execute(['id' => $quoteId]); + return 'Kurs geloescht.'; + } + + private function refreshInstrumentQuote(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + $instrument = $this->assertInstrumentAccessible($instrumentId); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.'); + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + $quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.'; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + + return !empty($storeResult['inserted']) + ? 'Alpha-Vantage-Kurs gespeichert.' + : 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.'; + } + + private function searchSymbol(): string + { + $this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? '')); + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords); + $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : []; + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); + } + return (string) ($result['message'] ?? 'Suche abgeschlossen.'); + } + + private function assertInstrumentAccessible(int $instrumentId): array + { + if ($instrumentId <= 0) { + throw new RuntimeException('Aktie nicht gefunden.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.* + FROM ' . $this->instrumentTable . ' i + INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id + WHERE i.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' + ); + $stmt->execute([ + 'id' => $instrumentId, + 'owner_sub' => $this->ownerSub, + ]); + $instrument = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($instrument)) { + throw new RuntimeException('Aktie ist nicht verfuegbar.'); + } + + return $instrument; + } + + private function normalizeDateTimeLocal(?string $value): string + { + $timezone = new \DateTimeZone(nexus_display_timezone_name()); + $value = trim((string) $value); + if ($value === '') { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->format('Y-m-d H:i:s'); + } + + try { + return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } +} diff --git a/modules/boersenchecker/src/Support/InstrumentRegistry.php b/modules/boersenchecker/src/Support/InstrumentRegistry.php new file mode 100644 index 00000000..fc8f4718 --- /dev/null +++ b/modules/boersenchecker/src/Support/InstrumentRegistry.php @@ -0,0 +1,190 @@ +normalizePayload($payload); + $matchingId = $this->findMatchingInstrumentId($data, $currentId); + + if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) { + return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data); + } + + if ($currentId > 0) { + $this->updateInstrument($currentId, $data); + return $currentId; + } + + if ($matchingId > 0) { + $this->updateInstrument($matchingId, $data); + return $matchingId; + } + + return $this->insertInstrument($data); + } + + public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int + { + $data = $this->normalizePayload($payload); + $conditions = []; + $excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : ''; + + if ($data['isin'] !== null) { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1', + 'params' => ['isin' => $data['isin']], + ]; + } + + if ($data['symbol'] !== null && $data['market'] !== null) { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1', + 'params' => ['symbol' => $data['symbol'], 'market' => $data['market']], + ]; + } + + if ($data['symbol'] !== null && $data['name'] !== '') { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1', + 'params' => ['symbol' => $data['symbol'], 'name' => $data['name']], + ]; + } + + foreach ($conditions as $condition) { + $params = $condition['params']; + if ($excludeId > 0) { + $params['exclude_id'] = $excludeId; + } + + $stmt = $this->pdo->prepare($condition['sql']); + $stmt->execute($params); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + return null; + } + + private function normalizePayload(array $payload): array + { + $data = [ + 'isin' => $this->normalizeUpper($payload['isin'] ?? null), + 'wkn' => $this->normalizeUpper($payload['wkn'] ?? null), + 'symbol' => $this->normalizeUpper($payload['symbol'] ?? null), + 'name' => trim((string) ($payload['name'] ?? '')), + 'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'), + 'market' => trim((string) ($payload['market'] ?? '')) ?: null, + ]; + + if ($data['name'] === '') { + throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.'); + } + + return $data; + } + + private function normalizeUpper(mixed $value, string $fallback = ''): ?string + { + $normalized = strtoupper(trim((string) $value)); + if ($normalized !== '') { + return $normalized; + } + + return $fallback !== '' ? $fallback : null; + } + + private function updateInstrument(int $instrumentId, array $data): void + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->instrumentTable . ' + SET isin = :isin, + wkn = :wkn, + symbol = :symbol, + name = :name, + quote_currency = :quote_currency, + market = :market, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id' + ); + $stmt->execute($data + ['id' => $instrumentId]); + } + + private function insertInstrument(array $data): int + { + $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market) + RETURNING id' + ); + $stmt->execute($data); + return (int) $stmt->fetchColumn(); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)' + ); + $stmt->execute($data); + return (int) $this->pdo->lastInsertId(); + } + + private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int + { + $this->pdo->beginTransaction(); + try { + $this->updateInstrument($targetId, $data); + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->positionTable . ' + SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP + WHERE instrument_id = :source_id' + ); + $stmt->execute([ + 'target_id' => $targetId, + 'source_id' => $sourceId, + ]); + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->quoteTable . ' + SET instrument_id = :target_id + WHERE instrument_id = :source_id' + ); + $stmt->execute([ + 'target_id' => $targetId, + 'source_id' => $sourceId, + ]); + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id'); + $stmt->execute(['id' => $sourceId]); + + $this->pdo->commit(); + } catch (\Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + + return $targetId; + } +} diff --git a/modules/fx-rates/bootstrap.php b/modules/fx-rates/bootstrap.php index bacd6fe2..1d09f952 100644 --- a/modules/fx-rates/bootstrap.php +++ b/modules/fx-rates/bootstrap.php @@ -13,3 +13,30 @@ spl_autoload_register(static function (string $class): void { require_once $path; } }); + +$moduleName = 'fx-rates'; +$moduleBasePath = __DIR__; + +if (method_exists(modules(), 'registerFunction')) { + modules()->registerFunction($moduleName, 'service', static function () use ($moduleBasePath): \Modules\FxRates\Domain\FxRatesService { + static $service = null; + + if ($service instanceof \Modules\FxRates\Domain\FxRatesService) { + return $service; + } + + $moduleConfig = \Modules\FxRates\Infrastructure\ModuleConfig::load($moduleBasePath); + $settingsStore = new \Modules\FxRates\Infrastructure\SettingsStore($moduleConfig); + $settings = $settingsStore->load(); + $pdo = \Modules\FxRates\Infrastructure\ConnectionFactory::make($settingsStore); + $repository = new \Modules\FxRates\Infrastructure\FxRatesRepository( + $pdo, + $moduleConfig->tablePrefix() + ); + $repository->ensureSchema(); + + $service = new \Modules\FxRates\Domain\FxRatesService($repository, $settings); + + return $service; + }); +} diff --git a/public/api/boersenchecker/index.php b/public/api/boersenchecker/index.php new file mode 100644 index 00000000..3783b906 --- /dev/null +++ b/public/api/boersenchecker/index.php @@ -0,0 +1,5 @@ +auth; } + + public function basePdo(): ?\PDO + { + if ($this->basePdo instanceof \PDO) { + return $this->basePdo; + } + + $db = $this->config()->dbConfig; + if ($db === []) { + return null; + } + + $db['options'] ??= [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ]; + + $this->basePdo = PdoFactory::createFromArray($db); + + return $this->basePdo; + } } final class LegacyCompatConfig @@ -123,6 +146,10 @@ namespace ModulesCore { { /** @var array> */ private array $settingsCache = []; + /** @var array */ + private array $callbacks = []; + /** @var array */ + private array $booted = []; public function __construct( private readonly string $projectRoot, @@ -136,11 +163,20 @@ namespace ModulesCore { public function hasFunction(string $moduleName, string $functionName): bool { - return false; + $this->ensureBooted($moduleName); + + return isset($this->callbacks[$moduleName . ':' . $functionName]); } public function call(string $moduleName, string $functionName, mixed ...$args): mixed { + $this->ensureBooted($moduleName); + + $key = $moduleName . ':' . $functionName; + if (isset($this->callbacks[$key])) { + return ($this->callbacks[$key])(...$args); + } + throw new RuntimeException(sprintf( 'Modulfunktion %s::%s ist in dieser Laufzeit nicht registriert.', $moduleName, @@ -148,6 +184,11 @@ namespace ModulesCore { )); } + public function registerFunction(string $moduleName, string $functionName, callable $callable): void + { + $this->callbacks[$moduleName . ':' . $functionName] = $callable; + } + /** * @return array */ @@ -158,19 +199,97 @@ namespace ModulesCore { } $configPath = $this->modulePath($moduleName) . '/config/module.php'; - if (!is_file($configPath)) { - return $this->settingsCache[$moduleName] = []; + $config = is_file($configPath) ? require $configPath : []; + $defaults = is_array($config) ? $config : []; + + $storedSettingsPath = $this->storedSettingsPath($moduleName); + if (!is_file($storedSettingsPath)) { + return $this->settingsCache[$moduleName] = $defaults; } - $config = require $configPath; + $raw = file_get_contents($storedSettingsPath); + $stored = is_string($raw) && trim($raw) !== '' ? json_decode($raw, true) : []; - return $this->settingsCache[$moduleName] = is_array($config) ? $config : []; + return $this->settingsCache[$moduleName] = $this->mergeSettings( + $defaults, + is_array($stored) ? $stored : [] + ); + } + + public function saveSettings(string $moduleName, array $settings): void + { + $path = $this->storedSettingsPath($moduleName); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + + file_put_contents( + $path, + json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) + ); + + unset($this->settingsCache[$moduleName]); + } + + public function modulePdo(string $moduleName, array $fallback = []): ?\PDO + { + $settings = $this->settings($moduleName); + $db = $settings['db'] ?? $fallback; + if (!is_array($db) || $db === []) { + throw new RuntimeException('Modul-Datenbank nicht konfiguriert.'); + } + + $db['options'] ??= [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ]; + + return PdoFactory::createFromArray($db); } private function modulePath(string $moduleName): string { return $this->projectRoot . '/modules/' . trim($moduleName, '/'); } + + private function ensureBooted(string $moduleName): void + { + if (isset($this->booted[$moduleName])) { + return; + } + + $this->booted[$moduleName] = true; + $bootstrapPath = $this->modulePath($moduleName) . '/bootstrap.php'; + if (is_file($bootstrapPath)) { + $modules = $this; + require_once $bootstrapPath; + } + } + + private function storedSettingsPath(string $moduleName): string + { + return $this->projectRoot . '/data/module-settings/' . trim($moduleName, '/') . '.json'; + } + + /** + * @param array $defaults + * @param array $stored + * @return array + */ + private function mergeSettings(array $defaults, array $stored): array + { + foreach ($stored as $key => $value) { + if (is_array($value) && isset($defaults[$key]) && is_array($defaults[$key])) { + $defaults[$key] = $this->mergeSettings($defaults[$key], $value); + continue; + } + + $defaults[$key] = $value; + } + + return $defaults; + } } } @@ -217,4 +336,186 @@ namespace { return $timezone !== '' ? $timezone : 'Europe/Berlin'; } } + + if (!function_exists('auth_enabled')) { + function auth_enabled(): bool + { + return app()->auth()->isEnabled(); + } + } + + if (!function_exists('auth_user')) { + function auth_user(): ?array + { + return app()->auth()->user(); + } + } + + if (!function_exists('auth_groups')) { + function auth_groups(): array + { + $user = auth_user(); + + return is_array($user['groups'] ?? null) ? array_values($user['groups']) : []; + } + } + + if (!function_exists('auth_is_admin')) { + function auth_is_admin(): bool + { + return in_array('administrators', auth_groups(), true); + } + } + + if (!function_exists('require_auth')) { + function require_auth(): void + { + if (auth_user() !== null) { + return; + } + + header('Location: /auth/keycloak', true, 302); + exit; + } + } + + if (!function_exists('module_design')) { + function module_design(string $module): array + { + if (preg_match('/[^a-zA-Z0-9_\-]/', $module)) { + return []; + } + + $path = dirname(__DIR__, 2) . '/modules/' . $module . '/design.json'; + if (!is_file($path)) { + return []; + } + + $raw = file_get_contents($path); + $decoded = is_string($raw) && trim($raw) !== '' ? json_decode($raw, true) : null; + + return is_array($decoded) ? $decoded : []; + } + } + + if (!function_exists('e')) { + function e(?string $string): string + { + return htmlspecialchars($string ?? '', ENT_QUOTES, 'UTF-8'); + } + } + + if (!function_exists('module_shell_header')) { + function module_shell_header(string $module, array $options = []): string + { + $design = module_design($module); + $requestPath = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ''); + $requestQuery = []; + parse_str((string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_QUERY) ?: ''), $requestQuery); + $title = trim((string) ($options['title'] ?? '')); + $description = trim((string) ($options['description'] ?? ($design['description'] ?? ''))); + $eyebrow = trim((string) ($options['eyebrow'] ?? $design['eyebrow'] ?? 'Modul')); + $actions = is_array($options['actions'] ?? null) ? $options['actions'] : (is_array($design['actions'] ?? null) ? $design['actions'] : []); + $tabs = is_array($options['tabs'] ?? null) ? $options['tabs'] : (is_array($design['tabs'] ?? null) ? $design['tabs'] : []); + + $html = '
'; + $html .= ''; + + return $html; + } + } + + if (!function_exists('module_shell_footer')) { + function module_shell_footer(): string + { + return '
'; + } + } + + if (!function_exists('module_tpl')) { + function module_tpl(string $module, string $name, array $data = []): void + { + $path = dirname(__DIR__, 2) . '/modules/' . $module . '/partials/' . $name . '.php'; + if (!is_file($path)) { + echo ''; + return; + } + + extract($data); + require $path; + } + } + + if (!function_exists('module_debug_push')) { + function module_debug_push(string $source, array $entry): void + { + if (!auth_is_admin()) { + return; + } + + if (!isset($_SESSION['desktop_debug_server']) || !is_array($_SESSION['desktop_debug_server'])) { + $_SESSION['desktop_debug_server'] = []; + } + + $entry['source'] = $entry['source'] ?? $source; + $entry['time'] = $entry['time'] ?? date(DATE_ATOM); + array_unshift($_SESSION['desktop_debug_server'], $entry); + $_SESSION['desktop_debug_server'] = array_slice($_SESSION['desktop_debug_server'], 0, 250); + } + } }