Billing Phase 3: Dolibarr-Rechnungssynchronisation

Bei erfolgreicher Stripe-Zahlung (invoice.paid) wird automatisch ein
Dolibarr-Kunde ermittelt/angelegt und eine validierte Rechnung als
Buchhaltungsspiegel erzeugt. Dolibarr-Fehler blockieren die
Stripe-Webhook-Verarbeitung nicht, sondern landen im Audit-Log zur
manuellen Nachbearbeitung. Getestet gegen die produktive
Dolibarr-Instanz des Kunden (kein Sandbox verfuegbar) mit einem
rechtebeschraenkten API-Key und einem nicht validierten Test-Datensatz.
This commit is contained in:
2026-07-17 10:35:01 +02:00
parent 80da042cae
commit 73d599f85b
3 changed files with 275 additions and 34 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
function dolibarr_base_url(): string
{
$url = getenv('DOLIBARR_URL');
if ($url === false || $url === '') {
throw new RuntimeException('DOLIBARR_URL ist nicht gesetzt.');
}
return rtrim($url, '/');
}
function dolibarr_api_key(): string
{
$key = getenv('DOLIBARR_API_KEY');
if ($key === false || $key === '') {
throw new RuntimeException('DOLIBARR_API_KEY ist nicht gesetzt.');
}
return $key;
}
/**
* @return array{ok: bool, status: int, data: array, error: ?string}
*/
function dolibarr_request(string $method, string $path, ?array $jsonBody = null): array
{
$headers = 'DOLAPIKEY: ' . dolibarr_api_key() . "\r\nAccept: application/json\r\n";
$content = null;
if ($jsonBody !== null) {
$content = json_encode($jsonBody, JSON_THROW_ON_ERROR);
$headers .= "Content-Type: application/json\r\n";
}
$context = stream_context_create([
'http' => [
'method' => $method,
'timeout' => 20,
'ignore_errors' => true,
'header' => $headers,
'content' => $content,
],
]);
$url = dolibarr_base_url() . '/api/index.php' . $path;
$body = @file_get_contents($url, false, $context);
$status = 0;
foreach ($http_response_header ?? [] as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $matches) === 1) {
$status = (int)$matches[1];
}
}
if ($body === false) {
$error = error_get_last();
return ['ok' => false, 'status' => $status, 'data' => [], 'error' => $error['message'] ?? 'Unbekannter HTTP-Fehler'];
}
$decoded = json_decode($body, true);
if ($status < 200 || $status >= 300) {
$message = is_array($decoded) ? ($decoded['error']['message'] ?? $body) : $body;
return ['ok' => false, 'status' => $status, 'data' => is_array($decoded) ? $decoded : [], 'error' => (string)$message];
}
// Dolibarr's create-endpoints return a bare numeric id, not JSON.
if (!is_array($decoded)) {
return ['ok' => true, 'status' => $status, 'data' => ['id' => trim($body, "\" \n")], 'error' => null];
}
return ['ok' => true, 'status' => $status, 'data' => $decoded, 'error' => null];
}
/**
* Resolves the Dolibarr thirdparty (customer) id for a tenant, creating it
* on first use and caching it in tenant_billing.dolibarr_thirdparty_id.
*/
function dolibarr_find_or_create_thirdparty(PDO $pdo, int $tenantId): ?int
{
$billing = billing_fetch_or_init($pdo, $tenantId);
if (!empty($billing['dolibarr_thirdparty_id'])) {
return (int)$billing['dolibarr_thirdparty_id'];
}
$stmt = $pdo->prepare('SELECT name FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$tenantName = (string)($stmt->fetchColumn() ?: "Mandant #{$tenantId}");
$stmt = $pdo->prepare(
"SELECT u.email FROM users u
INNER JOIN tenant_memberships tm ON tm.user_id = u.id
WHERE tm.tenant_id = ? AND tm.role = 'owner' AND tm.status = 'active'
ORDER BY tm.id ASC LIMIT 1"
);
$stmt->execute([$tenantId]);
$ownerEmail = $stmt->fetchColumn();
$result = dolibarr_request('POST', '/thirdparties', [
'name' => "Kaffeeliste - {$tenantName}",
'client' => 1,
'code_client' => 'auto',
'email' => $ownerEmail !== false ? (string)$ownerEmail : '',
'note_private' => "Automatisch angelegt durch die Kaffeeliste-App fuer Mandant #{$tenantId}.",
]);
if (!$result['ok']) {
return null;
}
$thirdpartyId = (int)$result['data']['id'];
billing_update($pdo, $tenantId, ['dolibarr_thirdparty_id' => (string)$thirdpartyId]);
return $thirdpartyId;
}
/**
* Creates a single-line invoice for a thirdparty and validates it
* immediately (assigns the permanent, sequential invoice number). Intended
* to be called only for genuine, already-collected Stripe payments.
*
* @return array{ok: bool, invoice_id: ?int, ref: ?string, error: ?string}
*/
function dolibarr_create_and_validate_invoice(int $thirdpartyId, string $description, int $amountCents): array
{
$created = dolibarr_request('POST', '/invoices', [
'socid' => $thirdpartyId,
'type' => 0,
'note_private' => 'Automatisch erzeugt durch die Kaffeeliste-App aus einer erfolgreichen Stripe-Zahlung.',
]);
if (!$created['ok']) {
return ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => 'Rechnung anlegen fehlgeschlagen: ' . $created['error']];
}
$invoiceId = (int)$created['data']['id'];
$line = dolibarr_request('POST', "/invoices/{$invoiceId}/lines", [
'desc' => $description,
'qty' => 1,
'subprice' => round($amountCents / 100, 2),
// Kleinunternehmer nach § 19 UStG: keine Umsatzsteuer ausgewiesen.
'tva_tx' => 0,
]);
if (!$line['ok']) {
return ['ok' => false, 'invoice_id' => $invoiceId, 'ref' => null, 'error' => 'Rechnungsposition anlegen fehlgeschlagen: ' . $line['error']];
}
$validated = dolibarr_request('POST', "/invoices/{$invoiceId}/validate", []);
if (!$validated['ok']) {
return ['ok' => false, 'invoice_id' => $invoiceId, 'ref' => null, 'error' => 'Rechnung validieren fehlgeschlagen: ' . $validated['error']];
}
$fetched = dolibarr_request('GET', "/invoices/{$invoiceId}");
$ref = $fetched['ok'] ? (string)($fetched['data']['ref'] ?? '') : null;
return ['ok' => true, 'invoice_id' => $invoiceId, 'ref' => $ref, 'error' => null];
}
/**
* Orchestrates the Dolibarr side of a successful Stripe payment: resolves
* (or creates) the tenant's Dolibarr customer, then creates and validates
* a mirrored invoice. Stripe remains the sole payment channel; Dolibarr is
* populated purely as a bookkeeping mirror, never invoked to collect money.
*
* @return array{ok: bool, invoice_id: ?int, ref: ?string, error: ?string}
*/
function dolibarr_sync_invoice_paid(PDO $pdo, int $tenantId, string $planCode, int $amountCents): array
{
$thirdpartyId = dolibarr_find_or_create_thirdparty($pdo, $tenantId);
if ($thirdpartyId === null) {
return ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => 'Dolibarr-Kunde konnte nicht ermittelt/angelegt werden.'];
}
$plans = billing_plans();
$planLabel = $plans[$planCode]['label'] ?? $planCode;
$description = "Kaffeeliste SaaS-Abo - Tarif {$planLabel} - " . date('m/Y');
return dolibarr_create_and_validate_invoice($thirdpartyId, $description, $amountCents);
}