← Code-Übersicht

LexofficeApiAdapter.php

Pfad: src/Infrastructure/Rechnung/LexofficeApiAdapter.php

Ext: php

Größe: 6941 Bytes

Geändert: 2026-07-16T14:13:31+02:00

Frühere Version vom 2026-07-16T14:13:31+02:00 · zur aktuellen Fassung

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Rechnung;

use Demo\Domain\Rechnung\Amount;
use Demo\Domain\Rechnung\ExtractedInvoiceData;
use Demo\Domain\Rechnung\LexofficePort;
use Demo\Domain\Rechnung\PostingResult;

/**
 * Lexoffice-API-Adapter mit konfigurierbaren Endpunkten.
 */
final class LexofficeApiAdapter implements LexofficePort
{
    /**
     * @param string  $baseUrl              API-Basis.
     * @param string  $apiKey               Bearer-Token.
     * @param int     $rateLimitPerSecond   Max. RPS.
     * @param array<string,string> $paths    Konfigurierbare Pfade.
     */
    public function __construct(
        private string $baseUrl,
        private string $apiKey,
        private int $rateLimitPerSecond = 2,
        private array $paths = []
    ) {
        if ($this->paths === []) {
            $this->paths = [
                'contact_search' => '/v1/contacts',
                'contacts' => '/v1/contacts',
                'post_invoice' => '/v1/billing-documents',
                'attachment' => '/v1/voucher/{voucherId}/files',
            ];
        }
    }

    /**
     * @inheritDoc
     */
    public function findContactByName(string $vendorName): array
    {
        $query = $this->paths['contact_search'] . '?query=' . rawurlencode($vendorName);
        $response = $this->request('GET', $query);

        if (isset($response['content']) && is_array($response['content'])) {
            return $response['content'];
        }

        if (isset($response['_embedded']['contacts']) && is_array($response['_embedded']['contacts'])) {
            return $response['_embedded']['contacts'];
        }

        return [];
    }

    /**
     * @inheritDoc
     */
    public function createContact(string $vendorName, string $vendorAddressSignature): string
    {
        $payload = [
            'name' => $vendorName,
            'street' => $vendorAddressSignature,
        ];

        $response = $this->request('POST', $this->paths['contacts'], $payload);
        if (!is_array($response)) {
            throw new \RuntimeException('Leere Antwort beim Kontakt-Anlegen');
        }

        foreach (['id', 'contactId', 'uuid'] as $key) {
            if (isset($response[$key])) {
                return (string) $response[$key];
            }
        }

        throw new \RuntimeException('Kontakt-Anlage ohne ID');
    }

    /**
     * @inheritDoc
     */
    public function postInvoice(ExtractedInvoiceData $invoice, string $contactId, string $pdfPath): PostingResult
    {
        $payload = [
            'type' => 'salesinvoice',
            'documentDate' => $invoice->invoiceDate,
            'voucherNumber' => $invoice->invoiceNumber,
            'partner' => ['id' => $contactId],
            'totalNetAmount' => $invoice->amount->netto,
            'totalTaxAmount' => $invoice->amount->steuer,
            'totalGrossAmount' => $invoice->amount->brutto,
            'currency' => $invoice->amount->währung,
            'status' => 'final',
        ];

        $result = $this->request('POST', $this->paths['post_invoice'], $payload);
        $voucherId = null;
        foreach (['id', 'voucherId', 'documentId'] as $candidate) {
            if (isset($result[$candidate])) {
                $voucherId = (string) $result[$candidate];
                break;
            }
        }

        if ($voucherId === null) {
            throw new \RuntimeException('Buchung ohne Voucher-ID');
        }

        $this->uploadAttachment($voucherId, $pdfPath);

        return new PostingResult($voucherId, 'final', $result['fileId'] ?? null);
    }

    /**
     * @param string $method HTTP-Methode.
     * @param string $path   Pfad relativ zu baseUrl.
     * @param array  $payload JSON-Payload.
     * @return array JSON-Zurueckgabe.
     */
    private function request(string $method, string $path, array $payload = []): array
    {
        static $last = 0;
        $now = microtime(true);
        if ($this->rateLimitPerSecond > 0) {
            $minGap = 1 / $this->rateLimitPerSecond;
            $sleep = $last + $minGap - $now;
            if ($sleep > 0) {
                usleep((int) round($sleep * 1_000_000));
            }
            $last = microtime(true);
        }

        $url = rtrim($this->baseUrl, '/') . '/' . ltrim($path, '/');
        $ch = curl_init($url);
        if ($ch === false) {
            throw new \RuntimeException('Lexoffice: cURL konnte nicht initialisiert werden');
        }

        $methodUpper = strtoupper($method);
        $headers = [
            'Authorization: Bearer ' . $this->apiKey,
            'Accept: application/json',
        ];

        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);
        if ($methodUpper === 'GET') {
            curl_setopt($ch, CURLOPT_HTTPGET, true);
        } else {
            curl_setopt($ch, CURLOPT_POST, true);

            $filePath = $payload['filePath'] ?? null;
            if (is_string($filePath) && is_file($filePath)) {
                $cFile = new \CURLFile($filePath, 'application/pdf', basename($filePath));
                curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => $cFile]);
            } else {
                $encoded = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
                curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded);
                $headers[] = 'Content-Type: application/json';
            }
        }

        curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_unique($headers)));
        $response = curl_exec($ch);
        if (!is_string($response)) {
            $error = (string) curl_error($ch);
            curl_close($ch);
            throw new \RuntimeException('Lexoffice-Request fehlgeschlagen: ' . $error);
        }

        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode >= 400) {
            throw new \RuntimeException(
                'Lexoffice-Request fehlgeschlagen: '
                . $httpCode . ' - '
                . substr($response, 0, 400)
            );
        }

        if ($response === '') {
            return [];
        }

        $decoded = json_decode($response, true);
        if (!is_array($decoded)) {
            throw new \RuntimeException(
                'Lexoffice-Request: unerwartete Nicht-JSON-Antwort bei ' . $methodUpper . ' ' . $path
            );
        }

        return $decoded;
    }

    /**
     * @param string $voucherId Gutschein-ID.
     * @param string $pdfPath Pfad.
     */
    private function uploadAttachment(string $voucherId, string $pdfPath): void
    {
        if (!is_file($pdfPath)) {
            return;
        }

        $path = str_replace('{voucherId}', $voucherId, $this->paths['attachment']);
        $payload = ['filePath' => $pdfPath];
        $this->request('POST', $path, $payload);
    }
}