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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user