HttpWetellSourceAdapter.php
Pfad: src/Infrastructure/Rechnung/HttpWetellSourceAdapter.php
Ext: php
Größe: 7428 Bytes
Geändert: 2026-07-16T17:38:52+02:00
Frühere Version vom 2026-07-16T17:38:52+02:00 · zur aktuellen Fassung
<?php
declare(strict_types=1);
namespace Demo\Infrastructure\Rechnung;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use Demo\Domain\Rechnung\InvoiceLoggerPort;
use Demo\Domain\Rechnung\SourceInvoice;
use Demo\Domain\Rechnung\WetellSourcePort;
use RuntimeException;
/**
* Direkter, redirect-gepruefter HTTP-Fallback fuer WEtell.
*/
final class HttpWetellSourceAdapter implements WetellSourcePort
{
private readonly string $cookieFile;
private readonly WetellUrlPolicy $policy;
/** Bindet Portalvertrag, Formularfelder und Logger. */
public function __construct(private readonly string $loginUrl, private readonly string $invoicesUrl, string $listSelector, private readonly string $usernameField, private readonly string $passwordField, private readonly InvoiceLoggerPort $logger, private readonly ?string $userAgent = null)
{
unset($listSelector);
$this->policy = new WetellUrlPolicy($invoicesUrl);
$this->policy->assert($loginUrl);
$temp = tempnam(sys_get_temp_dir(), 'wetell-cookie-');
if ($temp === false) {
throw new RuntimeException('Cookie-Datei fehlt');
}
$this->cookieFile = $temp;
}
/** Entfernt den lokalen Sessioncookie. */
public function __destruct() { @unlink($this->cookieFile); }
/** Meldet sich ueber die tatsaechliche Formularaction an. */
public function login(string $username, string $password): void
{
$page = $this->request('GET', $this->loginUrl);
[$action, $fields] = $this->form($page->body);
$fields[$this->usernameField] = $username;
$fields[$this->passwordField] = $password;
$result = $this->request('POST', $this->policy->resolve($action, $this->loginUrl), $fields);
if ($result->status >= 400 || preg_match('/type=["\']password["\']/i', $result->body) === 1) {
throw new RuntimeException('WEtell-Anmeldung nicht bestaetigt');
}
$this->logger->logStep('portal', 'login', 'DONE', ['adapter' => 'http']);
}
/** @return list<SourceInvoice> Gefundene PDF-Links. */
public function discoverInvoices(): array
{
$response = $this->request('GET', $this->invoicesUrl);
$xpath = $this->xpath($response->body);
$result = [];
foreach ($this->elements($xpath, '//a[@href]') as $node) {
$href = $node->getAttribute('href');
if ($href === '' || !str_contains(strtolower($href), 'pdf')) {
continue;
}
$url = $this->policy->resolve($href, $this->invoicesUrl);
$text = trim((string) $node->textContent);
preg_match('/\b(RF\d{6,})\b/i', $text, $number);
preg_match('/\b(\d{2}\.\d{2}\.\d{4})\b/', $text, $date);
$dateHint = isset($date[1]) ? $this->date($date[1]) : null;
$result[] = new SourceInvoice(hash('sha256', $url), 'WEtell GmbH', $url, $dateHint, isset($number[1]) ? strtoupper($number[1]) : null);
}
$this->logger->logStep('portal', 'discover', 'DONE', ['count' => count($result), 'adapter' => 'http']);
return $result;
}
/** Laedt und prueft PDF-Bytes. */
public function downloadInvoicePdf(string $downloadUrl): string
{
$response = $this->request('GET', $this->policy->assert($downloadUrl));
if (!str_starts_with($response->body, '%PDF-') || str_contains(strtolower($response->contentType), 'text/html')) {
throw new RuntimeException('WEtell-Download ist kein PDF');
}
return $response->body;
}
/** @param array<string,string> $postData Fuehrt bis zu fuenf validierte Redirects aus. */
private function request(string $method, string $url, array $postData = []): HttpResponse
{
for ($redirects = 0; $redirects <= 5; $redirects++) {
$response = $this->singleRequest($method, $this->policy->assert($url), $postData);
if ($response->status < 300 || $response->status >= 400 || $response->location === null) {
return $response;
}
$url = $this->policy->resolve($response->location, $url);
$method = $response->status === 307 || $response->status === 308 ? $method : 'GET';
$postData = $method === 'POST' ? $postData : [];
}
throw new RuntimeException('Zu viele WEtell-Redirects');
}
/** @param array<string,string> $postData Fuehrt genau einen HTTP-Aufruf aus. */
private function singleRequest(string $method, string $url, array $postData): HttpResponse
{
$headers = [];
$ch = curl_init($url);
if ($ch === false) { throw new RuntimeException('WEtell-cURL fehlt'); }
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_COOKIEFILE => $this->cookieFile, CURLOPT_COOKIEJAR => $this->cookieFile, CURLOPT_TIMEOUT => 30, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => $this->userAgent ?? 'Demo-Invoice/1.0', CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int { $parts = explode(':', $line, 2); if (count($parts) === 2) { $headers[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); }]);
if ($method === 'POST') { curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($postData)]); }
$body = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $type = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $error = curl_error($ch); curl_close($ch);
if (!is_string($body) || $status >= 400) { throw new RuntimeException('WEtell HTTP ' . $status . ' ' . $error); }
return new HttpResponse($status, $body, $type, isset($headers['location']) ? (string) $headers['location'] : null);
}
/** @return array{string,array<string,string>} Liest Action und Hidden-Felder. */
private function form(string $html): array
{
$xpath = $this->xpath($html); $form = $this->elements($xpath, '//form[.//input[@type="password"]]')[0] ?? null;
if ($form === null) { throw new RuntimeException('Loginformular fehlt'); }
$fields = [];
foreach ($this->elements($xpath, './/input[@name]', $form) as $input) { $name = $input->getAttribute('name'); if ($name !== '') { $fields[$name] = $input->getAttribute('value'); } }
$action = $form->getAttribute('action');
return [$action === '' ? $this->loginUrl : $action, $fields];
}
/** @return list<DOMElement> */
private function elements(DOMXPath $xpath, string $expression, ?DOMNode $context = null): array
{
$nodes = $xpath->query($expression, $context);
if ($nodes === false) { throw new RuntimeException('Ungueltiger XPath-Ausdruck'); }
$elements = [];
foreach ($nodes as $node) { if ($node instanceof DOMElement) { $elements[] = $node; } }
return $elements;
}
/** Erzeugt einen fehlertoleranten XPath-Kontext. */
private function xpath(string $html): DOMXPath { $document = new DOMDocument(); @$document->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); return new DOMXPath($document); }
/** Normalisiert einen deutschen Datumshinweis. */
private function date(string $raw): ?string { $date = \DateTimeImmutable::createFromFormat('!d.m.Y', $raw); return $date === false ? null : $date->format('Y-m-d'); }
}