Steuerkram/src/Models/BankSource.php

245 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
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): void
{
// Setzt zugleich fetch_range auf den Default zurück: einmalige Sonderauswahl
// (z.B. "letzte Woche") gilt nur für genau einen Abruf.
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;
}
}