$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; $updateFields = [ 'subscription_status' => $status, 'current_period_end' => $periodEnd, ]; // Tarifwechsel aus dem Stripe-Kundenportal lokal nachziehen: der // aktuelle Preis steckt am Subscription-Item, per lookup_key wieder // auf unseren Plan-Code abbildbar. Nur bei aktiver Subscription und // eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen. $lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? ''); $mappedPlan = billing_plan_code_by_lookup_key($lookupKey); if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due'], true)) { $updateFields['plan_code'] = $mappedPlan; } billing_update($pdo, $tenantId, $updateFields); app_audit_log($pdo, $tenantId, null, 'billing.subscription_updated', 'tenant', $tenantId, [ 'status' => $status, 'plan_code' => $updateFields['plan_code'] ?? null, ]); } 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';