HttpWetellSourceAdapter.php
Pfad: src/Infrastructure/Rechnung/HttpWetellSourceAdapter.php
Ext: php
Größe: 7918 Bytes
Geändert: 2026-07-16 18:20:06+02
<?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): \Demo\Domain\Rechnung\WetellSessionHandle
{
$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 \Demo\Domain\Rechnung\WetellAuthenticationException('WEtell-Anmeldung nicht bestaetigt');
}
$this->logger->logStep('portal', 'login', 'DONE', ['adapter' => 'http']);
return \Demo\Domain\Rechnung\WetellSessionHandle::issue($this);
}
/** @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');
}
}
Frühere Versionen
| Version | Zeitpunkt | Operation |
|---|---|---|
| 61 | 2026-07-16 20:20:52.018728+02 | UPDATE |
| 55 | 2026-07-16 20:18:22.873047+02 | UPDATE |
| 40 | 2026-07-16 19:55:01.888554+02 | UPDATE |
| 10 | 2026-07-16 17:21:44.384907+02 | UPDATE |