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:
+64
-7
@@ -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
@@ -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'],
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user