Boersenchecker
All checks were successful
Deploy / deploy-staging (push) Successful in 28s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-22 23:29:03 +02:00
parent 616b03474f
commit dc66f75eef
24 changed files with 5151 additions and 5 deletions

View File

@@ -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

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
ModuleHttp::requireDesktopAccess(dirname(__DIR__, 3), true);
require_auth();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
if ($instrumentId <= 0) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => 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;

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/boersenchecker/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
$normalized = strtolower(trim($relativePath, '/'));
if ($normalized === 'v1/chart-data') {
require __DIR__ . '/chart_data.php';
return;
}
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => false,
'message' => 'Ressource nicht gefunden.',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -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;
}
}

View File

@@ -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 = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
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 `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
}).join('');
chartShell.innerHTML = `
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs>
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
</linearGradient>
</defs>
<g class="bc-chart-grid">${grid}</g>
<path class="bc-chart-area" d="${area}"></path>
<path class="bc-chart-path" d="${path}"></path>
</svg>
`;
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 = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
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 = `<div class="muted">${error.message}</div>`;
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();
})();

View File

@@ -0,0 +1,995 @@
<?php
declare(strict_types=1);
use App\ModuleConfigException;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\Boersenchecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'boersenchecker';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->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];
});

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\ConfigLoader;
$projectRoot = dirname(__DIR__, 3);
$baseDb = ConfigLoader::load($projectRoot, 'base_db');
$baseDbConfig = (bool) ($baseDb['enabled'] ?? false) && is_array($baseDb['db'] ?? null)
? $baseDb['db']
: ConfigLoader::load($projectRoot, 'db_settings_basic');
return [
'use_separate_db' => 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),
];

View File

@@ -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" }
]
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
return [
'app_id' => '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.'),
];

View File

@@ -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

View File

@@ -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": []
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$view = trim((string) ($_GET['view'] ?? 'overview'));
$allowedViews = ['overview', 'depotverwaltung', 'aktienverwaltung', 'setup'];
if (!in_array($view, $allowedViews, true)) {
$view = 'overview';
}
$assetVersion = static function (string $relativePath): string {
$fullPath = dirname(__DIR__) . '/' . ltrim($relativePath, '/');
$mtime = is_file($fullPath) ? filemtime($fullPath) : false;
return $mtime === false ? '0' : (string) $mtime;
};
$renderSetup = static function (): void {
$moduleName = 'boersenchecker';
$manifestPath = dirname(__DIR__) . '/module.json';
$manifestRaw = is_file($manifestPath) ? file_get_contents($manifestPath) : false;
$manifest = is_string($manifestRaw) && trim($manifestRaw) !== '' ? json_decode($manifestRaw, true) : [];
$fields = is_array($manifest['setup']['fields'] ?? null) ? $manifest['setup']['fields'] : [];
$settings = modules()->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.',
]);
?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Modul-Setup</h2>
<p>Die Felder entsprechen dem alten Nexus-Modul und werden lokal in `data/module-settings/boersenchecker.json` gespeichert.</p>
</div>
</div>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_setup">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:12px;">
<?php foreach ($fields as $field): ?>
<?php
if (!is_array($field)) {
continue;
}
$name = trim((string) ($field['name'] ?? ''));
$label = trim((string) ($field['label'] ?? $name));
$type = trim((string) ($field['type'] ?? 'text'));
$help = trim((string) ($field['help'] ?? ''));
$value = $getValue($settings, $name);
?>
<label class="setup-field muted">
<span><?= e($label) ?></span>
<?php if ($type === 'checkbox'): ?>
<input type="checkbox" name="settings[<?= e($name) ?>]" value="1" <?= !empty($value) ? 'checked' : '' ?>>
<?php elseif ($type === 'password'): ?>
<input type="password" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php elseif ($type === 'number'): ?>
<input type="number" step="any" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php else: ?>
<input type="text" name="settings[<?= e($name) ?>]" value="<?= e(is_scalar($value) ? (string) $value : '') ?>">
<?php endif; ?>
<?php if ($help !== ''): ?>
<small class="bc-text"><?= e($help) ?></small>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Setup speichern</button>
</div>
</form>
</section>
</div>
<?= module_shell_footer() ?>
<?php
};
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Börsenchecker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-2: #0f766e;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.boersenchecker-module-shell {
min-height: 100vh;
padding: 18px;
}
.section-box,
.submenu-box,
.card-box {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
padding: 22px 24px;
}
.module-page-stack {
display: grid;
gap: 18px;
}
.module-hero-copy { margin-bottom: 16px; }
.module-title { margin: 0; font-size: 2.4rem; line-height: 1.05; }
.module-lead { margin: 12px 0 0; color: var(--muted); line-height: 1.6; }
.eyebrow {
margin: 0 0 12px;
color: #4338ca;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
font-weight: 700;
}
.module-hero-top--compact,
.module-tabs,
.module-hero-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.module-hero-top--compact { justify-content:space-between; gap:16px; }
.module-button {
display:inline-flex;
align-items:center;
justify-content:center;
min-height:44px;
padding:10px 16px;
border-radius:999px;
text-decoration:none;
border:1px solid rgba(148, 163, 184, 0.28);
color:#0f172a;
background:#ffffff;
font-weight:700;
}
.module-button--tab-active,
.module-button--primary {
color:#ffffff;
border-color:transparent;
background:linear-gradient(135deg, var(--brand-accent), var(--brand-accent-3));
}
.module-button--secondary,
.module-button--tab {
background: rgba(255, 255, 255, 0.94);
}
.module-button--small { min-height:40px; padding:8px 14px; }
.setup-field { display:grid; gap:8px; }
.setup-field input, .setup-field select, .setup-field textarea {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.4);
border-radius: 14px;
font: inherit;
background: #ffffff;
color: #0f172a;
}
.setup-field textarea { min-height: 100px; }
.grid { display:grid; }
</style>
<link rel="stylesheet" href="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.css&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.css'), ENT_QUOTES) ?>">
</head>
<body>
<div class="boersenchecker-module-shell">
<?php
if ($view === 'setup') {
$renderSetup();
} elseif ($view === 'depotverwaltung') {
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->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());
}
?>
</div>
<script src="/module-assets/index.php?module=boersenchecker&amp;path=assets/boersenchecker.js&amp;v=<?= htmlspecialchars($assetVersion('assets/boersenchecker.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -0,0 +1,513 @@
<?php $ownerQuery = $isAdmin ? '&owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
<?= module_shell_header('boersenchecker', [
'title' => 'Depotverwaltung',
]) ?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<?php if ($isAdmin): ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Benutzer-Scope</h2>
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
</div>
</div>
<form method="get" action="/apps/boersenchecker" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="view" value="depotverwaltung">
<label class="setup-field muted" style="margin:0; min-width:260px;">
<span>Depots von Benutzer</span>
<select name="owner_sub">
<?php foreach ($availableOwners as $owner): ?>
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
<?= e((string) $owner['label']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
</form>
</section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
</div>
</div>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depotname</span>
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Berichtswahrung</span>
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
<?php if ($editPortfolio): ?>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">API / FX</h2>
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
</div>
</div>
<p class="muted" style="margin-top:16px;">
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.
</p>
<p class="muted" style="margin-top:12px;">
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
</p>
<div class="bc-actions" style="margin-top:16px;">
<form method="post">
<input type="hidden" name="action" value="refresh_fx">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_all">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
</form>
</div>
<div class="muted" style="margin-top:12px;">
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
</div>
<div class="muted" style="margin-top:6px;">
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/apps/boersenchecker?view=setup">dieses Modul-Setup</a> gepflegt.
</div>
<div class="muted" style="margin-top:6px;">
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/apps/fx-rates">fx-rates</a> gepflegt.
</div>
<div class="muted" style="margin-top:12px;">
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
</div>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depot</span>
<select name="portfolio_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktienname</span>
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>API-Symbol / Ticker</span>
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
</label>
<label class="setup-field muted">
<span>ISIN</span>
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>WKN</span>
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Boerse / Markt</span>
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Kurswaehrung</span>
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Stueckzahl</span>
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufpreis</span>
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufwaehrung</span>
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufdatum</span>
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
</label>
<label class="setup-field muted">
<span>Gebuehren</span>
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
</label>
</div>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
<?php if ($editPosition): ?>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($symbolSearchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Typ</th>
<th>Region</th>
<th>Waehrung</th>
<th>Match</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($symbolSearchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['type'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
In Formular uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktie</span>
<select name="quote_instrument_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($instrumentList as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>Kurs</span>
<input type="number" name="quote_price" min="0" step="0.00000001" required>
</label>
<label class="setup-field muted">
<span>Waehrung</span>
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
</label>
<label class="setup-field muted">
<span>Zeitpunkt</span>
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
</label>
<label class="setup-field muted">
<span>Quelle</span>
<input type="text" name="quote_source" value="manual">
</label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Depots</h2>
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($portfolios as $portfolio): ?>
<?php
$portfolioId = (int) $portfolio['id'];
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $portfolio['name']) ?></strong>
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
</div>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
<input type="hidden" name="action" value="delete_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</div>
<?php if (!empty($portfolio['notes'])): ?>
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
<?php endif; ?>
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
<div class="bc-stat">
<div class="muted">Investiert</div>
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Aktuell</div>
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Gewinn / Verlust</div>
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Positionen</h2>
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
</div>
</div>
<?php if ($positions === []): ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Positionen vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Depot</th>
<th>Aktie</th>
<th>ISIN / WKN</th>
<th>Stueck</th>
<th>Kauf</th>
<th>Letzter Kurs</th>
<th>Wert</th>
<th>Delta</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($positions as $position): ?>
<tr>
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
<td>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<?php if (!empty($position['symbol'])): ?>
<div class="muted"><?= e((string) $position['symbol']) ?></div>
<?php endif; ?>
</td>
<td>
<?= e((string) ($position['isin'] ?: '-')) ?>
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
</td>
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
<td>
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
</td>
<td>
<?php if ($position['latest_price'] !== null): ?>
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
<?php else: ?>
<span class="muted">kein Kurs</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['current_total_base'] !== null): ?>
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['gain_base'] !== null): ?>
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
</form>
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
<input type="hidden" name="action" value="delete_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $instrument['name']) ?></strong>
<div class="muted">
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
</div>
</div>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=depotverwaltung&owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
</div>
<?php if ($history === []): ?>
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
<?php else: ?>
<div class="bc-table-shell" style="margin-top:12px;">
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($history as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,211 @@
<?= module_shell_header('boersenchecker', [
'title' => 'Depot-Ueberblick',
]) ?>
<div class="bc-page" data-bc-home data-chart-endpoint="/api/boersenchecker/index.php?path=v1/chart-data">
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
return [
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
];
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Marktueberblick</h2>
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
</div>
</div>
<div class="bc-toolbar" style="margin-top:16px;">
<form class="bc-panel" method="get" action="/apps/boersenchecker">
<input type="hidden" name="view" value="overview">
<div class="bc-field-label">Depotauswahl</div>
<?php if ($portfolios === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Depot</span>
<select name="portfolio_id" onchange="this.form.submit()">
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="get" action="/apps/boersenchecker">
<input type="hidden" name="view" value="overview">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Aktienauswahl</div>
<?php if ($positions === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Aktie</span>
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
<?php foreach ($positions as $position): ?>
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="post">
<input type="hidden" name="action" value="refresh_current_quotes_home">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Marktdaten</div>
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
<div class="bc-actions" style="margin-top:16px;">
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
</div>
</form>
</div>
</section>
<div class="bc-overview-grid">
<section class="card-box bc-stat">
<div class="bc-field-label">Positionen</div>
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Investiert</div>
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Aktueller Wert</div>
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Performance</div>
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
</section>
</div>
<div class="bc-card-grid">
<section class="card-box">
<div class="bc-field-label">Bester Wert</div>
<?php if (!empty($summary['best'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<section class="card-box">
<div class="bc-field-label">Schwaechster Wert</div>
<?php if (!empty($summary['worst'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
<section class="card-box bc-stat">
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
</section>
<?php endforeach; ?>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
</div>
</div>
<div class="bc-chart-card" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
<div>
<div class="bc-field-label">Aktie</div>
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
<?php if ($selectedInstrument): ?>
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
<?php endif; ?>
</div>
<div class="bc-range-list">
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
</div>
</div>
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
</div>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktien im Depot</h2>
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
</div>
</div>
<div class="bc-section-copy">
<?php if ($positions === []): ?>
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<div class="bc-position-list" style="padding:0 0 18px;">
<?php foreach ($positions as $position): ?>
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
<div class="bc-position-row">
<div>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
<?php if (!empty($position['market'])): ?>
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
<?php endif; ?>
</div>
<div>
<div class="bc-field-label">Stueckzahl</div>
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
</div>
<div>
<div class="bc-field-label">Kaufpreis</div>
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
</div>
<div>
<div class="bc-field-label">Letzter Kurs</div>
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
</div>
<div>
<div class="bc-field-label">Performance</div>
<div class="bc-performance <?= e($gainClass) ?>">
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,188 @@
<?= module_shell_header('boersenchecker', [
'title' => 'Aktienverwaltung',
]) ?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie waehlen</h2>
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
</div>
</div>
<form method="get" action="/apps/boersenchecker" style="margin-top:16px;">
<input type="hidden" name="view" value="aktienverwaltung">
<label class="setup-field muted">
<span>Aktien aller Depots</span>
<select name="instrument_id" onchange="this.form.submit()">
<?php foreach ($instruments as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($searchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Region</th>
<th>Waehrung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($searchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/apps/boersenchecker?view=aktienverwaltung&instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
Uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie bearbeiten</h2>
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
</div>
</form>
<form method="post" style="margin-top:12px;">
<input type="hidden" name="action" value="refresh_market_data_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
</div>
</div>
<?php if ($quotes === []): ?>
<div class="bc-section-copy">
<div class="muted">Keine Kursdaten vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($quotes as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,898 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class DashboardPage
{
private PDO $pdo;
private array $user;
private bool $isAdmin;
private string $ownerSub;
private array $moduleSettings;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
private int $editPortfolioId;
private int $editPositionId;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private InstrumentRegistry $instrumentRegistry;
private string $symbolSearchKeywords = '';
private array $symbolSearchResults = [];
private array $availableOwners = [];
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class HomePage
{
private PDO $pdo;
private string $ownerSub;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentPage
{
private PDO $pdo;
private string $ownerSub;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private string $searchKeywords = '';
private array $searchResults = [];
private int $selectedInstrumentOverrideId = 0;
private InstrumentRegistry $instrumentRegistry;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentRegistry
{
public function __construct(
private PDO $pdo,
private string $instrumentTable,
private string $positionTable,
private string $quoteTable,
) {
}
public function save(array $payload): int
{
$currentId = (int) ($payload['id'] ?? 0);
$data = $this->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;
}
}

View File

@@ -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;
});
}

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__, 3) . '/modules/boersenchecker/api/index.php';

View File

@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__, 3) . '/modules/boersenchecker/pages/index.php';

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App;
final class ModuleConfigException extends \RuntimeException
{
public function __construct(
public readonly string $moduleName,
string $message,
?string $detail = null,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct(
$detail !== null && $detail !== '' ? $message . ' ' . $detail : $message,
$code,
$previous
);
}
}

View File

@@ -6,6 +6,7 @@ namespace ModulesCore {
use App\ConfigLoader;
use App\KeycloakAuth;
use App\PdoFactory;
use RuntimeException;
final class LegacyCompat
@@ -41,6 +42,7 @@ namespace ModulesCore {
{
private ?LegacyCompatConfig $config = null;
private ?LegacyCompatAuthContext $auth = null;
private ?\PDO $basePdo = null;
public function __construct(
private readonly string $projectRoot,
@@ -64,6 +66,27 @@ namespace ModulesCore {
return $this->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<string, array<string, mixed>> */
private array $settingsCache = [];
/** @var array<string, callable> */
private array $callbacks = [];
/** @var array<string, bool> */
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<string, mixed>
*/
@@ -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<string, mixed> $defaults
* @param array<string, mixed> $stored
* @return array<string, mixed>
*/
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 = '<div class="module-shell"><div class="module-page-bg"><div class="module-page-stack">';
$html .= '<header class="module-hero submenu-box">';
$html .= '<div class="module-hero-copy">';
$html .= '<div class="eyebrow">' . e($eyebrow) . '</div>';
if ($title !== '') {
$html .= '<h1 class="module-title">' . e($title) . '</h1>';
}
if ($description !== '') {
$html .= '<p class="module-lead">' . e($description) . '</p>';
}
$html .= '</div>';
if ($tabs !== [] || $actions !== []) {
$html .= '<div class="module-hero-top module-hero-top--compact">';
if ($tabs !== []) {
$html .= '<nav class="module-tabs" aria-label="Modulnavigation">';
foreach ($tabs as $tab) {
if (!is_array($tab)) {
continue;
}
$label = trim((string) ($tab['label'] ?? ''));
$href = trim((string) ($tab['href'] ?? ''));
if ($label === '' || $href === '') {
continue;
}
$activeView = trim((string) ($tab['view'] ?? ''));
$isActive = !empty($tab['active']);
if (!$isActive && $activeView !== '') {
$isActive = (string) ($requestQuery['view'] ?? '') === $activeView;
}
if (!$isActive && $href === $requestPath) {
$isActive = true;
}
$class = $isActive ? 'module-button module-button--tab-active' : 'module-button module-button--tab';
$html .= '<a class="' . e($class) . '" href="' . e($href) . '">' . e($label) . '</a>';
}
$html .= '</nav>';
}
if ($actions !== []) {
$html .= '<div class="module-hero-actions module-submenu-actions">';
foreach ($actions as $action) {
if (!is_array($action)) {
continue;
}
$label = trim((string) ($action['label'] ?? ''));
$href = trim((string) ($action['href'] ?? ''));
if ($label === '' || $href === '') {
continue;
}
$html .= '<a class="module-button module-button--secondary module-button--small" href="' . e($href) . '">' . e($label) . '</a>';
}
$html .= '</div>';
}
$html .= '</div>';
}
$html .= '</header>';
return $html;
}
}
if (!function_exists('module_shell_footer')) {
function module_shell_footer(): string
{
return '</div></div></div>';
}
}
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 '<!-- module_tpl(): not found -->';
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);
}
}
}