Files
kaffeekasse-saas/app/stripe.php
T
clemens dd2277c803 Selbstbedienungs-Tarifverwaltung: Admin kann Abo im Backend waehlen und bestellen
- mandant-einstellungen.php: vollstaendige Tarif-Vergleichstabelle statt nur
  erzwungenem Upgrade-Hinweis. Jeder Tarif zeigt Limit, Preis und einen
  Aktions-Button (Buchen/Kuendigen/Anfragen); aktueller Tarif markiert,
  zu kleine Tarife fuer die aktuelle Teilnehmerzahl deaktiviert.
- abo-upgrade.php: unterscheidet jetzt Upgrade, Downgrade zwischen bezahlten
  Stufen (Preis wird in-place gewechselt statt neuer Checkout-Session,
  Stripe prorated automatisch) und Wechsel auf 'free' (echte Kuendigung der
  bestehenden Subscription), statt nur eine Checkout-Session zu erzeugen.
- app/stripe.php: neue Helper stripe_get_subscription(),
  stripe_update_subscription_price(), stripe_cancel_subscription().
- app/billing.php: billing_plan_selectable_for() prueft serverseitig, ob
  ein Zieltarif die aktuelle Teilnehmerzahl noch abdeckt (Downgrade-Schutz).
- docs/billing.md: Phase 4 dokumentiert.

Getestet: php -l fuer alle geaenderten Dateien und das gesamte Repo (keine
Syntaxfehler), reine Funktionslogik-Tests ohne DB (billing_plans(),
billing_required_plan_code(), stripe_flatten_params()) gruen. Kein Live-Test
gegen echte Stripe-Testobjekte moeglich (keine PHP/DB-Laufzeitumgebung
verfuegbar) - vor Go-Live im Stripe-Testmodus nachholen.
2026-07-20 20:19:05 +00:00

305 lines
9.9 KiB
PHP

<?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;
}
/**
* Fetches a subscription including its first item's price, so the caller
* can see the currently billed price and swap it out in place.
*
* @return array{ok: bool, id: ?string, item_id: ?string, price_lookup_key: ?string, status: ?string, cancel_at_period_end: bool, error: ?string}
*/
function stripe_get_subscription(string $subscriptionId): array
{
$result = stripe_request('GET', "subscriptions/{$subscriptionId}");
if (!$result['ok']) {
return ['ok' => false, 'id' => null, 'item_id' => null, 'price_lookup_key' => null, 'status' => null, 'cancel_at_period_end' => false, 'error' => $result['error']];
}
$item = $result['data']['items']['data'][0] ?? null;
return [
'ok' => true,
'id' => (string)($result['data']['id'] ?? ''),
'item_id' => $item !== null ? (string)($item['id'] ?? '') : null,
'price_lookup_key' => $item !== null ? (string)($item['price']['lookup_key'] ?? '') : null,
'status' => (string)($result['data']['status'] ?? ''),
'cancel_at_period_end' => (bool)($result['data']['cancel_at_period_end'] ?? false),
'error' => null,
];
}
/**
* Switches an existing active subscription to a different Price in place
* (upgrade or downgrade between paid tiers), instead of creating a brand
* new Checkout Session. Stripe prorates the difference automatically.
* Also clears any pending cancel_at_period_end, since choosing a plan is
* an explicit signal the tenant wants to keep the subscription running.
*
* @return array{ok: bool, error: ?string}
*/
function stripe_update_subscription_price(string $subscriptionId, string $subscriptionItemId, string $newPriceId): array
{
$result = stripe_request('POST', "subscriptions/{$subscriptionId}", [
'items' => [['id' => $subscriptionItemId, 'price' => $newPriceId]],
'proration_behavior' => 'create_prorations',
'cancel_at_period_end' => 'false',
]);
if (!$result['ok']) {
return ['ok' => false, 'error' => $result['error']];
}
return ['ok' => true, 'error' => null];
}
/**
* Cancels a subscription immediately (not at period end) - used when a
* tenant downgrades all the way to the free plan from the admin UI.
*
* @return array{ok: bool, error: ?string}
*/
function stripe_cancel_subscription(string $subscriptionId): array
{
$result = stripe_request('DELETE', "subscriptions/{$subscriptionId}");
if (!$result['ok']) {
return ['ok' => false, 'error' => $result['error']];
}
return ['ok' => true, 'error' => null];
}
/**
* @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'],
];
}