diff --git a/aurora-livecam/api/stripe-webhook.php b/aurora-livecam/api/stripe-webhook.php new file mode 100644 index 0000000..98070bf --- /dev/null +++ b/aurora-livecam/api/stripe-webhook.php @@ -0,0 +1,56 @@ + 'Method not allowed']); + exit; +} + +// Payload lesen +$payload = file_get_contents('php://input'); +$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? ''; + +if (empty($payload)) { + http_response_code(400); + echo json_encode(['error' => 'Empty payload']); + exit; +} + +// Webhook verarbeiten +try { + $handler = new WebhookHandler(); + $result = $handler->handle($payload, $signature); + + if ($result['success']) { + http_response_code(200); + } else { + http_response_code(400); + } + + header('Content-Type: application/json'); + echo json_encode($result); + +} catch (\Exception $e) { + error_log('Stripe Webhook Error: ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'Internal server error']); +} diff --git a/aurora-livecam/dashboard/billing.php b/aurora-livecam/dashboard/billing.php new file mode 100644 index 0000000..a6157a0 --- /dev/null +++ b/aurora-livecam/dashboard/billing.php @@ -0,0 +1,282 @@ +requireLogin(); + +// Prüfe ob Billing aktiviert +if (!$settingsManager->isBillingEnabled()) { + header('Location: /dashboard/'); + exit; +} + +$user = $auth->getUser(); +$tenantId = $user['tenant_id'] ?? 0; + +$flashMessage = null; +$flashType = 'info'; + +$stripe = new StripeService(); +$subscriptions = new SubscriptionManager(); + +// Aktuelle Subscription +$currentSub = null; +$plans = []; +$invoices = []; +$trialDays = 0; + +try { + $currentSub = $subscriptions->getSubscription($tenantId); + $plans = $subscriptions->getPlans(); + $invoices = $subscriptions->getInvoices($tenantId, 5); + $trialDays = $subscriptions->getTrialDaysRemaining($tenantId); +} catch (\Exception $e) { + $flashMessage = 'Fehler beim Laden der Abrechnungsdaten'; + $flashType = 'error'; +} + +// Checkout starten +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['plan_id'])) { + $planId = (int)$_POST['plan_id']; + $plan = $subscriptions->getPlan($planId); + + if ($plan && !empty($plan['stripe_price_id'])) { + $baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; + $session = $stripe->createCheckoutSession( + $tenantId, + $plan['stripe_price_id'], + $baseUrl . '/dashboard/billing.php?success=1', + $baseUrl . '/dashboard/billing.php?canceled=1' + ); + + if ($session && isset($session['url'])) { + header('Location: ' . $session['url']); + exit; + } else { + $flashMessage = 'Fehler beim Erstellen der Checkout-Session'; + $flashType = 'error'; + } + } +} + +// Billing Portal öffnen +if (isset($_GET['portal'])) { + $baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; + $session = $stripe->createPortalSession($tenantId, $baseUrl . '/dashboard/billing.php'); + + if ($session && isset($session['url'])) { + header('Location: ' . $session['url']); + exit; + } +} + +// Success/Cancel Messages +if (isset($_GET['success'])) { + $flashMessage = 'Zahlung erfolgreich! Ihr Abo ist jetzt aktiv.'; + $flashType = 'success'; +} +if (isset($_GET['canceled'])) { + $flashMessage = 'Checkout abgebrochen.'; + $flashType = 'warning'; +} + +$pageTitle = 'Abrechnung'; +$currentPage = 'billing'; + +ob_start(); +?> + + +
+
+

Aktueller Plan

+ + + + + +
+
+ +
+
+

+ 0): ?> +

+ Trial endet in Tag +

+ +

+ Nächste Abrechnung: +

+ +
+ isConfigured() && !empty($currentSub['stripe_customer_id'])): ?> + + Abo verwalten + + +
+ + +
+

Enthaltene Features:

