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 |