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 - Von Test- auf Live-API-Keys wechseln, sobald ein echter Stripe-Account
mit Bankverbindung eingerichtet ist. 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 **Ausgangslage:** Der Kunde betreibt nur eine einzige Dolibarr-Instanz,
Dolibarr-Instanz, und das ist die produktive für sein anderes Business und das ist die produktive für sein anderes Business keine separate
keine separate Sandbox verfügbar. Der ursprüngliche Plan „erst gegen Sandbox verfügbar (Dolibarr 23.0.3, `https://verwaltung.ctb-it.de`).
Sandbox testen" ist damit hinfällig; stattdessen wird sicher **innerhalb** Der ursprüngliche Plan „erst gegen Sandbox testen" wurde daher ersetzt
der einen produktiven Instanz getestet: durch sicheres Testen **innerhalb** der einen produktiven Instanz.
- **Eigener, rechtebeschränkter API-Key**: Der Kunde legt einen Umgesetzte Dateien:
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).
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 - `app/dolibarr.php`: minimaler REST-Client nach demselben Muster wie
die Dolibarr-REST-API eine Rechnung im bestehenden Dolibarr des Kunden `app/stripe.php` (PHP-Streams, kein SDK). `dolibarr_find_or_create_thirdparty()`
anlegen (`dolibarr_thirdparty_id` verknüpft den Mandanten mit dem legt bei Erstkontakt einen Dolibarr-Kunden für den Mandanten an
Dolibarr-Kunden). (`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- - Stripe bleibt alleiniger Zahlungsweg; Dolibarr wird nur als Buchhaltungs-
Spiegel befüllt, nicht selbst zum Auslösen von Zahlungen verwendet. 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. - **Verbindung/Lesezugriff**: `GET /status`, `/thirdparties`,
- Dolibarr-Version (API-Feldnamen unterscheiden sich leicht je Version). `/invoices` gegen die echte Instanz erfolgreich geprüft (Version,
- Ob pro Kaffeeliste-Tarif ein eigenes Dolibarr-Produkt angelegt wird Erreichbarkeit, Rechte des API-Keys).
oder eine einzelne generische Rechnungsposition mit Freitext- - **Schreibzugriff (mit expliziter Freigabe des Kunden)**: ein eindeutig
Beschreibung + Betrag aus Stripe genügt. 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/database.php';
require_once __DIR__ . '/app/billing.php'; require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.php'; require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/dolibarr.php';
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen // Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird // Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
@@ -87,10 +88,34 @@ switch ($event['type']) {
case 'invoice.paid': case 'invoice.paid':
$tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? '')); $tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? ''));
if ($tenantId !== null) { if ($tenantId !== null) {
$amountPaidCents = (int)($object['amount_paid'] ?? 0);
app_audit_log($pdo, $tenantId, null, 'billing.invoice_paid', 'tenant', $tenantId, [ 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; break;