+
+ $value): ?> + + + 'Max. Zuschauer: ' . ($value === -1 ? '∞' : $value), + 'storage_gb' => 'Speicher: ' . $value . ' GB', + 'custom_domain' => 'Custom Domain', + 'weather_widget' => 'Wetter-Widget', + 'timelapse' => 'Timelapse', + 'analytics' => 'Analytics', + 'branding' => 'Custom Branding', + 'priority_support' => 'Priority Support', + ]; + echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature)); + ?> + + + +
+
+ + +

Kein aktives Abo

+ +
+
+ + + +
+
+

Verfügbare Pläne

+
+
+
+ + +
+

+
+ 0): ?> + CHF + /Monat + + Kostenlos + +
+ + +
    + $value): ?> + +
  • + ✓ 'Bis ' . ($value === -1 ? 'unbegrenzt' : $value) . ' Zuschauer', + 'storage_gb' => $value . ' GB Speicher', + 'custom_domain' => 'Eigene Domain', + 'weather_widget' => 'Wetter-Widget', + 'timelapse' => 'Timelapse', + 'analytics' => 'Analytics', + 'branding' => 'Custom Branding', + 'priority_support' => 'Priority Support', + ]; + echo $labels[$feature] ?? ucfirst(str_replace('_', ' ', $feature)); + ?> +
  • + + +
+ + + + + 0 && $stripe->isConfigured()): ?> +
+ + +
+ + + + + +
+ +
+
+
+ + + + +
+
+

Rechnungen

+
+
+ + + + + + + + + + + + + + + + + + + +
DatumBetragStatusPDF
+ + + + + + + Download + + +
+
+
+ + +isConfigured()): ?> +
+ Hinweis: Stripe ist noch nicht konfiguriert. Bitte fügen Sie Ihre Stripe API-Keys in config.php hinzu. +
+ + +isLandingPageEnabled()) { + header('Location: /'); + exit; +} + +$trialDays = $settingsManager->getTrialDays(); +?> + + + + + + Aurora Livecam - Ihre Webcam als Service + + + + + + +
+
+ + +
+
+ + +
+

Ihre Webcam als Service - in 5 Minuten online

+

Erstellen Sie Ihre eigene Live-Webcam-Website mit Wetter-Widget, Timelapse, Analytics und mehr. Keine Programmierkenntnisse erforderlich.

+
+ + Jetzt starten + + + Features ansehen + +
+
+ Tage kostenlos testen - Keine Kreditkarte erforderlich +
+
+ + +
+
+

Alles was Sie brauchen

+

Professionelle Features für Ihre Live-Webcam

+
+
+
+
📹
+

Live-Streaming

+

HLS, RTMP oder WebRTC - verbinden Sie jeden Stream in Sekunden. Automatische Qualitätsanpassung inklusive.

+
+
+
🌤️
+

Wetter-Widget

+

Zeigen Sie Temperatur, Wind, Luftdruck und mehr an. Kostenlose Open-Meteo Integration ohne API-Key.

+
+
+
⏱️
+

Timelapse

+

Automatische Zeitraffer-Erstellung. Scrubben Sie durch den ganzen Tag mit variabler Geschwindigkeit.

+
+
+
🔍
+

Zoom & Pan

+

Lassen Sie Besucher in Ihren Stream hineinzoomen. Unterstützt Touch-Gesten und Maus-Steuerung.

+
+
+
📊
+

Analytics

+

Sehen Sie wer Ihre Webcam besucht. Echtzeit-Zuschauerzähler und detaillierte Statistiken.

+
+
+
🎨
+

Custom Branding

+

Ihr Logo, Ihre Farben, Ihre Domain. Machen Sie die Webcam zu Ihrer eigenen.

+
+
+
+ + +
+
+

So einfach geht's

+

In 3 Schritten zur eigenen Livecam

+
+
+
+
1
+

Registrieren

+

Erstellen Sie in 30 Sekunden Ihr kostenloses Konto.

+
+
+
2
+

Stream verbinden

+

Fügen Sie Ihre Stream-URL ein. Wir unterstützen alle gängigen Formate.

+
+
+
3
+

Anpassen & Teilen

+

Personalisieren Sie Ihre Seite und teilen Sie den Link.

+
+
+
+ + +
+

Bereit loszulegen?

+

Tage kostenlos testen - keine Kreditkarte erforderlich

