258 lines
9.8 KiB
PHP
258 lines
9.8 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Steuerkram\Models;
|
||
|
||
use Steuerkram\Database;
|
||
use Steuerkram\Services\Crypto;
|
||
|
||
/**
|
||
* Einheitliche Tabelle für alle Bank-Importquellen:
|
||
* - type='csv' – CSV-Importformat (Spaltenmapping, Trennzeichen, Templates)
|
||
* - type='api' – Direktverbindung über REST/NVP API (PayPal)
|
||
* - type='fints' – Direktverbindung über FinTS (Comdirect, DKB, …)
|
||
*
|
||
* Beim Lesen werden die verschlüsselten Spalten (client_id_enc, secret_enc, pin_enc)
|
||
* automatisch zu Klartext-Schlüsseln (client_id, secret, pin) entschlüsselt.
|
||
* Außerdem wird "name" als Alias für "bezeichnung" gesetzt, damit der bestehende
|
||
* BankImportParser-Code ($format['name'] etc.) ohne Anpassung funktioniert.
|
||
*/
|
||
class BankSource
|
||
{
|
||
public static function all(?string $type = null): array
|
||
{
|
||
$sql = 'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
|
||
FROM bank_sources bs
|
||
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id';
|
||
$params = [];
|
||
if ($type !== null) {
|
||
$sql .= ' WHERE bs.type = ?';
|
||
$params[] = $type;
|
||
}
|
||
$sql .= ' ORDER BY bs.sort_order, bs.type, bs.bezeichnung';
|
||
$rows = Database::fetchAll($sql, $params);
|
||
return array_map([self::class, 'hydrate'], $rows);
|
||
}
|
||
|
||
public static function find(int $id): ?array
|
||
{
|
||
$row = Database::fetchOne(
|
||
'SELECT bs.*, bp.name AS provider_name, bp.verbindungstyp, bp.default_fints_url
|
||
FROM bank_sources bs
|
||
LEFT JOIN bank_providers bp ON bp.id = bs.provider_id
|
||
WHERE bs.id = ?',
|
||
[$id]
|
||
);
|
||
return $row ? self::hydrate($row) : null;
|
||
}
|
||
|
||
public static function create(array $data): int
|
||
{
|
||
$type = $data['type'] ?? 'csv';
|
||
$row = self::dehydrate($data);
|
||
|
||
$cols = array_keys($row);
|
||
$sql = 'INSERT INTO bank_sources (' . implode(',', array_map(fn($c) => "`$c`", $cols)) . ')
|
||
VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ')';
|
||
Database::execute($sql, array_values($row));
|
||
return (int)Database::lastInsertId();
|
||
}
|
||
|
||
public static function update(int $id, array $data): void
|
||
{
|
||
$row = self::dehydrate($data);
|
||
if (empty($row)) {
|
||
return;
|
||
}
|
||
$sets = implode(', ', array_map(fn($c) => "`$c` = ?", array_keys($row)));
|
||
$params = array_values($row);
|
||
$params[] = $id;
|
||
Database::execute("UPDATE bank_sources SET $sets WHERE id = ?", $params);
|
||
}
|
||
|
||
public static function updateField(int $id, string $field, mixed $value): void
|
||
{
|
||
if (in_array($field, ['client_id', 'secret', 'pin'], true)) {
|
||
$field = $field . '_enc';
|
||
$value = $value !== null && $value !== '' ? Crypto::encrypt((string)$value) : null;
|
||
}
|
||
Database::execute("UPDATE bank_sources SET `$field` = ? WHERE id = ?", [$value, $id]);
|
||
}
|
||
|
||
public static function delete(int $id): void
|
||
{
|
||
Database::execute('DELETE FROM bank_sources WHERE id = ?', [$id]);
|
||
}
|
||
|
||
public static function updateLastFetched(int $id, ?float $balance = null): void
|
||
{
|
||
// Setzt zugleich fetch_range auf den Default zurück: einmalige Sonderauswahl
|
||
// (z.B. "letzte Woche") gilt nur für genau einen Abruf.
|
||
// last_balance wird nur überschrieben, wenn der Abruf einen Kontostand geliefert
|
||
// hat – sonst bleibt der zuletzt bekannte Stand erhalten.
|
||
if ($balance !== null) {
|
||
Database::execute(
|
||
"UPDATE bank_sources
|
||
SET last_fetched_at = NOW(),
|
||
fetch_range = 'last_fetched',
|
||
last_balance = ?
|
||
WHERE id = ?",
|
||
[$balance, $id]
|
||
);
|
||
return;
|
||
}
|
||
Database::execute(
|
||
"UPDATE bank_sources
|
||
SET last_fetched_at = NOW(),
|
||
fetch_range = 'last_fetched'
|
||
WHERE id = ?",
|
||
[$id]
|
||
);
|
||
}
|
||
|
||
/** Erlaubte Werte für die "Abrufen ab"-Auswahl. */
|
||
public const FETCH_RANGES = [
|
||
'last_fetched', 'last_week', 'current_month', 'last_30_days', 'full',
|
||
];
|
||
|
||
/**
|
||
* Berechnet das Start-Datum für einen Direktverbindungs-Abruf anhand der
|
||
* Auswahl im Bearbeiten-Dialog und des letzten Abruf-Zeitstempels.
|
||
*
|
||
* Liefert ein Tupel [\DateTimeImmutable $start, string $label].
|
||
* $label ist eine sprechende Beschreibung für das Abruf-Log.
|
||
*/
|
||
public static function computeStartDate(?string $range, ?string $lastFetchedAt, ?\DateTimeImmutable $today = null): array
|
||
{
|
||
$today = $today ?? new \DateTimeImmutable('today');
|
||
$range = in_array($range, self::FETCH_RANGES, true) ? $range : 'last_fetched';
|
||
|
||
switch ($range) {
|
||
case 'last_week':
|
||
return [$today->modify('-7 days'), 'letzte 7 Tage'];
|
||
|
||
case 'current_month':
|
||
$start = $today->modify('first day of this month');
|
||
return [$start, 'aktueller Monat ab ' . $start->format('d.m.Y')];
|
||
|
||
case 'last_30_days':
|
||
return [$today->modify('-30 days'), 'letzte 30 Tage'];
|
||
|
||
case 'full':
|
||
return [$today->modify('-365 days'), 'letzte 365 Tage (ohne Datumsbegrenzung)'];
|
||
|
||
case 'last_fetched':
|
||
default:
|
||
if ($lastFetchedAt) {
|
||
$start = new \DateTimeImmutable(
|
||
(new \DateTimeImmutable($lastFetchedAt))->format('Y-m-d')
|
||
);
|
||
return [$start, 'ab letztem Abruf am ' . $start->format('d.m.Y') . ' 00:00 Uhr'];
|
||
}
|
||
return [$today->modify('-365 days'), 'kein vorheriger Abruf – starte mit den letzten 365 Tagen'];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Berechnet einen neuen account_filter aus dem bisherigen Filter und einem
|
||
* POST-Payload des Bearbeiten-Dialogs.
|
||
*
|
||
* Rückgabe:
|
||
* ['update' => true, 'value' => null] – Filter löschen (Reset)
|
||
* ['update' => true, 'value' => '[...]'] – neuen JSON-Filter setzen
|
||
* ['update' => false, 'value' => null] – Filter unverändert lassen
|
||
*
|
||
* Nur Identifier, die bereits im bisherigen Filter standen, werden übernommen
|
||
* (kein Unterschieben fremder Konten via POST).
|
||
*/
|
||
public static function computeFilterFromPost(?string $currentJson, array $post): array
|
||
{
|
||
if (isset($post['reset_account_filter'])) {
|
||
return ['update' => true, 'value' => null];
|
||
}
|
||
if (!array_key_exists('active_accounts', $post)) {
|
||
return ['update' => false, 'value' => null];
|
||
}
|
||
|
||
$known = [];
|
||
if ($currentJson !== null && $currentJson !== '') {
|
||
$decoded = json_decode($currentJson, true);
|
||
if (is_array($decoded)) {
|
||
$known = array_values(array_map('strval', $decoded));
|
||
}
|
||
}
|
||
|
||
$postedRaw = is_array($post['active_accounts']) ? $post['active_accounts'] : [];
|
||
$posted = array_map('strval', $postedRaw);
|
||
$kept = array_values(array_intersect($known, $posted));
|
||
|
||
if ($kept === [] && $known !== []) {
|
||
// Alle abgehakt → wie Reset behandeln (beim nächsten Abruf neu fragen)
|
||
return ['update' => true, 'value' => null];
|
||
}
|
||
if ($kept !== []) {
|
||
return ['update' => true, 'value' => json_encode($kept, JSON_UNESCAPED_UNICODE)];
|
||
}
|
||
return ['update' => false, 'value' => null];
|
||
}
|
||
|
||
/** Aktualisiert sort_order anhand der übergebenen ID-Reihenfolge (Position 0..N). */
|
||
public static function updateOrder(array $orderedIds): void
|
||
{
|
||
foreach (array_values($orderedIds) as $pos => $id) {
|
||
$id = (int)$id;
|
||
if ($id <= 0) continue;
|
||
Database::execute(
|
||
'UPDATE bank_sources SET sort_order = ? WHERE id = ?',
|
||
[$pos, $id]
|
||
);
|
||
}
|
||
}
|
||
|
||
private static function hydrate(array $row): array
|
||
{
|
||
$row['name'] = $row['bezeichnung'];
|
||
$row['client_id'] = Crypto::decrypt((string)($row['client_id_enc'] ?? ''));
|
||
$row['secret'] = Crypto::decrypt((string)($row['secret_enc'] ?? ''));
|
||
$row['pin'] = Crypto::decrypt((string)($row['pin_enc'] ?? ''));
|
||
return $row;
|
||
}
|
||
|
||
/**
|
||
* Überführt ein Eingabe-Array in die DB-Spalten:
|
||
* - client_id/secret/pin → *_enc (verschlüsselt)
|
||
* - nur erlaubte Spalten werden zurückgegeben
|
||
*/
|
||
private static function dehydrate(array $data): array
|
||
{
|
||
$allowed = [
|
||
'type', 'provider_id', 'bezeichnung', 'kuerzel', 'farbe',
|
||
'skip_rows', 'has_header_row', 'delimiter', 'enclosure',
|
||
'col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert',
|
||
'col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck',
|
||
'date_format', 'decimal_separator', 'thousands_separator',
|
||
'buchungstext_template_debit', 'buchungstext_template_credit',
|
||
'sandbox', 'last_fetched_at', 'fetch_range',
|
||
'app_name', 'client_id_enc', 'secret_enc',
|
||
'blz', 'fints_url', 'benutzerkennung', 'pin_enc', 'account_filter', 'tan_mode_id',
|
||
];
|
||
|
||
$out = [];
|
||
foreach ($allowed as $col) {
|
||
if (array_key_exists($col, $data)) {
|
||
$out[$col] = $data[$col];
|
||
}
|
||
}
|
||
|
||
// Klartext-Felder verschlüsseln
|
||
foreach (['client_id' => 'client_id_enc', 'secret' => 'secret_enc', 'pin' => 'pin_enc'] as $plain => $enc) {
|
||
if (array_key_exists($plain, $data)) {
|
||
$val = $data[$plain];
|
||
$out[$enc] = ($val === null || $val === '') ? null : Crypto::encrypt((string)$val);
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
}
|