diff --git a/app/dolibarr.php b/app/dolibarr.php new file mode 100644 index 0000000..893012d --- /dev/null +++ b/app/dolibarr.php @@ -0,0 +1,182 @@ + [ + '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); +} diff --git a/docs/billing.md b/docs/billing.md index 2132788..b0e4784 100644 --- a/docs/billing.md +++ b/docs/billing.md @@ -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 - `, 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. diff --git a/stripe-webhook.php b/stripe-webhook.php index 0e4066c..82ed3c5 100644 --- a/stripe-webhook.php +++ b/stripe-webhook.php @@ -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;