Files
kaffeekasse-saas/stripe-webhook.php
T
clemens 73d599f85b 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.
2026-07-17 10:35:01 +02:00

130 lines
5.4 KiB
PHP

<?php
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
// ausschliesslich ueber die Signaturpruefung sichergestellt.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Nur POST erlaubt.');
}
$payload = file_get_contents('php://input') ?: '';
$signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
if ($signatureHeader === '' || !stripe_verify_webhook_signature($payload, $signatureHeader, stripe_webhook_secret())) {
http_response_code(400);
exit('Ungültige Signatur.');
}
$event = stripe_parse_event($payload);
if ($event === null) {
http_response_code(400);
exit('Ungültiges Ereignis.');
}
$pdo = app_db_pdo();
$object = $event['data'];
switch ($event['type']) {
case 'checkout.session.completed':
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
$planCode = (string)($object['metadata']['plan_code'] ?? '');
$knownPlans = billing_plans();
if ($tenantId !== null && isset($object['customer'], $object['subscription']) && isset($knownPlans[$planCode])) {
billing_update($pdo, $tenantId, [
'plan_code' => $planCode,
'subscription_status' => 'active',
'stripe_customer_id' => (string)$object['customer'],
'stripe_subscription_id' => (string)$object['subscription'],
]);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
}
break;
case 'customer.subscription.updated':
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
if ($tenantId !== null) {
$status = (string)($object['status'] ?? 'active');
$periodEnd = isset($object['current_period_end'])
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
: null;
billing_update($pdo, $tenantId, [
'subscription_status' => $status,
'current_period_end' => $periodEnd,
]);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_updated', 'tenant', $tenantId, ['status' => $status]);
}
break;
case 'customer.subscription.deleted':
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
if ($tenantId !== null) {
billing_update($pdo, $tenantId, [
'plan_code' => 'free',
'subscription_status' => 'canceled',
]);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_canceled', 'tenant', $tenantId);
}
break;
case 'invoice.payment_failed':
$tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? ''));
if ($tenantId !== null) {
billing_update($pdo, $tenantId, ['subscription_status' => 'past_due']);
app_audit_log($pdo, $tenantId, null, 'billing.payment_failed', 'tenant', $tenantId);
}
break;
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' => $amountPaidCents,
]);
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;
default:
// Unbehandelte Ereignistypen werden bewusst ignoriert, aber mit 200
// bestaetigt, damit Stripe sie nicht wiederholt zustellt.
break;
}
http_response_code(200);
echo 'ok';