+ + Jetzt kostenlos starten + +
+ + + + + diff --git a/aurora-livecam/landing/pricing.php b/aurora-livecam/landing/pricing.php new file mode 100644 index 0000000..227b9d0 --- /dev/null +++ b/aurora-livecam/landing/pricing.php @@ -0,0 +1,497 @@ +getPlans(); +} catch (\Exception $e) { + // Fallback-Pläne + $plans = [ + ['name' => 'Free', 'slug' => 'free', 'price_monthly' => 0, 'features' => ['max_viewers' => 10, 'weather_widget' => true]], + ['name' => 'Basic', 'slug' => 'basic', 'price_monthly' => 19, 'features' => ['max_viewers' => 50, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true]], + ['name' => 'Professional', 'slug' => 'professional', 'price_monthly' => 49, 'features' => ['max_viewers' => 200, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true]], + ['name' => 'Enterprise', 'slug' => 'enterprise', 'price_monthly' => 149, 'features' => ['max_viewers' => -1, 'custom_domain' => true, 'weather_widget' => true, 'timelapse' => true, 'analytics' => true, 'branding' => true, 'priority_support' => true]], + ]; +} + +$trialDays = $settingsManager->getTrialDays(); + +// Feature-Labels +$featureLabels = [ + 'max_viewers' => 'Gleichzeitige Zuschauer', + 'storage_gb' => 'Speicherplatz', + 'custom_domain' => 'Eigene Domain', + 'weather_widget' => 'Wetter-Widget', + 'timelapse' => 'Timelapse', + 'analytics' => 'Analytics & Statistiken', + 'branding' => 'Custom Branding', + 'priority_support' => 'Priority Support', +]; +?> + + + + + + Preise - Aurora Livecam + + + + +
+
+ + +
+
+ + + +
+
+ $plan): ?> + +
+

+ +
+ 0): ?> + CHF /Monat + + Kostenlos + +
+ +
+ 0): ?> + CHF /Monat +
+ CHF jährlich +
+ 0): ?> + CHF /Monat +
+ CHF jährlich +
+ + Kostenlos + +
+ +
    + +
  • + +
  • + +
+ + + 0 ? 'Jetzt starten' : 'Kostenlos starten'; ?> + +
+ +
+
+ + +
+

Häufige Fragen

