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);
}
+66 -32
View File
@@ -147,43 +147,77 @@ klicken, wenn `billing_check_tenant()` einen höheren Bedarf anzeigt).
- Von Test- auf Live-API-Keys wechseln, sobald ein echter Stripe-Account
mit Bankverbindung eingerichtet ist.
## Phase 3: Dolibarr-Anbindung (noch offen)
## Phase 3: Dolibarr-Anbindung (umgesetzt)
**Korrektur (2026-07-16):** Der Kunde betreibt nur eine einzige
Dolibarr-Instanz, und das ist die produktive für sein anderes Business
keine separate Sandbox verfügbar. Der ursprüngliche Plan „erst gegen
Sandbox testen" ist damit hinfällig; stattdessen wird sicher **innerhalb**
der einen produktiven Instanz getestet:
**Ausgangslage:** Der Kunde betreibt nur eine einzige Dolibarr-Instanz,
und das ist die produktive für sein anderes Business keine separate
Sandbox verfügbar (Dolibarr 23.0.3, `https://verwaltung.ctb-it.de`).
Der ursprüngliche Plan „erst gegen Sandbox testen" wurde daher ersetzt
durch sicheres Testen **innerhalb** der einen produktiven Instanz.
- **Eigener, rechtebeschränkter API-Key**: Der Kunde legt einen
dedizierten Dolibarr-Benutzer/API-Key nur mit den für die Integration
nötigen Rechten an (Thirdparty + Invoice lesen/erstellen), statt seinen
bestehenden Admin-Key zu nutzen. Begrenzt den Schaden, falls der Key
je kompromittiert wird.
- **Test nur über Entwurfs-Rechnungen (Draft)**: Dolibarr-Rechnungen im
Entwurfsstatus haben noch keine fortlaufende, GoBD-relevante
Rechnungsnummer und lassen sich rückstandsfrei löschen. Die
Integration wird gegen einen eindeutig benannten Test-Kunden
(z. B. „TEST Kaffeeliste Integration") getestet; die dabei erzeugte
Entwurfs-Rechnung wird **nicht validiert**, sondern nach Prüfung der
Feldinhalte über die API wieder gelöscht.
- Erst wenn dieser Test erfolgreich war, wird der Live-Betrieb
freigeschaltet (echte Stripe-`invoice.paid`-Events lösen dann echte
Rechnungserstellung bei echten Kunden aus).
Umgesetzte Dateien:
Geplanter Umfang der Anbindung selbst bleibt unverändert:
```text
app/dolibarr.php
stripe-webhook.php (invoice.paid ruft dolibarr_sync_invoice_paid())
```
- Bei jedem erfolgreichen Stripe-Zahlungsereignis (`invoice.paid`) über
die Dolibarr-REST-API eine Rechnung im bestehenden Dolibarr des Kunden
anlegen (`dolibarr_thirdparty_id` verknüpft den Mandanten mit dem
Dolibarr-Kunden).
- `app/dolibarr.php`: minimaler REST-Client nach demselben Muster wie
`app/stripe.php` (PHP-Streams, kein SDK). `dolibarr_find_or_create_thirdparty()`
legt bei Erstkontakt einen Dolibarr-Kunden für den Mandanten an
(`Kaffeeliste - <Mandantenname>`, E-Mail des Owners) und cached die
Dolibarr-Kunden-ID in `tenant_billing.dolibarr_thirdparty_id`.
`dolibarr_create_and_validate_invoice()` legt eine Rechnung mit einer
Freitext-Position an (kein eigenes Dolibarr-Produkt pro Tarif nötig),
fügt die Position hinzu und validiert die Rechnung sofort (vergibt die
permanente, fortlaufende Nummer). `tva_tx = 0` durchgängig, da
Kleinunternehmer nach § 19 UStG.
- `stripe-webhook.php`: `invoice.paid` löst jetzt zusätzlich zum
Audit-Log-Eintrag `dolibarr_sync_invoice_paid()` aus. Ein Fehlschlag
dort (Netzwerk, Dolibarr down, ungültige Daten) wird **nicht** an
Stripe als Fehler zurückgemeldet Stripe hat das Geld bereits
erfolgreich eingezogen, das ist unabhängig vom Dolibarr-Spiegel. Der
Fehler landet stattdessen als `billing.dolibarr_invoice_failed` im
Mandanten-Audit-Log (sichtbar sowohl unter „Protokoll" in
Mandant-Einstellungen als auch im Back-Office) zur manuellen
Nachbearbeitung.
- Rechnungen werden **automatisch validiert** (nicht als Entwurf für
manuelle Prüfung liegen gelassen) bewusste Entscheidung des Kunden,
um den Prozess vollautomatisch zu halten.
- Das automatische **Erfassen der Zahlung** in Dolibarr (Rechnung als
bezahlt markieren) ist bewusst **nicht** umgesetzt erfordert
Kenntnis der Bankkonto-/Zahlungsart-IDs der Dolibarr-Instanz. Rechnungen
erscheinen offen und werden vom Kunden manuell abgeglichen.
- Stripe bleibt alleiniger Zahlungsweg; Dolibarr wird nur als Buchhaltungs-
Spiegel befüllt, nicht selbst zum Auslösen von Zahlungen verwendet.
**Offen, bevor programmiert werden kann:**
### Live getestet
- Dolibarr-Basis-URL und der neue, rechtebeschränkte API-Key.
- Dolibarr-Version (API-Feldnamen unterscheiden sich leicht je Version).
- Ob pro Kaffeeliste-Tarif ein eigenes Dolibarr-Produkt angelegt wird
oder eine einzelne generische Rechnungsposition mit Freitext-
Beschreibung + Betrag aus Stripe genügt.
- **Verbindung/Lesezugriff**: `GET /status`, `/thirdparties`,
`/invoices` gegen die echte Instanz erfolgreich geprüft (Version,
Erreichbarkeit, Rechte des API-Keys).
- **Schreibzugriff (mit expliziter Freigabe des Kunden)**: ein eindeutig
benannter Test-Kunde („TEST Kaffeeliste Integration") sowie eine
Entwurfs-Rechnung mit Position wurden über die API angelegt und die
Struktur verifiziert (`status: 0` = Entwurf, `ref: (PROVx)` = noch
keine permanente Nummer, korrekter Betrag, korrekte Verknüpfung).
**Nicht validiert** der Kunde hat die beiden Testdatensätze danach
manuell in der Dolibarr-Oberfläche gelöscht (der beschränkte API-Key
hat kein Lösch-Recht, `DELETE` liefert `403`).
- **Bewusst nicht live getestet**: der eigentliche `validate()`-Aufruf,
der die permanente Rechnungsnummer vergibt das hätte einen
unlöschbaren Testeintrag in der produktiven Buchhaltung hinterlassen.
Auf Wunsch des Kunden ist die erste echte Stripe-Zahlung nach dem
Go-Live der erste echte Test dieses Schritts. Das Risiko ist durch die
fehlertolerante Webhook-Verarbeitung begrenzt (siehe oben).
- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
### Für den Produktivbetrieb noch zu tun
- Nach dem ersten echten `invoice.paid`-Ereignis in Produktion das
Mandanten-Audit-Log prüfen (`billing.dolibarr_invoice_created` bzw.
`billing.dolibarr_invoice_failed`), um den `validate()`-Aufruf einmal
scharf zu bestätigen.
- Zahlungserfassung in Dolibarr (Rechnung als bezahlt markieren) als
möglicher Ausbau, sobald Bankkonto-/Zahlungsart-IDs bekannt sind.
+27 -2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/app/database.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/dolibarr.php';
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
@@ -87,10 +88,34 @@ switch ($event['type']) {
case 'invoice.paid':
$tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? ''));
if ($tenantId !== null) {
$amountPaidCents = (int)($object['amount_paid'] ?? 0);
app_audit_log($pdo, $tenantId, null, 'billing.invoice_paid', 'tenant', $tenantId, [
'amount_paid_cents' => $object['amount_paid'] ?? null,
'amount_paid_cents' => $amountPaidCents,
]);
// Dolibarr-Rechnungssynchronisation folgt in Phase 3.
if ($amountPaidCents > 0) {
$billing = billing_fetch_or_init($pdo, $tenantId);
try {
$sync = dolibarr_sync_invoice_paid($pdo, $tenantId, (string)$billing['plan_code'], $amountPaidCents);
} catch (Throwable $e) {
$sync = ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => $e->getMessage()];
}
if ($sync['ok']) {
app_audit_log($pdo, $tenantId, null, 'billing.dolibarr_invoice_created', 'tenant', $tenantId, [
'dolibarr_invoice_id' => $sync['invoice_id'],
'dolibarr_ref' => $sync['ref'],
]);
} else {
// Wird bewusst nicht als Fehler an Stripe zurueckgegeben (kein Retry
// ausgeloest) - Stripe hat das Geld bereits erfolgreich eingezogen,
// das ist unabhaengig vom Dolibarr-Spiegel. Fehler landet im Audit-
// Log fuer manuelle Nachbearbeitung.
app_audit_log($pdo, $tenantId, null, 'billing.dolibarr_invoice_failed', 'tenant', $tenantId, [
'error' => $sync['error'],
]);
}
}
}
break;