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.
+
+
+
+
+
+ = module_shell_footer() ?>
+
+
+
+
+
+ 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 @@
+
+= module_shell_header('boersenchecker', [
+ 'title' => 'Depotverwaltung',
+]) ?>
+
+
+
+
+
+
+
+
+
+
+
+
Benutzer-Scope
+
Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.
+
+
+
+
+
+
+
+
+
+
+
= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?>
+
Stammdaten und Berichtswahrung fuer ein Depot pflegen.
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+ FX-Daten aktualisieren
+
+
+
+
+ Alle API-Kurse abrufen
+
+
+
+ Alpha Vantage Mindestabstand: = e((string) $marketDataMinIntervalMinutes) ?> Min.
+
+
+
+ FX-Provider, API-Key und Waehrungskatalog werden im Modul
fx-rates gepflegt.
+
+
+ Standard-Berichtswahrung: = e($defaultReportCurrency) ?> · Max. Alter: = e((string) $fxMaxAgeHours) ?>h
+
+
+
+
+
+
+
+
+
+
Wertpapiersuche
+
Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.
+
+
+
+
+
+
+
+ Suchbegriff
+
+
+ Suchen
+
+
+
+
+
+
+ Symbol
+ Name
+ Typ
+ Region
+ Waehrung
+ Match
+ Aktion
+
+
+
+
+
+ = e((string) ($result['symbol'] ?? '')) ?>
+ = e((string) ($result['name'] ?? '')) ?>
+ = e((string) ($result['type'] ?? '')) ?>
+ = e((string) ($result['region'] ?? '')) ?>
+ = e((string) ($result['currency'] ?? '')) ?>
+ = e((string) ($result['match_score'] ?? '')) ?>
+
+
+ In Formular uebernehmen
+
+
+
+
+
+
+
+
+
Noch keine Symbolsuche ausgefuehrt.
+
+
+
+
+
+
+
+
+
+
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];
+ ?>
+
+
+
+
= e((string) $portfolio['name']) ?>
+
= e((string) $portfolio['base_currency']) ?> · = e((string) $stats['positions']) ?> Position(en)
+
+
+
+
+ = e((string) $portfolio['notes']) ?>
+
+
+
+
Investiert
+
= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?>
+
+
+
Aktuell
+
= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?>
+
+
+
Gewinn / Verlust
+
= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?>
+
+
+
+
+
+
+
+
+
+
+
+
Positionen
+
Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.
+
+
+
+
+
Noch keine Positionen vorhanden.
+
+
+
+
+
+ Depot
+ Aktie
+ ISIN / WKN
+ Stueck
+ Kauf
+ Letzter Kurs
+ Wert
+ Delta
+ Aktion
+
+
+
+
+
+ = e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?>
+
+ = e((string) $position['instrument_name']) ?>
+
+ = e((string) $position['symbol']) ?>
+
+
+
+ = e((string) ($position['isin'] ?: '-')) ?>
+ = e((string) ($position['wkn'] ?: '-')) ?>
+
+ = e($fmtNumber((float) $position['quantity'], 6)) ?>
+
+ = e($fmtNumber((float) $position['purchase_price'], 4)) ?> = e((string) $position['purchase_currency']) ?>
+ = e((string) $position['purchase_date']) ?>
+
+
+
+ = e($fmtNumber((float) $position['latest_price'], 4)) ?> = e((string) $position['latest_currency']) ?>
+ = e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?>
+
+ kein Kurs
+
+
+
+
+ = e($fmtNumber((float) $position['current_total_base'])) ?> = e((string) $position['base_currency']) ?>
+
+ n/a
+
+
+
+
+ = e($fmtNumber((float) $position['gain_base'])) ?> = e((string) $position['base_currency']) ?>
+
+ n/a
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Kursverlauf
+
Historische Kurse pro Aktie mit Zeitstempel und Quelle.
+
+
+
+ Noch keine Kursdaten vorhanden.
+
+
+ $instrument): ?>
+
+
+
+
+
= e((string) $instrument['name']) ?>
+
+ = e((string) ($instrument['symbol'] ?: '-')) ?> · = e((string) ($instrument['isin'] ?: '-')) ?>
+
+
+
Neuen Kurs erfassen
+
+
+ Noch keine historischen Kurse vorhanden.
+
+
+
+
+
+ Zeitpunkt
+ Kurs
+ Quelle
+ Aktion
+
+
+
+
+
+ = e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?>
+ = e($fmtNumber((float) $quote['price'], 4)) ?> = e((string) $quote['currency']) ?>
+ = e((string) $quote['source']) ?>
+
+
+
+
+
+ Loeschen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+= module_shell_footer() ?>
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 @@
+= module_shell_header('boersenchecker', [
+ 'title' => 'Depot-Ueberblick',
+]) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
Marktueberblick
+
Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.
+
+
+
+
+
+
+
+ Positionen
+ = e((string) ($summary['positions'] ?? 0)) ?>
+ Aktien im aktuell gewaehlten Depot
+
+
+ Investiert
+ = isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
+ In Berichtswahrung bewertet
+
+
+ Aktueller Wert
+ = isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
+ Basierend auf dem letzten gespeicherten Kurs
+
+
+ Performance
+ = isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
+ = !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?>
+
+
+
+
+
+ Bester Wert
+
+ = e((string) $summary['best']['instrument_name']) ?>
+ = e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%
+
+ Noch keine Performance verfuegbar.
+
+
+
+
+ Schwaechster Wert
+
+ = e((string) $summary['worst']['instrument_name']) ?>
+ = e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%
+
+ Noch keine Performance verfuegbar.
+
+
+
+
+
+ = e((string) $position['instrument_name']) ?>
+ = $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?>
+ = e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?>
+
+
+
+
+
+
+
+
Kursverlauf
+
Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.
+
+
+
+
+
+
Aktie
+
= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?>
+
+
= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · = e((string) ($selectedInstrument['isin'] ?? '-')) ?>
+
+
+
+ Tag
+ 5 Tage
+ Monat
+ 3 Monate
+ 6 Monate
+ Jahr
+ 5 Jahre
+
+
+
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'; ?>
+
+
+
= e((string) $position['instrument_name']) ?>
+
= e((string) ($position['symbol'] ?? '')) ?> · = e((string) ($position['isin'] ?? '-')) ?>
+
+
= e((string) $position['market']) ?>
+
+
+
+
Stueckzahl
+
= e(number_format((float) $position['quantity'], 6, ',', '.')) ?>
+
+
+
Kaufpreis
+
= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> = e((string) $position['purchase_currency']) ?>
+
+
+
Letzter Kurs
+
= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?>
+
+
+
Performance
+
+ = isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
+
+
+
+
+
+
+
+
+
+= module_shell_footer() ?>
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 @@
+= module_shell_header('boersenchecker', [
+ 'title' => 'Aktienverwaltung',
+]) ?>
+
+
+
+
+
+
+
+
+
+
+
+
Aktie waehlen
+
Systemweit vorhandene Aktie aus allen Depots auswaehlen.
+
+
+
+
+
+ Aktien aller Depots
+
+
+ >
+ = e((string) $instrument['name']) ?>= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
+
+
+
+
+
+
+
+
+
+
+
Wertpapiersuche
+
Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.
+
+
+
+
+
+
+
+ Suchbegriff
+
+
+ Suchen
+
+
+
+
+
+
+ Symbol
+ Name
+ Region
+ Waehrung
+ Aktion
+
+
+
+
+
+ = e((string) ($result['symbol'] ?? '')) ?>
+ = e((string) ($result['name'] ?? '')) ?>
+ = e((string) ($result['region'] ?? '')) ?>
+ = e((string) ($result['currency'] ?? '')) ?>
+
+
+ Uebernehmen
+
+
+
+
+
+
+
+
+
Noch keine Symbolsuche ausgefuehrt.
+
+
+
+
+
+
+
+
+
+
+
+
+
Kursverlauf
+
Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.
+
+
+
+
+
Keine Kursdaten vorhanden.
+
+
+
+
+
+ Zeitpunkt
+ Kurs
+ Quelle
+ Aktion
+
+
+
+
+
+ = e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?>
+ = e(number_format((float) $quote['price'], 4, ',', '.')) ?> = e((string) $quote['currency']) ?>
+ = e((string) $quote['source']) ?>
+
+
+
+
+
+ Loeschen
+
+
+
+
+
+
+
+
+
+= module_shell_footer() ?>
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);
+ }
+ }
}