- HKEKA v3/v4/v5 Segmente fuer phpFinTS implementiert (VR Bank unterstuetzt kein HKEKP) - GetElectronicStatement Action mit Base64-Erkennung und Quittungscode - PDF-Deduplizierung per MD5 (Bank sendet identische Saldenmitteilungen) - Saldenmitteilungen ohne Auszugsnummer werden uebersprungen - Datums-Validierung: 30.02. (Bank-Konvention) wird auf 28.02. korrigiert - Numerische Sortierung fuer statement_number (CAST statt String-Sort) - Jahr-Filter: statement_year=0 ausgeschlossen - Menue/Button: "Kontoauszuege" -> "Umsaetze" (statements.php zeigt MT940, nicht PDFs) - Redirect nach FinTS-Abruf auf aktuelles Jahr statt year=0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
359 lines
14 KiB
PHP
Executable file
359 lines
14 KiB
PHP
Executable file
<?php
|
|
|
|
namespace Fhp\Action;
|
|
|
|
use Fhp\Model\SEPAAccount;
|
|
use Fhp\PaginateableAction;
|
|
use Fhp\Protocol\BPD;
|
|
use Fhp\Protocol\Message;
|
|
use Fhp\Protocol\UnexpectedResponseException;
|
|
use Fhp\Protocol\UPD;
|
|
use Fhp\Segment\AnonymousSegment;
|
|
use Fhp\Segment\Common\Kti;
|
|
use Fhp\Segment\Common\KtvV3;
|
|
use Fhp\Segment\EKA\HIEKA;
|
|
use Fhp\Segment\EKA\HIEKAS;
|
|
use Fhp\Segment\EKA\HKEKAv3;
|
|
use Fhp\Segment\EKA\HKEKAv4;
|
|
use Fhp\Segment\EKA\HKEKAv5;
|
|
use Fhp\Segment\HIRMS\Rueckmeldungscode;
|
|
use Fhp\UnsupportedException;
|
|
|
|
/**
|
|
* Ruft elektronische Kontoauszuege von der Bank ab (HKEKA).
|
|
*
|
|
* Im Gegensatz zu GetBankStatement (HKEKP, nur PDF) unterstuetzt HKEKA
|
|
* verschiedene Formate: 1=MT940, 2=ISO8583, 3=PDF.
|
|
* Das gewuenschte Format wird im Request mitgesendet.
|
|
*/
|
|
class GetElectronicStatement extends PaginateableAction
|
|
{
|
|
// Kontoauszugsformate
|
|
const FORMAT_MT940 = '1';
|
|
const FORMAT_ISO8583 = '2';
|
|
const FORMAT_PDF = '3';
|
|
|
|
// Request-Parameter
|
|
/** @var SEPAAccount */
|
|
private $account;
|
|
/** @var string|null Gewuenschtes Format (1=MT940, 2=ISO8583, 3=PDF) */
|
|
private $format;
|
|
/** @var string|null Auszugsnummer */
|
|
private $statementNumber;
|
|
/** @var string|null Jahr (JJJJ) */
|
|
private $year;
|
|
|
|
// Response: Gesammelte Daten
|
|
/** @var array Array von ['data' => string, 'format' => string|null] */
|
|
private $statements = [];
|
|
|
|
/**
|
|
* @param SEPAAccount $account Das Konto fuer das Auszuege abgerufen werden sollen.
|
|
* @param string|null $format Gewuenschtes Format (FORMAT_MT940/FORMAT_ISO8583/FORMAT_PDF), null=Bank-Standard.
|
|
* @param string|null $statementNumber Optionale Auszugsnummer.
|
|
* @param string|null $year Optionales Jahr (JJJJ).
|
|
* @return GetElectronicStatement
|
|
*/
|
|
public static function create(
|
|
SEPAAccount $account,
|
|
?string $format = null,
|
|
?string $statementNumber = null,
|
|
?string $year = null
|
|
): GetElectronicStatement {
|
|
$result = new GetElectronicStatement();
|
|
$result->account = $account;
|
|
$result->format = $format;
|
|
$result->statementNumber = $statementNumber;
|
|
$result->year = $year;
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @deprecated
|
|
*/
|
|
public function serialize(): string
|
|
{
|
|
return serialize($this->__serialize());
|
|
}
|
|
|
|
public function __serialize(): array
|
|
{
|
|
return [
|
|
parent::__serialize(),
|
|
$this->account,
|
|
$this->format,
|
|
$this->statementNumber,
|
|
$this->year,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @deprecated
|
|
*/
|
|
public function unserialize($serialized)
|
|
{
|
|
self::__unserialize(unserialize($serialized));
|
|
}
|
|
|
|
public function __unserialize(array $serialized): void
|
|
{
|
|
list(
|
|
$parentSerialized,
|
|
$this->account,
|
|
$this->format,
|
|
$this->statementNumber,
|
|
$this->year
|
|
) = $serialized;
|
|
|
|
is_array($parentSerialized) ?
|
|
parent::__unserialize($parentSerialized) :
|
|
parent::unserialize($parentSerialized);
|
|
}
|
|
|
|
/**
|
|
* @return array Array von ['data' => string, 'format' => string|null] pro Kontoauszug.
|
|
*/
|
|
public function getStatements(): array
|
|
{
|
|
$this->ensureDone();
|
|
return $this->statements;
|
|
}
|
|
|
|
/**
|
|
* Hilfsfunktion: Gibt nur die PDF-Daten zurueck (filtert nach Format=3 oder erkennt %PDF-).
|
|
* @return string[] Array von PDF-Binaerdaten.
|
|
*/
|
|
public function getPdfStatements(): array
|
|
{
|
|
$this->ensureDone();
|
|
$pdfs = [];
|
|
foreach ($this->statements as $stmt) {
|
|
$data = $stmt['data'];
|
|
$format = $stmt['format'] ?? null;
|
|
|
|
// PDF wenn Format=3 oder Daten mit %PDF- beginnen
|
|
if ($format === self::FORMAT_PDF || str_starts_with($data, '%PDF-')) {
|
|
$pdfs[] = $data;
|
|
}
|
|
}
|
|
return $pdfs;
|
|
}
|
|
|
|
/**
|
|
* Ermittelt die hoechste unterstuetzte HIEKAS-Version aus den BPD.
|
|
* Funktioniert auch mit AnonymousSegments (wenn unsere typisierten Klassen
|
|
* nicht zur Bank-Antwort passen).
|
|
*/
|
|
private function resolveHiekasVersion(BPD $bpd): int
|
|
{
|
|
// Erst typisierte Segmente versuchen
|
|
$hiekas = $bpd->getLatestSupportedParameters('HIEKAS');
|
|
if ($hiekas !== null) {
|
|
return $hiekas->getVersion();
|
|
}
|
|
|
|
// Fallback: Version aus anonymen BPD-Segmenten lesen
|
|
if (isset($bpd->parameters['HIEKAS'])) {
|
|
$versions = array_keys($bpd->parameters['HIEKAS']);
|
|
// Bereits absteigend sortiert (krsort in BPD::extractFromResponse)
|
|
$version = reset($versions);
|
|
error_log("[BankImport HKEKA] HIEKAS nur als AnonymousSegment verfuegbar, Version=" . $version);
|
|
return (int) $version;
|
|
}
|
|
|
|
throw new UnsupportedException('HIEKAS nicht in BPD gefunden');
|
|
}
|
|
|
|
/** {@inheritdoc} */
|
|
protected function createRequest(BPD $bpd, ?UPD $upd)
|
|
{
|
|
error_log("[BankImport HKEKA] createRequest() aufgerufen");
|
|
|
|
$version = $this->resolveHiekasVersion($bpd);
|
|
error_log("[BankImport HKEKA] HIEKAS Version=" . $version);
|
|
error_log("[BankImport HKEKA] Account IBAN=" . $this->account->getIban() . ", BIC=" . $this->account->getBic());
|
|
error_log("[BankImport HKEKA] Format=" . ($this->format ?: 'null (Bank-Standard)')
|
|
. ", Auszugsnummer=" . ($this->statementNumber ?: 'null')
|
|
. ", Jahr=" . ($this->year ?: 'null'));
|
|
|
|
switch ($version) {
|
|
case 3:
|
|
error_log("[BankImport HKEKA] Erstelle HKEKAv3 (KtvV3-basiert, BLZ)");
|
|
return HKEKAv3::create(
|
|
KtvV3::fromAccount($this->account),
|
|
$this->format,
|
|
$this->statementNumber,
|
|
$this->year
|
|
);
|
|
case 4:
|
|
error_log("[BankImport HKEKA] Erstelle HKEKAv4 (Kti-basiert, IBAN/BIC)");
|
|
return HKEKAv4::create(
|
|
Kti::fromAccount($this->account),
|
|
$this->format,
|
|
$this->statementNumber,
|
|
$this->year
|
|
);
|
|
case 5:
|
|
error_log("[BankImport HKEKA] Erstelle HKEKAv5 (Kti-basiert, IBAN/BIC)");
|
|
return HKEKAv5::create(
|
|
Kti::fromAccount($this->account),
|
|
$this->format,
|
|
$this->statementNumber,
|
|
$this->year
|
|
);
|
|
default:
|
|
error_log("[BankImport HKEKA] FEHLER: Nicht unterstuetzte Version " . $version);
|
|
throw new UnsupportedException('Nicht unterstuetzte HKEKA-Version: ' . $version);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loggt Diagnose-Informationen ueber anonyme HIEKA-Segmente in der Antwort.
|
|
* Wird aufgerufen wenn findSegments(HIEKA::class) leer ist, um die tatsaechliche
|
|
* Segment-Struktur der Bank zu analysieren.
|
|
*/
|
|
private function logAnonymousHiekaSegments(Message $response): void
|
|
{
|
|
foreach ($response->plainSegments as $seg) {
|
|
$name = $seg->getName();
|
|
if ($name !== 'HIEKA') {
|
|
continue;
|
|
}
|
|
$version = $seg->getVersion();
|
|
$class = get_class($seg);
|
|
error_log("[BankImport HKEKA] DIAGNOSE: Segment {$name}v{$version} class={$class}");
|
|
|
|
if ($seg instanceof AnonymousSegment) {
|
|
// Reflection um private 'elements' zu lesen
|
|
try {
|
|
$ref = new \ReflectionClass($seg);
|
|
$elProp = $ref->getProperty('elements');
|
|
$elProp->setAccessible(true);
|
|
$elements = $elProp->getValue($seg);
|
|
error_log("[BankImport HKEKA] DIAGNOSE: " . count($elements) . " Elemente im Segment");
|
|
foreach ($elements as $idx => $el) {
|
|
if ($el === null) {
|
|
error_log("[BankImport HKEKA] [{$idx}] NULL (leer)");
|
|
} elseif (is_array($el)) {
|
|
// DEG (Data Element Group)
|
|
$parts = array_map(function ($v) {
|
|
if ($v === null) return 'NULL';
|
|
$s = (string) $v;
|
|
return strlen($s) > 40 ? substr($s, 0, 40) . '...(' . strlen($s) . 'B)' : $s;
|
|
}, $el);
|
|
error_log("[BankImport HKEKA] [{$idx}] DEG: " . implode(' : ', $parts));
|
|
} else {
|
|
$val = (string) $el;
|
|
if (strlen($val) > 80) {
|
|
// Binaerdaten oder lange Strings kuerzen
|
|
$hex = bin2hex(substr($val, 0, 16));
|
|
error_log("[BankImport HKEKA] [{$idx}] BIN/LANG: " . strlen($val)
|
|
. " Bytes, Hex-Start=" . $hex
|
|
. ", Text-Start=" . substr($val, 0, 30));
|
|
} else {
|
|
error_log("[BankImport HKEKA] [{$idx}] " . $val);
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("[BankImport HKEKA] DIAGNOSE: Reflection fehlgeschlagen: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** {@inheritdoc} */
|
|
public function processResponse(Message $response)
|
|
{
|
|
error_log("[BankImport HKEKA] processResponse() aufgerufen");
|
|
|
|
parent::processResponse($response);
|
|
|
|
// Bank sendet 3010 wenn keine Auszuege verfuegbar
|
|
$isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
|
|
$responseSegments = $response->findSegments(HIEKA::class);
|
|
|
|
error_log("[BankImport HKEKA] isUnavailable=" . ($isUnavailable ? 'JA' : 'NEIN')
|
|
. ", HIEKA-Segmente=" . count($responseSegments)
|
|
. ", Request-Segment-Nummern=" . implode(',', $this->getRequestSegmentNumbers()));
|
|
|
|
// Alle Rueckmeldungen loggen
|
|
try {
|
|
$rueckmeldungen = $response->findSegments(\Fhp\Segment\HIRMS\HIRMSv2::class);
|
|
foreach ($rueckmeldungen as $hirms) {
|
|
foreach ($hirms->rueckmeldung as $rm) {
|
|
error_log("[BankImport HKEKA] Rueckmeldung: Code=" . $rm->rueckmeldungscode
|
|
. " Ref=" . $rm->bezugsdatenelement
|
|
. " Text=" . $rm->rueckmeldungstext);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("[BankImport HKEKA] Rueckmeldungen konnten nicht gelesen werden: " . $e->getMessage());
|
|
}
|
|
|
|
// Wenn keine typisierten HIEKA-Segmente gefunden: Diagnose-Logging
|
|
if (count($responseSegments) === 0) {
|
|
error_log("[BankImport HKEKA] Keine typisierten HIEKA-Segmente, pruefe anonyme...");
|
|
$this->logAnonymousHiekaSegments($response);
|
|
}
|
|
|
|
if (!$isUnavailable && count($responseSegments) === 0 && count($this->getRequestSegmentNumbers()) > 0) {
|
|
error_log("[BankImport HKEKA] FEHLER: Keine HIEKA-Segmente in Antwort!");
|
|
throw new UnexpectedResponseException('Keine HIEKA-Antwort-Segmente erhalten!');
|
|
}
|
|
|
|
/** @var HIEKA $hieka */
|
|
foreach ($responseSegments as $segIdx => $hieka) {
|
|
error_log("[BankImport HKEKA] Verarbeite HIEKA-Segment " . ($segIdx + 1) . "/" . count($responseSegments));
|
|
|
|
$format = $hieka->getKontoauszugsformat();
|
|
error_log("[BankImport HKEKA] Format=" . ($format ?: 'null')
|
|
. " (1=MT940, 2=ISO8583, 3=PDF)");
|
|
|
|
$data = $hieka->getKontoauszug()->getData();
|
|
$rawLen = strlen($data);
|
|
$rawStart = substr($data, 0, 20);
|
|
error_log("[BankImport HKEKA] Rohdaten: " . $rawLen . " Bytes, Anfang='" . $rawStart
|
|
. "', Hex=" . bin2hex(substr($data, 0, 10)));
|
|
|
|
// Pruefen ob Base64-kodiert
|
|
if ($format === self::FORMAT_PDF && !str_starts_with($data, '%PDF-')) {
|
|
error_log("[BankImport HKEKA] PDF-Format aber beginnt NICHT mit %PDF-, pruefe Base64...");
|
|
$decoded = base64_decode($data, true);
|
|
if ($decoded !== false && str_starts_with($decoded, '%PDF-')) {
|
|
error_log("[BankImport HKEKA] Base64-Dekodierung erfolgreich! " . strlen($decoded) . " Bytes");
|
|
$data = $decoded;
|
|
} else {
|
|
error_log("[BankImport HKEKA] WARNUNG: Base64-Dekodierung fehlgeschlagen, verwende Rohdaten");
|
|
}
|
|
} elseif ($format === null && !str_starts_with($data, '%PDF-')) {
|
|
// Kein Format angegeben, trotzdem Base64 pruefen
|
|
$decoded = base64_decode($data, true);
|
|
if ($decoded !== false && str_starts_with($decoded, '%PDF-')) {
|
|
error_log("[BankImport HKEKA] Ohne Format-Angabe: Base64-PDF erkannt! " . strlen($decoded) . " Bytes");
|
|
$data = $decoded;
|
|
$format = self::FORMAT_PDF;
|
|
}
|
|
}
|
|
|
|
// Quittung pruefen
|
|
$quittung = $hieka->getQuittung();
|
|
if ($quittung !== null) {
|
|
error_log("[BankImport HKEKA] Quittung vorhanden: " . strlen($quittung->getData()) . " Bytes");
|
|
}
|
|
|
|
if (!empty($data)) {
|
|
$this->statements[] = [
|
|
'data' => $data,
|
|
'format' => $format,
|
|
];
|
|
error_log("[BankImport HKEKA] Statement hinzugefuegt (Format=" . ($format ?: 'unbekannt')
|
|
. ", gesamt: " . count($this->statements) . ")");
|
|
} else {
|
|
error_log("[BankImport HKEKA] WARNUNG: Leere Daten, uebersprungen");
|
|
}
|
|
}
|
|
|
|
error_log("[BankImport HKEKA] processResponse() fertig, " . count($this->statements) . " Statements gesammelt");
|
|
}
|
|
}
|