← Code-Übersicht

LexofficeApiAdapter.php

Pfad: src/Infrastructure/Rechnung/LexofficeApiAdapter.php

Ext: php

Größe: 6022 Bytes

Geändert: 2026-07-16 17:41:12+02

<?php
declare(strict_types=1);

namespace Demo\Infrastructure\Rechnung;

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

final class LexofficeApiAdapter implements LexofficePort
{
    private readonly LexwareTransportPort $transport;
    private readonly LexwareInvoicePayloadFactory $payloadFactory;
    private readonly string $voucherStatus;

    /** @param array<string,mixed> $paths */
    public function __construct(
        string $baseUrl,
        string $apiKey,
        int $rateLimitPerSecond = 2,
        array $paths = [],
        ?LexwareTransportPort $transport = null,
    ) {
        $baseUrl = str_replace('https://api.lexoffice.io', 'https://api.lexware.io', rtrim($baseUrl, '/'));
        if ($baseUrl !== 'https://api.lexware.io') {
            throw new RuntimeException('Nur die offizielle Lexware-API ist erlaubt');
        }
        $category = $paths['postingCategoryId'] ?? null;
        $categoryId = is_string($category) && preg_match('/^[0-9a-f-]{36}$/i', $category) === 1
            ? $category
            : null;
        $this->payloadFactory = new LexwareInvoicePayloadFactory($categoryId);
        $this->voucherStatus = $this->payloadFactory->voucherStatus();
        $this->transport = $transport ?? new CurlLexwareTransport($baseUrl, $apiKey, $rateLimitPerSecond);
    }

    /** @return list<array<string,mixed>> */
    public function findContactByName(string $vendorName): array
    {
        $response = $this->transport->request('GET', '/v1/contacts', ['name' => $vendorName]);
        $content = $response['content'] ?? [];
        if (!is_array($content)) {
            throw new RuntimeException('Lexware-Kontaktliste ist ungueltig');
        }
        return array_values(array_filter($content, 'is_array'));
    }

    public function createContact(string $vendorName, string $vendorAddressSignature): string
    {
        [$street, $zip, $city, $country] = $this->address($vendorAddressSignature);
        $response = $this->transport->request('POST', '/v1/contacts', [], [
            'version' => 0,
            'roles' => ['vendor' => (object) []],
            'company' => ['name' => $vendorName],
            'addresses' => ['billing' => [[
                'street' => $street,
                'zip' => $zip,
                'city' => $city,
                'countryCode' => $country,
            ]]],
        ]);
        return $this->requiredId($response, 'Kontakt');
    }

    public function postInvoice(ExtractedInvoiceData $invoice, string $contactId, string $pdfPath): PostingResult
    {
        $this->assertPdf($pdfPath);
        $data = $invoice->toArray();
        $voucherId = $this->existingVoucher($data, $contactId);
        if ($voucherId === null) {
            $payload = $this->payloadFactory->create($data, $contactId);
            $voucherId = $this->requiredId(
                $this->transport->request('POST', '/v1/vouchers', [], $payload),
                'Voucher',
            );
        }
        $file = $this->transport->request('POST', '/v1/vouchers/' . rawurlencode($voucherId) . '/files', [], null, $pdfPath);
        $fileId = $this->requiredId($file, 'Voucher-Datei');
        return new PostingResult($voucherId, strtoupper($this->voucherStatus), $fileId);
    }

    /** @param array<string,mixed> $data */
    private function existingVoucher(array $data, string $contactId): ?string
    {
        $number = (string) $data['invoiceNumber'];
        $response = $this->transport->request('GET', '/v1/voucherlist', [
            'voucherType' => 'purchaseinvoice',
            'voucherStatus' => 'any',
            'voucherNumber' => $number,
        ]);
        $content = $response['content'] ?? [];
        if (!is_array($content)) {
            throw new RuntimeException('Lexware-Voucherliste ist ungueltig');
        }
        $match = null;
        foreach ($content as $item) {
            if (!is_array($item) || (string) ($item['voucherNumber'] ?? '') !== $number) {
                continue;
            }
            $same = (string) ($item['contactId'] ?? '') === $contactId
                && LexwareMoney::format((string) ($item['totalAmount'] ?? '')) === LexwareMoney::format((string) $data['brutto']);
            if (!$same || $match !== null) {
                throw new RuntimeException('Lexware-Duplikatkonflikt fuer Rechnungsnummer ' . $number);
            }
            $match = $this->requiredId($item, 'bestehender Voucher');
        }
        return $match;
    }

    /** @return array{string,string,string,string} */
    private function address(string $signature): array
    {
        $parts = array_map('trim', explode('|', $signature));
        if (count($parts) !== 3 || preg_match('/^(\d{5})\s+(.+)$/u', $parts[1], $match) !== 1) {
            throw new RuntimeException('Lieferantenadresse ist nicht strukturiert');
        }
        $country = strtoupper($parts[2]);
        if (preg_match('/^[A-Z]{2}$/', $country) !== 1 || $parts[0] === '') {
            throw new RuntimeException('Lieferantenadresse ist ungueltig');
        }
        return [$parts[0], $match[1], $match[2], $country];
    }

    /** @param array<string,mixed> $response */
    private function requiredId(array $response, string $resource): string
    {
        foreach (['id', 'fileId'] as $key) {
            if (isset($response[$key]) && is_string($response[$key]) && $response[$key] !== '') {
                return $response[$key];
            }
        }
        throw new RuntimeException($resource . '-ID fehlt');
    }

    private function assertPdf(string $path): void
    {
        $size = is_file($path) ? filesize($path) : false;
        $prefix = is_file($path) ? file_get_contents($path, false, null, 0, 5) : false;
        if (!is_int($size) || $size < 5 || $size > 5_000_000 || $prefix !== '%PDF-') {
            throw new RuntimeException('Voucher-Datei ist kein gueltiges PDF');
        }
    }
}

Frühere Versionen

Version Zeitpunkt Operation
43 2026-07-16 19:55:01.88987+02 UPDATE