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';