← Code-Übersicht

HttpWetellSourceAdapter.php

Pfad: src/Infrastructure/Rechnung/HttpWetellSourceAdapter.php

Ext: php

Größe: 7883 Bytes

Geändert: 2026-07-16T18:17:31+02:00

Frühere Version vom 2026-07-16T18:17:31+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): \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 RuntimeException('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');
    }
}