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);
}
}