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