+ +
+
+ Kann ich jederzeit wechseln oder kündigen? + + +
+
+ Ja! Sie können Ihren Plan jederzeit upgraden oder downgraden. Bei einer Kündigung bleibt Ihr Zugang bis zum Ende der Abrechnungsperiode aktiv. +
+
+ +
+
+ Was passiert nach dem Trial? + + +
+
+ Nach Ablauf der Tage werden Sie automatisch auf den kostenlosen Plan umgestellt, sofern Sie kein Abo abschliessen. Keine Sorge, Ihre Daten bleiben erhalten. +
+
+ +
+
+ Welche Zahlungsmethoden werden akzeptiert? + + +
+
+ Wir akzeptieren alle gängigen Kreditkarten (Visa, Mastercard, American Express) sowie TWINT und Banküberweisung bei Jahresabos. +
+
+ +
+
+ Brauche ich technisches Wissen? + + +
+
+ Nein! Unser Onboarding-Wizard führt Sie Schritt für Schritt durch die Einrichtung. Sie benötigen lediglich eine Stream-URL (HLS/m3u8) von Ihrem Kamera-Anbieter. +
+
+
+ + + + + + diff --git a/aurora-livecam/src/Billing/StripeService.php b/aurora-livecam/src/Billing/StripeService.php new file mode 100644 index 0000000..4f5e986 --- /dev/null +++ b/aurora-livecam/src/Billing/StripeService.php @@ -0,0 +1,290 @@ +db = $db ?? Database::getInstance(); + $this->loadConfig(); + } + + /** + * Lädt Stripe-Konfiguration + */ + private function loadConfig(): void + { + $configFile = dirname(__DIR__, 2) . '/config.php'; + + if (file_exists($configFile)) { + $config = require $configFile; + $this->secretKey = $config['stripe']['secret_key'] ?? ''; + $this->publicKey = $config['stripe']['public_key'] ?? ''; + $this->webhookSecret = $config['stripe']['webhook_secret'] ?? ''; + $this->currency = $config['stripe']['currency'] ?? 'chf'; + } else { + $this->secretKey = getenv('STRIPE_SECRET_KEY') ?: ''; + $this->publicKey = getenv('STRIPE_PUBLIC_KEY') ?: ''; + $this->webhookSecret = getenv('STRIPE_WEBHOOK_SECRET') ?: ''; + $this->currency = 'chf'; + } + } + + /** + * Prüft ob Stripe konfiguriert ist + */ + public function isConfigured(): bool + { + return !empty($this->secretKey) && !empty($this->publicKey); + } + + /** + * Gibt den Public Key zurück + */ + public function getPublicKey(): string + { + return $this->publicKey ?? ''; + } + + /** + * Erstellt einen Stripe Customer + */ + public function createCustomer(int $tenantId, string $email, string $name): ?string + { + $response = $this->request('POST', '/v1/customers', [ + 'email' => $email, + 'name' => $name, + 'metadata' => [ + 'tenant_id' => $tenantId, + ], + ]); + + if ($response && isset($response['id'])) { + // In DB speichern + $this->db->execute( + "UPDATE subscriptions SET stripe_customer_id = ? WHERE tenant_id = ?", + [$response['id'], $tenantId] + ); + return $response['id']; + } + + return null; + } + + /** + * Erstellt eine Checkout Session + */ + public function createCheckoutSession(int $tenantId, string $priceId, string $successUrl, string $cancelUrl): ?array + { + // Customer ID holen oder erstellen + $customerId = $this->getOrCreateCustomer($tenantId); + + $params = [ + 'customer' => $customerId, + 'payment_method_types' => ['card'], + 'line_items' => [[ + 'price' => $priceId, + 'quantity' => 1, + ]], + 'mode' => 'subscription', + 'success_url' => $successUrl, + 'cancel_url' => $cancelUrl, + 'metadata' => [ + 'tenant_id' => $tenantId, + ], + ]; + + return $this->request('POST', '/v1/checkout/sessions', $params); + } + + /** + * Erstellt ein Billing Portal Session + */ + public function createPortalSession(int $tenantId, string $returnUrl): ?array + { + $customerId = $this->getCustomerId($tenantId); + + if (!$customerId) { + return null; + } + + return $this->request('POST', '/v1/billing_portal/sessions', [ + 'customer' => $customerId, + 'return_url' => $returnUrl, + ]); + } + + /** + * Holt oder erstellt Customer + */ + private function getOrCreateCustomer(int $tenantId): ?string + { + $customerId = $this->getCustomerId($tenantId); + + if ($customerId) { + return $customerId; + } + + // Tenant-Daten laden + $tenant = $this->db->fetchOne( + "SELECT t.*, u.email, u.name FROM tenants t + LEFT JOIN users u ON u.tenant_id = t.id AND u.role = 'tenant_admin' + WHERE t.id = ? LIMIT 1", + [$tenantId] + ); + + if (!$tenant) { + return null; + } + + return $this->createCustomer($tenantId, $tenant['email'], $tenant['name'] ?? $tenant['name']); + } + + /** + * Holt Customer ID aus DB + */ + private function getCustomerId(int $tenantId): ?string + { + $sub = $this->db->fetchOne( + "SELECT stripe_customer_id FROM subscriptions WHERE tenant_id = ?", + [$tenantId] + ); + + return $sub['stripe_customer_id'] ?? null; + } + + /** + * Holt Subscription von Stripe + */ + public function getSubscription(string $subscriptionId): ?array + { + return $this->request('GET', '/v1/subscriptions/' . $subscriptionId); + } + + /** + * Kündigt Subscription + */ + public function cancelSubscription(string $subscriptionId, bool $immediately = false): ?array + { + if ($immediately) { + return $this->request('DELETE', '/v1/subscriptions/' . $subscriptionId); + } + + return $this->request('POST', '/v1/subscriptions/' . $subscriptionId, [ + 'cancel_at_period_end' => true, + ]); + } + + /** + * Holt Rechnungen + */ + public function getInvoices(string $customerId, int $limit = 10): array + { + $response = $this->request('GET', '/v1/invoices', [ + 'customer' => $customerId, + 'limit' => $limit, + ]); + + return $response['data'] ?? []; + } + + /** + * Verifiziert Webhook-Signatur + */ + public function verifyWebhook(string $payload, string $signature): ?array + { + if (empty($this->webhookSecret)) { + return json_decode($payload, true); + } + + $elements = explode(',', $signature); + $timestamp = null; + $signatures = []; + + foreach ($elements as $element) { + $parts = explode('=', $element, 2); + if ($parts[0] === 't') { + $timestamp = $parts[1]; + } elseif ($parts[0] === 'v1') { + $signatures[] = $parts[1]; + } + } + + if (!$timestamp || empty($signatures)) { + return null; + } + + // Toleranz: 5 Minuten + if (abs(time() - $timestamp) > 300) { + return null; + } + + $signedPayload = $timestamp . '.' . $payload; + $expectedSignature = hash_hmac('sha256', $signedPayload, $this->webhookSecret); + + foreach ($signatures as $sig) { + if (hash_equals($expectedSignature, $sig)) { + return json_decode($payload, true); + } + } + + return null; + } + + /** + * Stripe API Request + */ + private function request(string $method, string $endpoint, array $data = []): ?array + { + if (!$this->isConfigured()) { + return null; + } + + $url = 'https://api.stripe.com' . $endpoint; + + $ch = curl_init(); + + $headers = [ + 'Authorization: Bearer ' . $this->secretKey, + 'Content-Type: application/x-www-form-urlencoded', + ]; + + curl_setopt_array($ch, [ + CURLOPT_URL => $url . ($method === 'GET' && $data ? '?' . http_build_query($data) : ''), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 30, + ]); + + if ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + } elseif ($method === 'DELETE') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300) { + return json_decode($response, true); + } + + // Log error + error_log("Stripe API Error ($httpCode): $response"); + return null; + } +} diff --git a/aurora-livecam/src/Billing/SubscriptionManager.php b/aurora-livecam/src/Billing/SubscriptionManager.php new file mode 100644 index 0000000..062588e --- /dev/null +++ b/aurora-livecam/src/Billing/SubscriptionManager.php @@ -0,0 +1,285 @@ +db = $db ?? Database::getInstance(); + $this->stripe = new StripeService($this->db); + } + + /** + * Gibt alle Pläne zurück + */ + public function getPlans(bool $activeOnly = true): array + { + $sql = "SELECT * FROM plans"; + if ($activeOnly) { + $sql .= " WHERE is_active = 1"; + } + $sql .= " ORDER BY sort_order ASC"; + + $plans = $this->db->fetchAll($sql); + + // Features JSON decodieren + foreach ($plans as &$plan) { + if (isset($plan['features'])) { + $plan['features'] = json_decode($plan['features'], true) ?? []; + } + } + + return $plans; + } + + /** + * Gibt einen Plan zurück + */ + public function getPlan(int $planId): ?array + { + $plan = $this->db->fetchOne("SELECT * FROM plans WHERE id = ?", [$planId]); + + if ($plan && isset($plan['features'])) { + $plan['features'] = json_decode($plan['features'], true) ?? []; + } + + return $plan; + } + + /** + * Gibt Plan by Slug zurück + */ + public function getPlanBySlug(string $slug): ?array + { + $plan = $this->db->fetchOne("SELECT * FROM plans WHERE slug = ?", [$slug]); + + if ($plan && isset($plan['features'])) { + $plan['features'] = json_decode($plan['features'], true) ?? []; + } + + return $plan; + } + + /** + * Gibt die aktuelle Subscription eines Tenants zurück + */ + public function getSubscription(int $tenantId): ?array + { + $sub = $this->db->fetchOne( + "SELECT s.*, p.name as plan_name, p.slug as plan_slug, p.features as plan_features + FROM subscriptions s + JOIN plans p ON s.plan_id = p.id + WHERE s.tenant_id = ? + ORDER BY s.created_at DESC LIMIT 1", + [$tenantId] + ); + + if ($sub && isset($sub['plan_features'])) { + $sub['plan_features'] = json_decode($sub['plan_features'], true) ?? []; + } + + return $sub; + } + + /** + * Erstellt oder aktualisiert eine Subscription + */ + public function createOrUpdate(int $tenantId, array $data): int + { + $existing = $this->db->fetchOne( + "SELECT id FROM subscriptions WHERE tenant_id = ?", + [$tenantId] + ); + + if ($existing) { + $this->db->update('subscriptions', $data, 'id = ?', [$existing['id']]); + return $existing['id']; + } + + $data['tenant_id'] = $tenantId; + return $this->db->insert('subscriptions', $data); + } + + /** + * Startet Trial für einen Tenant + */ + public function startTrial(int $tenantId, int $planId = null, int $days = 14): void + { + if (!$planId) { + $freePlan = $this->getPlanBySlug('basic'); + $planId = $freePlan['id'] ?? 1; + } + + $this->createOrUpdate($tenantId, [ + 'plan_id' => $planId, + 'status' => 'trialing', + 'current_period_start' => date('Y-m-d H:i:s'), + 'current_period_end' => date('Y-m-d H:i:s', strtotime("+$days days")), + ]); + + // Tenant Status + $this->db->update('tenants', [ + 'status' => 'trial', + 'trial_ends_at' => date('Y-m-d H:i:s', strtotime("+$days days")), + ], 'id = ?', [$tenantId]); + } + + /** + * Aktiviert Subscription nach Zahlung + */ + public function activate(int $tenantId, string $stripeSubscriptionId, int $planId): void + { + $this->createOrUpdate($tenantId, [ + 'plan_id' => $planId, + 'stripe_subscription_id' => $stripeSubscriptionId, + 'status' => 'active', + 'current_period_start' => date('Y-m-d H:i:s'), + ]); + + $this->db->update('tenants', ['status' => 'active', 'plan_id' => $planId], 'id = ?', [$tenantId]); + } + + /** + * Kündigt Subscription + */ + public function cancel(int $tenantId, bool $immediately = false): bool + { + $sub = $this->getSubscription($tenantId); + + if (!$sub) { + return false; + } + + // Bei Stripe kündigen + if (!empty($sub['stripe_subscription_id'])) { + $this->stripe->cancelSubscription($sub['stripe_subscription_id'], $immediately); + } + + $status = $immediately ? 'canceled' : 'active'; // Bleibt aktiv bis Periodenende + + $this->db->update('subscriptions', [ + 'status' => $status, + 'canceled_at' => date('Y-m-d H:i:s'), + ], 'tenant_id = ?', [$tenantId]); + + if ($immediately) { + $this->db->update('tenants', ['status' => 'cancelled'], 'id = ?', [$tenantId]); + } + + return true; + } + + /** + * Prüft ob Tenant aktiv ist (Trial oder bezahlt) + */ + public function isActive(int $tenantId): bool + { + $sub = $this->getSubscription($tenantId); + + if (!$sub) { + return false; + } + + if ($sub['status'] === 'active') { + return true; + } + + if ($sub['status'] === 'trialing') { + $endDate = strtotime($sub['current_period_end']); + return $endDate > time(); + } + + return false; + } + + /** + * Gibt verbleibende Trial-Tage zurück + */ + public function getTrialDaysRemaining(int $tenantId): int + { + $tenant = $this->db->fetchOne( + "SELECT trial_ends_at FROM tenants WHERE id = ?", + [$tenantId] + ); + + if (!$tenant || !$tenant['trial_ends_at']) { + return 0; + } + + $remaining = strtotime($tenant['trial_ends_at']) - time(); + return max(0, (int)ceil($remaining / 86400)); + } + + /** + * Prüft Feature-Zugriff + */ + public function hasFeature(int $tenantId, string $feature): bool + { + $sub = $this->getSubscription($tenantId); + + if (!$sub || !isset($sub['plan_features'])) { + return false; + } + + return !empty($sub['plan_features'][$feature]); + } + + /** + * Gibt Feature-Limit zurück + */ + public function getFeatureLimit(int $tenantId, string $feature): int + { + $sub = $this->getSubscription($tenantId); + + if (!$sub || !isset($sub['plan_features'][$feature])) { + return 0; + } + + $value = $sub['plan_features'][$feature]; + + // -1 = unlimited + if ($value === -1 || $value === true) { + return PHP_INT_MAX; + } + + return (int)$value; + } + + /** + * Speichert Rechnung + */ + public function saveInvoice(int $tenantId, array $invoiceData): void + { + $this->db->insert('invoices', [ + 'tenant_id' => $tenantId, + 'stripe_invoice_id' => $invoiceData['id'] ?? null, + 'amount' => ($invoiceData['amount_paid'] ?? 0) / 100, + 'currency' => strtoupper($invoiceData['currency'] ?? 'CHF'), + 'status' => $invoiceData['status'] ?? 'unknown', + 'paid_at' => isset($invoiceData['status_transitions']['paid_at']) + ? date('Y-m-d H:i:s', $invoiceData['status_transitions']['paid_at']) + : null, + 'invoice_pdf_url' => $invoiceData['invoice_pdf'] ?? null, + ]); + } + + /** + * Gibt Rechnungen eines Tenants zurück + */ + public function getInvoices(int $tenantId, int $limit = 10): array + { + return $this->db->fetchAll( + "SELECT * FROM invoices WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?", + [$tenantId, $limit] + ); + } +} diff --git a/aurora-livecam/src/Billing/WebhookHandler.php b/aurora-livecam/src/Billing/WebhookHandler.php new file mode 100644 index 0000000..63d893d --- /dev/null +++ b/aurora-livecam/src/Billing/WebhookHandler.php @@ -0,0 +1,250 @@ +db = $db ?? Database::getInstance(); + $this->stripe = new StripeService($this->db); + $this->subscriptions = new SubscriptionManager($this->db); + } + + /** + * Verarbeitet einen Webhook + */ + public function handle(string $payload, string $signature): array + { + // Signatur verifizieren + $event = $this->stripe->verifyWebhook($payload, $signature); + + if (!$event) { + return ['success' => false, 'error' => 'Invalid signature']; + } + + $type = $event['type'] ?? ''; + $data = $event['data']['object'] ?? []; + + try { + switch ($type) { + case 'checkout.session.completed': + return $this->handleCheckoutComplete($data); + + case 'customer.subscription.created': + case 'customer.subscription.updated': + return $this->handleSubscriptionUpdate($data); + + case 'customer.subscription.deleted': + return $this->handleSubscriptionDeleted($data); + + case 'invoice.paid': + return $this->handleInvoicePaid($data); + + case 'invoice.payment_failed': + return $this->handlePaymentFailed($data); + + default: + return ['success' => true, 'message' => 'Event ignored: ' . $type]; + } + } catch (\Exception $e) { + error_log("Webhook error: " . $e->getMessage()); + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Checkout abgeschlossen + */ + private function handleCheckoutComplete(array $session): array + { + $tenantId = $session['metadata']['tenant_id'] ?? null; + $subscriptionId = $session['subscription'] ?? null; + + if (!$tenantId || !$subscriptionId) { + return ['success' => false, 'error' => 'Missing tenant_id or subscription']; + } + + // Subscription-Details von Stripe holen + $subscription = $this->stripe->getSubscription($subscriptionId); + + if (!$subscription) { + return ['success' => false, 'error' => 'Could not fetch subscription']; + } + + // Plan aus Stripe Price ID ermitteln + $priceId = $subscription['items']['data'][0]['price']['id'] ?? null; + $plan = $this->db->fetchOne( + "SELECT id FROM plans WHERE stripe_price_id = ?", + [$priceId] + ); + + $planId = $plan['id'] ?? 1; + + // Subscription aktivieren + $this->subscriptions->activate($tenantId, $subscriptionId, $planId); + + // Customer ID speichern + $this->db->update('subscriptions', [ + 'stripe_customer_id' => $session['customer'], + ], 'tenant_id = ?', [$tenantId]); + + return ['success' => true, 'message' => 'Subscription activated']; + } + + /** + * Subscription erstellt/aktualisiert + */ + private function handleSubscriptionUpdate(array $subscription): array + { + $customerId = $subscription['customer'] ?? null; + + if (!$customerId) { + return ['success' => false, 'error' => 'No customer ID']; + } + + // Tenant über Customer ID finden + $sub = $this->db->fetchOne( + "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?", + [$customerId] + ); + + if (!$sub) { + return ['success' => true, 'message' => 'Customer not found in DB']; + } + + $tenantId = $sub['tenant_id']; + $status = $this->mapStripeStatus($subscription['status']); + + $this->db->update('subscriptions', [ + 'stripe_subscription_id' => $subscription['id'], + 'status' => $status, + 'current_period_start' => date('Y-m-d H:i:s', $subscription['current_period_start']), + 'current_period_end' => date('Y-m-d H:i:s', $subscription['current_period_end']), + ], 'tenant_id = ?', [$tenantId]); + + // Tenant-Status aktualisieren + $tenantStatus = in_array($status, ['active', 'trialing']) ? 'active' : 'suspended'; + $this->db->update('tenants', ['status' => $tenantStatus], 'id = ?', [$tenantId]); + + return ['success' => true, 'message' => 'Subscription updated']; + } + + /** + * Subscription gelöscht/gekündigt + */ + private function handleSubscriptionDeleted(array $subscription): array + { + $customerId = $subscription['customer'] ?? null; + + if (!$customerId) { + return ['success' => false, 'error' => 'No customer ID']; + } + + $sub = $this->db->fetchOne( + "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?", + [$customerId] + ); + + if (!$sub) { + return ['success' => true, 'message' => 'Customer not found']; + } + + $this->db->update('subscriptions', [ + 'status' => 'canceled', + 'canceled_at' => date('Y-m-d H:i:s'), + ], 'tenant_id = ?', [$sub['tenant_id']]); + + // Downgrade zu Free-Plan + $freePlan = $this->db->fetchOne("SELECT id FROM plans WHERE slug = 'free'"); + if ($freePlan) { + $this->db->update('tenants', [ + 'status' => 'active', + 'plan_id' => $freePlan['id'], + ], 'id = ?', [$sub['tenant_id']]); + } + + return ['success' => true, 'message' => 'Subscription canceled']; + } + + /** + * Rechnung bezahlt + */ + private function handleInvoicePaid(array $invoice): array + { + $customerId = $invoice['customer'] ?? null; + + if (!$customerId) { + return ['success' => false, 'error' => 'No customer ID']; + } + + $sub = $this->db->fetchOne( + "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?", + [$customerId] + ); + + if (!$sub) { + return ['success' => true, 'message' => 'Customer not found']; + } + + // Rechnung speichern + $this->subscriptions->saveInvoice($sub['tenant_id'], $invoice); + + return ['success' => true, 'message' => 'Invoice saved']; + } + + /** + * Zahlung fehlgeschlagen + */ + private function handlePaymentFailed(array $invoice): array + { + $customerId = $invoice['customer'] ?? null; + + if (!$customerId) { + return ['success' => false, 'error' => 'No customer ID']; + } + + $sub = $this->db->fetchOne( + "SELECT tenant_id FROM subscriptions WHERE stripe_customer_id = ?", + [$customerId] + ); + + if (!$sub) { + return ['success' => true, 'message' => 'Customer not found']; + } + + // Status auf past_due setzen + $this->db->update('subscriptions', ['status' => 'past_due'], 'tenant_id = ?', [$sub['tenant_id']]); + + // TODO: E-Mail an Tenant senden + + return ['success' => true, 'message' => 'Payment failure recorded']; + } + + /** + * Mappt Stripe-Status auf DB-Status + */ + private function mapStripeStatus(string $stripeStatus): string + { + $map = [ + 'active' => 'active', + 'trialing' => 'trialing', + 'past_due' => 'past_due', + 'canceled' => 'canceled', + 'unpaid' => 'unpaid', + 'incomplete' => 'incomplete', + 'incomplete_expired' => 'canceled', + ]; + + return $map[$stripeStatus] ?? 'unknown'; + } +}