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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:56:48 +02:00
co-authored by Claude Sonnet 5
parent ade1fd0ed9
commit d642814fb5
8 changed files with 618 additions and 20 deletions
+41
View File
@@ -0,0 +1,41 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/saas-mail.php';
$pdo = app_db_pdo();
$user = saas_require_login();
if (!saas_can_manage_tenant_settings($user)) {
http_response_code(403);
exit('Kein Zugriff.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: mandant-einstellungen.php');
exit;
}
app_require_csrf();
$tenantId = (int)$user['tenant_id'];
$billing = billing_fetch_or_init($pdo, $tenantId);
if ($billing['stripe_customer_id'] === null) {
http_response_code(400);
exit('Für diesen Mandanten gibt es noch keine Zahlungsmethode zu verwalten.');
}
$result = stripe_create_billing_portal_session($billing['stripe_customer_id'], saas_app_url('mandant-einstellungen.php'));
if (!$result['ok']) {
http_response_code(502);
exit('Das Kundenportal konnte nicht geöffnet werden: ' . saas_html((string)$result['error']));
}
app_audit_log($pdo, $tenantId, (int)$user['user_id'], 'billing.portal_opened', 'tenant', $tenantId);
header('Location: ' . $result['url']);
exit;
+59
View File
@@ -0,0 +1,59 @@
<?php
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/saas-mail.php';
$pdo = app_db_pdo();
$user = saas_require_login();
if (!saas_can_manage_tenant_settings($user)) {
http_response_code(403);
exit('Kein Zugriff.');
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: mandant-einstellungen.php');
exit;
}
app_require_csrf();
$tenantId = (int)$user['tenant_id'];
$planCode = (string)($_POST['plan_code'] ?? '');
$plans = billing_plans();
if (!isset($plans[$planCode]) || $plans[$planCode]['stripe_lookup_key'] === null) {
http_response_code(400);
exit('Ungültiger Tarif.');
}
$priceId = billing_stripe_price_id($planCode);
if ($priceId === null) {
http_response_code(502);
exit('Der Tarif ist bei Stripe aktuell nicht verfügbar. Bitte später erneut versuchen.');
}
$billing = billing_fetch_or_init($pdo, $tenantId);
$baseUrl = saas_app_url('mandant-einstellungen.php');
$result = stripe_create_checkout_session(
$priceId,
(string)$user['email'],
$billing['stripe_customer_id'],
$baseUrl . '?upgrade=success',
$baseUrl . '?upgrade=cancelled',
['tenant_id' => $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;
+64 -7
View File
@@ -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<string, array{label: string, max_participants: ?int, price_cents: int}>
* @return array<string, array{label: string, max_participants: ?int, price_cents: int, stripe_lookup_key: ?string}>
*/
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
+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* Minimal Stripe REST API client using PHP streams (no curl extension
* available in this environment, and streams are more portable anyway -
* no dependency on a vendored SDK, no composer.json needed for this).
*/
function stripe_secret_key(): string
{
$key = app_env('STRIPE_SECRET_KEY');
if ($key === null || trim($key) === '') {
throw new RuntimeException('STRIPE_SECRET_KEY ist nicht gesetzt.');
}
return $key;
}
function stripe_webhook_secret(): string
{
$secret = app_env('STRIPE_WEBHOOK_SECRET');
if ($secret === null || trim($secret) === '') {
throw new RuntimeException('STRIPE_WEBHOOK_SECRET ist nicht gesetzt.');
}
return $secret;
}
/**
* Flattens nested params into Stripe's bracket-notation form fields, e.g.
* ['line_items' => [['price' => 'x']]] -> line_items[0][price]=x
*
* @param array<int|string, mixed> $params
* @return array<string, string>
*/
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<string, mixed> $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=<timestamp>,v1=<hmac>[,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'],
];
}
+78 -11
View File
@@ -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)
+24 -2
View File
@@ -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: <b><?php echo saas_html($billingPlans[$billingCheck['current_plan']]['label'] ?? $billingCheck['current_plan']); ?></b><br>
Aktive Teilnehmer: <?php echo (int)$billingCheck['active_participants']; ?>
</p>
<?php if (isset($_GET['upgrade']) && $_GET['upgrade'] === 'success'): ?>
<div class="hint-box success"><p>Danke! Die Zahlung wird verarbeitet, der Tarif ist gleich aktuell.</p></div>
<?php elseif (isset($_GET['upgrade']) && $_GET['upgrade'] === 'cancelled'): ?>
<div class="hint-box error"><p>Der Bezahlvorgang wurde abgebrochen, es wurde nichts geändert.</p></div>
<?php endif; ?>
<?php if ($billingCheck['needs_upgrade']): ?>
<div class="hint-box error">
<p>Mit <?php echo (int)$billingCheck['active_participants']; ?> aktiven Teilnehmern benötigt ihr den
Tarif „<?php echo saas_html($billingPlans[$billingCheck['required_plan']]['label'] ?? $billingCheck['required_plan']); ?>".
Die automatische Abrechnung ist noch in Vorbereitung bitte <a href="mailto:info@ctb-it.de">meldet euch bei uns</a>,
wir stellen den passenden Tarif manuell um. Details zu den Stufen stehen unter <a href="preise.php">Preise</a>.</p>
Details zu den Stufen stehen unter <a href="preise.php">Preise</a>.</p>
<?php if (($billingPlans[$billingCheck['required_plan']]['stripe_lookup_key'] ?? null) !== null): ?>
<form method="post" action="abo-upgrade.php">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="plan_code" value="<?php echo saas_html($billingCheck['required_plan']); ?>">
<button type="submit">Jetzt auf „<?php echo saas_html($billingPlans[$billingCheck['required_plan']]['label']); ?>" upgraden</button>
</form>
<?php else: ?>
<p>Für diese Teilnehmerzahl <a href="mailto:info@ctb-it.de">meldet euch bitte bei uns</a>.</p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="hint-box success"><p>Euer aktueller Tarif deckt eure Teilnehmerzahl ab.</p></div>
<?php endif; ?>
<?php if (($billing['stripe_customer_id'] ?? null) !== null): ?>
<form method="post" action="abo-portal.php">
<?php echo app_csrf_field(); ?>
<button type="submit">Zahlungsmethode verwalten / Abo kündigen</button>
</form>
<?php endif; ?>
<h3>Protokoll</h3>
<p>Die letzten Admin-Aktionen für diesen Mandanten.</p>
<table>
+10
View File
@@ -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',
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app/database.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/audit.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) {
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';