[['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'], ]; }