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:
@@ -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;
|
||||||
@@ -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
@@ -3,25 +3,48 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/stripe.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for the pricing tiers, matching preise.php and
|
* Single source of truth for the pricing tiers, matching preise.php and
|
||||||
* agb.php. max_participants = null means "no upper limit defined, contact
|
* 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
|
function billing_plans(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'free' => ['label' => 'Kostenlos', 'max_participants' => 10, '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],
|
'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399, 'stripe_lookup_key' => 'kaffeeliste_basic'],
|
||||||
'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799],
|
'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799, 'stripe_lookup_key' => 'kaffeeliste_plus'],
|
||||||
'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299],
|
'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],
|
'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
|
* Returns the cheapest plan whose participant limit still covers
|
||||||
* $activeParticipantCount. Used both to show customers which plan they
|
* $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;
|
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
|
* Compares the tenant's currently booked plan against what their active
|
||||||
* participant count actually requires. Purely informational until the
|
* participant count actually requires. Purely informational until the
|
||||||
|
|||||||
+238
@@ -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
@@ -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
|
Back-Office live verifiziert. Alle bestehenden M8-Isolationstests und der
|
||||||
Rollen-Matrix-Test bleiben unverändert grün.
|
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.
|
```text
|
||||||
- Webhook-Endpunkt für `checkout.session.completed`,
|
app/stripe.php
|
||||||
`customer.subscription.updated`/`.deleted`, `invoice.paid`/`.payment_failed`,
|
app/billing.php (Stripe-Preis-Auflösung ergänzt)
|
||||||
der `tenant_billing` aktuell hält.
|
abo-upgrade.php
|
||||||
- Stripe Customer Portal für Zahlungsmittel-Verwaltung/Kündigung durch
|
abo-portal.php
|
||||||
den Kunden selbst.
|
stripe-webhook.php
|
||||||
- Automatischer Stufenwechsel: Sobald `billing_check_tenant()` einen
|
mandant-einstellungen.php (Upgrade-/Portal-Buttons)
|
||||||
höheren Bedarf erkennt, Wechsel der Stripe-Subscription auslösen
|
```
|
||||||
(mit Proration) statt nur anzuzeigen.
|
|
||||||
|
- `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)
|
## Phase 3: Dolibarr-Anbindung (noch offen)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ if ($canManageSettings) {
|
|||||||
$auditLog = app_fetch_audit_log($pdo, (int)$user['tenant_id'], 50);
|
$auditLog = app_fetch_audit_log($pdo, (int)$user['tenant_id'], 50);
|
||||||
$billingCheck = billing_check_tenant($pdo, (int)$user['tenant_id']);
|
$billingCheck = billing_check_tenant($pdo, (int)$user['tenant_id']);
|
||||||
$billingPlans = billing_plans();
|
$billingPlans = billing_plans();
|
||||||
|
$billing = billing_fetch_or_init($pdo, (int)$user['tenant_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
include 'header.php';
|
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>
|
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']; ?>
|
Aktive Teilnehmer: <?php echo (int)$billingCheck['active_participants']; ?>
|
||||||
</p>
|
</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']): ?>
|
<?php if ($billingCheck['needs_upgrade']): ?>
|
||||||
<div class="hint-box error">
|
<div class="hint-box error">
|
||||||
<p>Mit <?php echo (int)$billingCheck['active_participants']; ?> aktiven Teilnehmern benötigt ihr den
|
<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']); ?>".
|
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>,
|
Details zu den Stufen stehen unter <a href="preise.php">Preise</a>.</p>
|
||||||
wir stellen den passenden Tarif manuell um. 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>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="hint-box success"><p>Euer aktueller Tarif deckt eure Teilnehmerzahl ab.</p></div>
|
<div class="hint-box success"><p>Euer aktueller Tarif deckt eure Teilnehmerzahl ab.</p></div>
|
||||||
<?php endif; ?>
|
<?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>
|
<h3>Protokoll</h3>
|
||||||
<p>Die letzten Admin-Aktionen für diesen Mandanten.</p>
|
<p>Die letzten Admin-Aktionen für diesen Mandanten.</p>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ $checks = [
|
|||||||
'path' => 'datenexport.php',
|
'path' => 'datenexport.php',
|
||||||
'contains' => ['Login'],
|
'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',
|
'label' => 'Mandant löschen Login-Schutz',
|
||||||
'path' => 'mandant-loeschen.php',
|
'path' => 'mandant-loeschen.php',
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user