From d642814fb5b6243670e00f17028514804e05a106 Mon Sep 17 00:00:00 2001
From: Clemens Creutzburg
Date: Thu, 16 Jul 2026 23:56:48 +0200
Subject: [PATCH] Billing Phase 2: Stripe Checkout, Kundenportal und
Webhook-Verarbeitung
app/stripe.php: minimaler REST-Client fuer die Stripe-API auf Basis von
PHP-Streams statt eines vendorten SDKs - diese PHP-Installation hat keine
curl-Extension, Streams sind zudem portabler und brauchen kein
composer.json.
- Fuer jeden bezahlten Tarif per API ein Stripe-Produkt mit monatlichem
Preis angelegt, referenziert ueber einen stabilen lookup_key statt
hartcodierter Price-ID; Erstellung ist idempotent.
- abo-upgrade.php: erstellt eine Stripe-Checkout-Session fuer den
gewaehlten Tarif, tenant_id/plan_code als Metadaten auf Session UND
Subscription (damit spaetere Subscription-Events zuordenbar bleiben).
- abo-portal.php: oeffnet das Stripe Customer Portal fuer bestehende
Kunden (Zahlungsmittel/Kuendigung, ohne eigene UI dafuer).
- stripe-webhook.php: verifiziert die Stripe-Signature per HMAC-SHA256
mit Zeitstempel-Toleranz, verarbeitet checkout.session.completed,
customer.subscription.updated/.deleted, invoice.paid/.payment_failed
und haelt tenant_billing aktuell. Bewusst kein CSRF-/Login-Check
(Stripe ruft unauthentifiziert auf), stattdessen ausschliesslich
Signaturpruefung als Echtheitsnachweis.
- mandant-einstellungen.php: echter "Jetzt upgraden"-Button (Stripe
Checkout) sowie "Zahlungsmethode verwalten/Abo kuendigen" (Customer
Portal), sobald ein Stripe-Kunde existiert.
Live getestet (Stripe-Testmodus, kein echtes Geld): voller Checkout-Flow
bis zum echten Redirect auf checkout.stripe.com, Webhook-Verarbeitung
durch selbst erzeugte, korrekt signierte Test-Events (da diese
Dev-Umgebung keine oeffentlich erreichbare URL fuer echte Stripe-
Zustellung hat) inklusive Ablehnung falscher Signaturen, Customer Portal
mit echtem per API angelegtem Test-Kunden. Alle Regressionstests
weiterhin gruen (36 Seiten HTTP-Smoke, Golden Master, M8-Isolation/
Rollenmatrix).
Noch offen: echten Webhook-Endpunkt auf die Produktions-Domain eintragen,
sobald diese feststeht; Wechsel auf Live-Keys; Dolibarr-Sync (Phase 3).
Co-Authored-By: Claude Sonnet 5
---
abo-portal.php | 41 +++++++
abo-upgrade.php | 59 ++++++++++
app/billing.php | 71 ++++++++++--
app/stripe.php | 238 ++++++++++++++++++++++++++++++++++++++
docs/billing.md | 89 ++++++++++++--
mandant-einstellungen.php | 26 ++++-
scripts/http-smoke.php | 10 ++
stripe-webhook.php | 104 +++++++++++++++++
8 files changed, 618 insertions(+), 20 deletions(-)
create mode 100644 abo-portal.php
create mode 100644 abo-upgrade.php
create mode 100644 app/stripe.php
create mode 100644 stripe-webhook.php
diff --git a/abo-portal.php b/abo-portal.php
new file mode 100644
index 0000000..f909f88
--- /dev/null
+++ b/abo-portal.php
@@ -0,0 +1,41 @@
+ $tenantId, 'plan_code' => $planCode]
+);
+
+if (!$result['ok']) {
+ app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_failed', 'tenant', $tenantId, ['plan_code' => $planCode, 'error' => $result['error']]);
+ http_response_code(502);
+ exit('Der Bezahlvorgang konnte nicht gestartet werden: ' . saas_html((string)$result['error']));
+}
+
+app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.checkout_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
+
+header('Location: ' . $result['url']);
+exit;
diff --git a/app/billing.php b/app/billing.php
index d97b731..0c1794d 100644
--- a/app/billing.php
+++ b/app/billing.php
@@ -3,25 +3,48 @@
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
+require_once __DIR__ . '/stripe.php';
/**
* Single source of truth for the pricing tiers, matching preise.php and
* agb.php. max_participants = null means "no upper limit defined, contact
- * us" (the "auf Anfrage" tier).
+ * us" (the "auf Anfrage" tier). stripe_lookup_key is null for tiers that
+ * have no Stripe Price (free and enterprise are never checked out through
+ * Stripe: free needs no payment, enterprise is arranged manually).
*
- * @return array
+ * @return array
*/
function billing_plans(): array
{
return [
- 'free' => ['label' => 'Kostenlos', 'max_participants' => 10, 'price_cents' => 0],
- 'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399],
- 'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799],
- 'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299],
- 'enterprise' => ['label' => 'Enterprise (auf Anfrage)', 'max_participants' => null, 'price_cents' => 0],
+ 'free' => ['label' => 'Kostenlos', 'max_participants' => 10, 'price_cents' => 0, 'stripe_lookup_key' => null],
+ 'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399, 'stripe_lookup_key' => 'kaffeeliste_basic'],
+ 'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799, 'stripe_lookup_key' => 'kaffeeliste_plus'],
+ 'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299, 'stripe_lookup_key' => 'kaffeeliste_pro'],
+ 'enterprise' => ['label' => 'Enterprise (auf Anfrage)', 'max_participants' => null, 'price_cents' => 0, 'stripe_lookup_key' => null],
];
}
+/**
+ * Resolves the Stripe Price id for a plan by its lookup_key. Returns null
+ * for plans without a Stripe price (free, enterprise) or on API failure.
+ */
+function billing_stripe_price_id(string $planCode): ?string
+{
+ $plans = billing_plans();
+ $lookupKey = $plans[$planCode]['stripe_lookup_key'] ?? null;
+ if ($lookupKey === null) {
+ return null;
+ }
+
+ $result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']);
+ if ($result['ok'] && !empty($result['data']['data'][0]['id'])) {
+ return (string)$result['data']['data'][0]['id'];
+ }
+
+ return null;
+}
+
/**
* Returns the cheapest plan whose participant limit still covers
* $activeParticipantCount. Used both to show customers which plan they
@@ -72,6 +95,40 @@ function billing_fetch_or_init(PDO $pdo, int $tenantId): array
return $row;
}
+function billing_update(PDO $pdo, int $tenantId, array $fields): void
+{
+ billing_fetch_or_init($pdo, $tenantId);
+
+ $setClauses = [];
+ $params = [];
+ foreach ($fields as $column => $value) {
+ $setClauses[] = "{$column} = ?";
+ $params[] = $value;
+ }
+ $params[] = $tenantId;
+
+ $pdo->prepare('UPDATE tenant_billing SET ' . implode(', ', $setClauses) . ' WHERE tenant_id = ?')
+ ->execute($params);
+}
+
+function billing_find_tenant_id_by_stripe_customer(PDO $pdo, string $stripeCustomerId): ?int
+{
+ $stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?');
+ $stmt->execute([$stripeCustomerId]);
+ $tenantId = $stmt->fetchColumn();
+
+ return $tenantId !== false ? (int)$tenantId : null;
+}
+
+function billing_find_tenant_id_by_stripe_subscription(PDO $pdo, string $stripeSubscriptionId): ?int
+{
+ $stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_subscription_id = ?');
+ $stmt->execute([$stripeSubscriptionId]);
+ $tenantId = $stmt->fetchColumn();
+
+ return $tenantId !== false ? (int)$tenantId : null;
+}
+
/**
* Compares the tenant's currently booked plan against what their active
* participant count actually requires. Purely informational until the
diff --git a/app/stripe.php b/app/stripe.php
new file mode 100644
index 0000000..4e915d8
--- /dev/null
+++ b/app/stripe.php
@@ -0,0 +1,238 @@
+ [['price' => 'x']]] -> line_items[0][price]=x
+ *
+ * @param array $params
+ * @return array
+ */
+function stripe_flatten_params(array $params, string $prefix = ''): array
+{
+ $flat = [];
+ foreach ($params as $key => $value) {
+ $paramKey = $prefix === '' ? (string)$key : "{$prefix}[{$key}]";
+ if (is_array($value)) {
+ $flat += stripe_flatten_params($value, $paramKey);
+ } elseif ($value !== null) {
+ $flat[$paramKey] = (string)$value;
+ }
+ }
+
+ return $flat;
+}
+
+/**
+ * @param array $params
+ * @return array{ok: bool, status: int, data: array, error: ?string}
+ */
+function stripe_request(string $method, string $path, array $params = []): array
+{
+ $url = 'https://api.stripe.com/v1/' . ltrim($path, '/');
+ $body = null;
+
+ if ($method === 'GET') {
+ if ($params !== []) {
+ $url .= '?' . http_build_query(stripe_flatten_params($params));
+ }
+ } else {
+ $body = http_build_query(stripe_flatten_params($params));
+ }
+
+ $headers = [
+ 'Authorization: Bearer ' . stripe_secret_key(),
+ 'Content-Type: application/x-www-form-urlencoded',
+ ];
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => $method,
+ 'header' => implode("\r\n", $headers),
+ 'content' => $body,
+ 'timeout' => 20,
+ 'ignore_errors' => true,
+ ],
+ ]);
+
+ $responseBody = @file_get_contents($url, false, $context);
+ $status = 0;
+ foreach ($http_response_header ?? [] as $header) {
+ if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
+ $status = (int)$m[1];
+ }
+ }
+
+ if ($responseBody === false) {
+ return ['ok' => false, 'status' => 0, 'data' => [], 'error' => 'Stripe-Anfrage fehlgeschlagen (keine Antwort).'];
+ }
+
+ $data = json_decode($responseBody, true);
+ if (!is_array($data)) {
+ return ['ok' => false, 'status' => $status, 'data' => [], 'error' => 'Ungültige Antwort von Stripe.'];
+ }
+
+ if ($status >= 400) {
+ return ['ok' => false, 'status' => $status, 'data' => $data, 'error' => $data['error']['message'] ?? 'Stripe-Fehler.'];
+ }
+
+ return ['ok' => true, 'status' => $status, 'data' => $data, 'error' => null];
+}
+
+/**
+ * Finds an existing recurring Price by lookup_key, or creates the Product
+ * and Price if none exists yet. Idempotent: safe to call on every request
+ * that needs the price id (result should be cached by the caller).
+ */
+function stripe_find_or_create_price(string $lookupKey, string $productName, int $priceCents): ?string
+{
+ $existing = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']);
+ if ($existing['ok'] && !empty($existing['data']['data'][0]['id'])) {
+ return (string)$existing['data']['data'][0]['id'];
+ }
+
+ $product = stripe_request('POST', 'products', ['name' => $productName]);
+ if (!$product['ok']) {
+ return null;
+ }
+
+ $price = stripe_request('POST', 'prices', [
+ 'product' => $product['data']['id'],
+ 'unit_amount' => $priceCents,
+ 'currency' => 'eur',
+ 'recurring' => ['interval' => 'month'],
+ 'lookup_key' => $lookupKey,
+ ]);
+
+ return $price['ok'] ? (string)$price['data']['id'] : null;
+}
+
+/**
+ * @return array{ok: bool, url: ?string, error: ?string}
+ */
+function stripe_create_checkout_session(string $priceId, string $customerEmail, ?string $existingCustomerId, string $successUrl, string $cancelUrl, array $metadata = []): array
+{
+ $params = [
+ 'mode' => 'subscription',
+ 'line_items' => [['price' => $priceId, 'quantity' => 1]],
+ 'success_url' => $successUrl,
+ 'cancel_url' => $cancelUrl,
+ 'metadata' => $metadata,
+ // Auch auf die Subscription selbst spiegeln: subscription.updated/
+ // .deleted-Events liefern kein Checkout-Session-Objekt, aber die
+ // Metadaten der Subscription, darueber loesen wir tenant_id auf.
+ 'subscription_data' => ['metadata' => $metadata],
+ ];
+
+ if ($existingCustomerId !== null) {
+ $params['customer'] = $existingCustomerId;
+ } else {
+ $params['customer_email'] = $customerEmail;
+ }
+
+ $result = stripe_request('POST', 'checkout/sessions', $params);
+ if (!$result['ok']) {
+ return ['ok' => false, 'url' => null, 'error' => $result['error']];
+ }
+
+ return ['ok' => true, 'url' => (string)$result['data']['url'], 'error' => null];
+}
+
+/**
+ * @return array{ok: bool, url: ?string, error: ?string}
+ */
+function stripe_create_billing_portal_session(string $customerId, string $returnUrl): array
+{
+ $result = stripe_request('POST', 'billing_portal/sessions', [
+ 'customer' => $customerId,
+ 'return_url' => $returnUrl,
+ ]);
+ if (!$result['ok']) {
+ return ['ok' => false, 'url' => null, 'error' => $result['error']];
+ }
+
+ return ['ok' => true, 'url' => (string)$result['data']['url'], 'error' => null];
+}
+
+/**
+ * Verifies a Stripe webhook signature per Stripe's documented scheme:
+ * the Stripe-Signature header contains "t=,v1=[,v0=...]";
+ * the expected signature is HMAC-SHA256("{t}.{payload}", secret). A
+ * $toleranceSeconds window guards against replay of old, captured payloads.
+ */
+function stripe_verify_webhook_signature(string $payload, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
+{
+ $parts = [];
+ foreach (explode(',', $signatureHeader) as $piece) {
+ [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, null);
+ if ($k !== null && $v !== null) {
+ $parts[$k][] = $v;
+ }
+ }
+
+ $timestamp = isset($parts['t'][0]) ? (int)$parts['t'][0] : 0;
+ $signatures = $parts['v1'] ?? [];
+ if ($timestamp <= 0 || $signatures === []) {
+ return false;
+ }
+
+ if (abs(time() - $timestamp) > $toleranceSeconds) {
+ return false;
+ }
+
+ $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
+
+ foreach ($signatures as $signature) {
+ if (hash_equals($expected, $signature)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * @return array{id: string, type: string, data: array}|null
+ */
+function stripe_parse_event(string $payload): ?array
+{
+ $decoded = json_decode($payload, true);
+ if (!is_array($decoded) || !isset($decoded['type'], $decoded['data']['object'])) {
+ return null;
+ }
+
+ return [
+ 'id' => (string)($decoded['id'] ?? ''),
+ 'type' => (string)$decoded['type'],
+ 'data' => $decoded['data']['object'],
+ ];
+}
diff --git a/docs/billing.md b/docs/billing.md
index 69cabce..d65f09e 100644
--- a/docs/billing.md
+++ b/docs/billing.md
@@ -66,19 +66,86 @@ als „basic" erkannt) geprüft; UI-Anzeige in Mandant-Einstellungen und
Back-Office live verifiziert. Alle bestehenden M8-Isolationstests und der
Rollen-Matrix-Test bleiben unverändert grün.
-## Phase 2: Stripe-Anbindung (noch offen)
+## Phase 2: Stripe-Anbindung (umgesetzt)
-Wartet auf Stripe-Test-API-Keys vom Kunden. Geplanter Umfang:
+Umgesetzte Dateien:
-- Stripe Checkout zum Abschließen/Wechseln eines bezahlten Tarifs.
-- Webhook-Endpunkt für `checkout.session.completed`,
- `customer.subscription.updated`/`.deleted`, `invoice.paid`/`.payment_failed`,
- der `tenant_billing` aktuell hält.
-- Stripe Customer Portal für Zahlungsmittel-Verwaltung/Kündigung durch
- den Kunden selbst.
-- Automatischer Stufenwechsel: Sobald `billing_check_tenant()` einen
- höheren Bedarf erkennt, Wechsel der Stripe-Subscription auslösen
- (mit Proration) statt nur anzuzeigen.
+```text
+app/stripe.php
+app/billing.php (Stripe-Preis-Auflösung ergänzt)
+abo-upgrade.php
+abo-portal.php
+stripe-webhook.php
+mandant-einstellungen.php (Upgrade-/Portal-Buttons)
+```
+
+- `app/stripe.php`: minimaler REST-Client für die Stripe-API auf Basis von
+ PHP-Streams (`file_get_contents` + `stream_context_create`), **kein**
+ vendortes SDK und **kein** composer.json nötig – diese PHP-Installation
+ hat ohnehin keine curl-Extension, Streams sind portabler.
+- Für jeden bezahlten Tarif (`basic`/`plus`/`pro`) wurde per API ein
+ Stripe-Produkt mit monatlichem Preis angelegt, referenziert über einen
+ stabilen `lookup_key` (`kaffeeliste_basic`/`_plus`/`_pro`) statt einer
+ hartcodierten Price-ID – `stripe_find_or_create_price()` ist idempotent,
+ ein erneuter Aufruf legt nichts doppelt an.
+- `abo-upgrade.php`: erstellt eine Stripe-Checkout-Session (Modus
+ `subscription`) für den vom Kunden gewählten Tarif und leitet dorthin
+ weiter. `tenant_id`/`plan_code` werden sowohl auf der Checkout-Session
+ als auch auf der Subscription selbst als Metadaten gesetzt, damit spätere
+ Subscription-Events (die kein Checkout-Session-Objekt mehr enthalten)
+ trotzdem einem Mandanten zugeordnet werden können.
+- `abo-portal.php`: öffnet das Stripe Customer Portal für den bestehenden
+ Stripe-Kunden (Zahlungsmittel ändern, Abo kündigen) – Self-Service ohne
+ eigene UI dafür.
+- `stripe-webhook.php`: verifiziert die `Stripe-Signature` nach dem von
+ Stripe dokumentierten HMAC-SHA256-Schema (inklusive Zeitstempel-Toleranz
+ gegen Replay), verarbeitet `checkout.session.completed`,
+ `customer.subscription.updated`/`.deleted`,
+ `invoice.paid`/`.payment_failed` und hält `tenant_billing` aktuell.
+ Bewusst kein `app_require_csrf()`/`saas_require_login()` (Stripe ruft
+ unauthentifiziert von außen auf), stattdessen ausschließlich die
+ Signaturprüfung als Echtheitsnachweis.
+- `mandant-einstellungen.php`: zeigt bei Bedarf einen echten
+ „Jetzt upgraden"-Button (führt zu Stripe Checkout) sowie, sobald ein
+ Stripe-Kunde existiert, „Zahlungsmethode verwalten / Abo kündigen"
+ (führt zum Customer Portal).
+
+**Noch nicht umgesetzt:** automatischer Stufenwechsel bei wachsender
+Teilnehmerzahl (aktuell muss der Kunde selbst erneut auf „Upgraden"
+klicken, wenn `billing_check_tenant()` einen höheren Bedarf anzeigt).
+
+### Live getestet (Testmodus, kein echtes Geld)
+
+- Checkout-Session-Erstellung direkt über die API sowie über den vollen
+ Seiten-Flow (Login → Mandant-Einstellungen zeigt Upgrade-Button bei 13
+ Teilnehmern → Klick → echter 302-Redirect zu einer
+ `checkout.stripe.com`-URL).
+- Webhook-Verarbeitung durch selbst erzeugte, korrekt signierte
+ Test-Ereignisse (da diese Dev-Umgebung keine öffentlich erreichbare URL
+ für echte Stripe-Zustellung hat): `checkout.session.completed` setzt
+ `tenant_billing` korrekt (Tarif, Status, Stripe-IDs) und wird im
+ Mandanten-Audit-Log protokolliert; `customer.subscription.deleted`
+ setzt den Mandanten korrekt auf `free`/`canceled` zurück.
+- Signaturprüfung: eine Anfrage mit falscher Signatur wird korrekt mit
+ `400` abgelehnt.
+- Customer Portal: mit einem echten, per API angelegten Stripe-Test-Kunden
+ liefert `abo-portal.php` einen echten 302-Redirect zu
+ `billing.stripe.com`.
+- Alle bestehenden Regressionstests (Golden Master, M8-Isolation,
+ M8-Rollenmatrix, HTTP-Smoke mit 36 Seiten) weiterhin grün.
+- Alle Testdaten (Stripe-Testobjekte kosten nichts und wurden im
+ Stripe-Testmodus belassen; lokale DB-Testdaten wurden entfernt)
+ aufgeräumt.
+
+### Für den Produktivbetrieb noch zu tun
+
+- Echten Webhook-Endpunkt im Stripe-Dashboard (oder per API) auf die
+ produktive Domain (`https://app.kaffeeliste.de/stripe-webhook.php`)
+ eintragen und das dortige Signing-Secret als `STRIPE_WEBHOOK_SECRET`
+ hinterlegen – das aktuelle Secret ist nur ein lokal erzeugter Platzhalter
+ für die Simulation in dieser Dev-Umgebung.
+- Von Test- auf Live-API-Keys wechseln, sobald ein echter Stripe-Account
+ mit Bankverbindung eingerichtet ist.
## Phase 3: Dolibarr-Anbindung (noch offen)
diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php
index f770d7a..b05fc8d 100644
--- a/mandant-einstellungen.php
+++ b/mandant-einstellungen.php
@@ -41,6 +41,7 @@ if ($canManageSettings) {
$auditLog = app_fetch_audit_log($pdo, (int)$user['tenant_id'], 50);
$billingCheck = billing_check_tenant($pdo, (int)$user['tenant_id']);
$billingPlans = billing_plans();
+ $billing = billing_fetch_or_init($pdo, (int)$user['tenant_id']);
}
include 'header.php';
@@ -125,17 +126,38 @@ include 'nav.php';
Aktueller Tarif:
Aktive Teilnehmer:
+
+ Danke! Die Zahlung wird verarbeitet, der Tarif ist gleich aktuell.
+
+ Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.
+
+
Mit aktiven Teilnehmern benötigt ihr den
Tarif „".
- Die automatische Abrechnung ist noch in Vorbereitung – bitte meldet euch bei uns,
- wir stellen den passenden Tarif manuell um. Details zu den Stufen stehen unter Preise.
+ Details zu den Stufen stehen unter
Preise.
+
+
+
+
Für diese Teilnehmerzahl meldet euch bitte bei uns.
+
Euer aktueller Tarif deckt eure Teilnehmerzahl ab.
+
+
+
+
Protokoll
Die letzten Admin-Aktionen für diesen Mandanten.
diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php
index 5b0bcff..9d0da42 100644
--- a/scripts/http-smoke.php
+++ b/scripts/http-smoke.php
@@ -137,6 +137,16 @@ $checks = [
'path' => 'datenexport.php',
'contains' => ['Login'],
],
+ [
+ 'label' => 'Abo-Upgrade GET-Redirect',
+ 'path' => 'abo-upgrade.php',
+ 'contains' => ['Login'],
+ ],
+ [
+ 'label' => 'Abo-Portal GET-Redirect',
+ 'path' => 'abo-portal.php',
+ 'contains' => ['Login'],
+ ],
[
'label' => 'Mandant löschen Login-Schutz',
'path' => 'mandant-loeschen.php',
diff --git a/stripe-webhook.php b/stripe-webhook.php
new file mode 100644
index 0000000..0e4066c
--- /dev/null
+++ b/stripe-webhook.php
@@ -0,0 +1,104 @@
+ $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) {
+ app_audit_log($pdo, $tenantId, null, 'billing.invoice_paid', 'tenant', $tenantId, [
+ 'amount_paid_cents' => $object['amount_paid'] ?? null,
+ ]);
+ // Dolibarr-Rechnungssynchronisation folgt in Phase 3.
+ }
+ break;
+
+ default:
+ // Unbehandelte Ereignistypen werden bewusst ignoriert, aber mit 200
+ // bestaetigt, damit Stripe sie nicht wiederholt zustellt.
+ break;
+}
+
+http_response_code(200);
+echo 'ok';