Add Billing/Stripe integration and Landing Page (Phase 4+5)

Phase 4 - Billing/Stripe:
- src/Billing/StripeService.php: Stripe API wrapper
  - Checkout session creation
  - Customer management
  - Billing portal sessions
  - Webhook signature verification
- src/Billing/SubscriptionManager.php: Subscription logic
  - Plan management (CRUD)
  - Trial handling
  - Feature access checks
  - Invoice storage
- src/Billing/WebhookHandler.php: Stripe webhook processing
  - checkout.session.completed
  - customer.subscription.* events
  - invoice.paid / payment_failed
- api/stripe-webhook.php: Webhook endpoint
- dashboard/billing.php: Billing dashboard
  - Current plan display with features
  - Plan comparison grid
  - Upgrade buttons with Stripe Checkout
  - Invoice history

Phase 5 - Landing Page:
- landing/index.php: Marketing homepage
  - Hero section with CTA
  - Feature grid (6 features)
  - How it works (3 steps)
  - Final CTA section
  - Responsive design
- landing/pricing.php: Pricing page
  - Dynamic plan cards from DB
  - Monthly/yearly toggle (2 months free)
  - Feature comparison
  - FAQ accordion

All features respect saas_features toggles in settings.
This commit is contained in:
Claude
2026-01-23 19:16:18 +00:00
parent ac77e27089
commit 16673b91d3
7 changed files with 2082 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<?php
/**
* Stripe Webhook Endpoint
*
* URL: /api/stripe-webhook.php
* Konfigurieren Sie diesen Endpoint in Ihrem Stripe Dashboard
*/
// Keine Session, keine Ausgabe vor JSON
error_reporting(0);
ini_set('display_errors', 0);
require_once dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\WebhookHandler;
// Nur POST erlaubt
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => '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']);
}
+282
View File
@@ -0,0 +1,282 @@
<?php
/**
* Dashboard - Abrechnung
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Auth\AuthManager;
use AuroraLivecam\Billing\StripeService;
use AuroraLivecam\Billing\SubscriptionManager;
$settingsManager = new SettingsManager();
$auth = new AuthManager();
$auth->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 -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Aktueller Plan</h3>
<?php if ($currentSub): ?>
<span class="badge badge-<?php echo $currentSub['status'] === 'active' ? 'success' : ($currentSub['status'] === 'trialing' ? 'warning' : 'danger'); ?>">
<?php echo ucfirst($currentSub['status']); ?>
</span>
<?php endif; ?>
</div>
<div class="card-body">
<?php if ($currentSub): ?>
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div>
<h2 style="margin: 0; font-size: 1.75rem;"><?php echo htmlspecialchars($currentSub['plan_name'] ?? 'Free'); ?></h2>
<?php if ($currentSub['status'] === 'trialing' && $trialDays > 0): ?>
<p style="color: var(--warning); margin: 0.5rem 0 0 0;">
Trial endet in <?php echo $trialDays; ?> Tag<?php echo $trialDays !== 1 ? 'en' : ''; ?>
</p>
<?php elseif ($currentSub['current_period_end']): ?>
<p style="color: var(--gray-500); margin: 0.5rem 0 0 0;">
Nächste Abrechnung: <?php echo date('d.m.Y', strtotime($currentSub['current_period_end'])); ?>
</p>
<?php endif; ?>
</div>
<?php if ($stripe->isConfigured() && !empty($currentSub['stripe_customer_id'])): ?>
<a href="?portal=1" class="btn btn-secondary">
Abo verwalten
</a>
<?php endif; ?>
</div>
<?php if (!empty($currentSub['plan_features'])): ?>
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--gray-200);">
<h4 style="font-size: 0.875rem; color: var(--gray-500); margin-bottom: 0.75rem;">Enthaltene Features:</h4>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<?php foreach ($currentSub['plan_features'] as $feature => $value): ?>
<?php if ($value): ?>
<span class="badge badge-info">
<?php
$labels = [
'max_viewers' => '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));
?>
</span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<p style="color: var(--gray-500);">Kein aktives Abo</p>
<?php endif; ?>
</div>
</div>
<!-- Verfügbare Pläne -->
<?php if (!empty($plans)): ?>
<div class="card">
<div class="card-header">
<h3 class="card-title">Verfügbare Pläne</h3>
</div>
<div class="card-body">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
<?php foreach ($plans as $plan): ?>
<?php $isCurrent = $currentSub && $currentSub['plan_id'] == $plan['id']; ?>
<div style="border: 2px solid <?php echo $isCurrent ? 'var(--primary)' : 'var(--gray-200)'; ?>; border-radius: 0.75rem; padding: 1.5rem; <?php echo $isCurrent ? 'background: rgba(102,126,234,0.05);' : ''; ?>">
<h4 style="margin: 0 0 0.5rem 0;"><?php echo htmlspecialchars($plan['name']); ?></h4>
<div style="font-size: 2rem; font-weight: 700; color: var(--gray-900);">
<?php if ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'], 0); ?>
<span style="font-size: 1rem; font-weight: 400; color: var(--gray-500);">/Monat</span>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<?php if (!empty($plan['features'])): ?>
<ul style="list-style: none; padding: 0; margin: 1rem 0; font-size: 0.875rem;">
<?php foreach ($plan['features'] as $feature => $value): ?>
<?php if ($value): ?>
<li style="padding: 0.25rem 0; color: var(--gray-600);">
✓ <?php
$labels = [
'max_viewers' => '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));
?>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($isCurrent): ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Aktueller Plan</button>
<?php elseif ($plan['price_monthly'] > 0 && $stripe->isConfigured()): ?>
<form method="POST" action="">
<input type="hidden" name="plan_id" value="<?php echo $plan['id']; ?>">
<button type="submit" class="btn btn-primary" style="width: 100%;">
Upgrade
</button>
</form>
<?php elseif ($plan['price_monthly'] == 0): ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Free Plan</button>
<?php else: ?>
<button class="btn btn-secondary" style="width: 100%;" disabled>Stripe nicht konfiguriert</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Rechnungen -->
<?php if (!empty($invoices)): ?>
<div class="card">
<div class="card-header">
<h3 class="card-title">Rechnungen</h3>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>Datum</th>
<th>Betrag</th>
<th>Status</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $invoice): ?>
<tr>
<td><?php echo date('d.m.Y', strtotime($invoice['created_at'])); ?></td>
<td><?php echo $invoice['currency']; ?> <?php echo number_format($invoice['amount'], 2); ?></td>
<td>
<span class="badge badge-<?php echo $invoice['status'] === 'paid' ? 'success' : 'warning'; ?>">
<?php echo ucfirst($invoice['status']); ?>
</span>
</td>
<td>
<?php if ($invoice['invoice_pdf_url']): ?>
<a href="<?php echo htmlspecialchars($invoice['invoice_pdf_url']); ?>" target="_blank" class="btn btn-sm btn-secondary">
Download
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if (!$stripe->isConfigured()): ?>
<div class="alert alert-warning">
<strong>Hinweis:</strong> Stripe ist noch nicht konfiguriert. Bitte fügen Sie Ihre Stripe API-Keys in config.php hinzu.
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
include __DIR__ . '/templates/layout.php';
+422
View File
@@ -0,0 +1,422 @@
<?php
/**
* Landing Page - Marketing Seite
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
$settingsManager = new SettingsManager();
// Prüfe ob Landing Page aktiviert
if (!$settingsManager->isLandingPageEnabled()) {
header('Location: /');
exit;
}
$trialDays = $settingsManager->getTrialDays();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aurora Livecam - Ihre Webcam als Service</title>
<meta name="description" content="Erstellen Sie Ihre eigene Live-Webcam in wenigen Minuten. Wetter-Widget, Timelapse, Analytics und mehr. Jetzt kostenlos testen!">
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
}
/* Header */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(10px);
z-index: 100;
border-bottom: 1px solid #e2e8f0;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.nav-links a:hover {
color: #667eea;
}
/* Hero */
.hero {
padding: 8rem 2rem 6rem;
background: var(--gradient);
color: white;
text-align: center;
}
.hero h1 {
font-size: 3rem;
font-weight: 800;
margin-bottom: 1.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.hero p {
font-size: 1.25rem;
opacity: 0.9;
max-width: 600px;
margin: 0 auto 2rem;
}
.hero-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-hero {
padding: 1rem 2rem;
border-radius: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn-hero-primary {
background: white;
color: #667eea;
}
.btn-hero-secondary {
background: rgba(255,255,255,0.2);
color: white;
border: 2px solid rgba(255,255,255,0.5);
}
.btn-hero:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}
.trial-badge {
display: inline-block;
background: rgba(255,255,255,0.2);
padding: 0.5rem 1rem;
border-radius: 2rem;
margin-top: 2rem;
font-size: 0.9rem;
}
/* Features */
.features {
padding: 6rem 2rem;
background: #f7fafc;
}
.section-title {
text-align: center;
margin-bottom: 4rem;
}
.section-title h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.section-title p {
color: #718096;
font-size: 1.1rem;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.feature-card {
background: white;
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
transition: transform 0.2s, box-shadow 0.2s;
}
.feature-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.feature-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.feature-card h3 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
.feature-card p {
color: #718096;
}
/* How it works */
.how-it-works {
padding: 6rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.step {
text-align: center;
}
.step-number {
width: 60px;
height: 60px;
background: var(--gradient);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.step h4 {
margin-bottom: 0.5rem;
}
.step p {
color: #718096;
font-size: 0.9rem;
}
/* CTA */
.cta {
padding: 6rem 2rem;
background: var(--gradient);
color: white;
text-align: center;
}
.cta h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.cta p {
font-size: 1.1rem;
opacity: 0.9;
margin-bottom: 2rem;
}
/* Footer */
.footer {
background: #1a202c;
color: #a0aec0;
padding: 3rem 2rem;
}
.footer-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 2rem;
}
.footer-links a {
color: #a0aec0;
text-decoration: none;
margin-right: 1.5rem;
}
.footer-links a:hover {
color: white;
}
/* Responsive */
@media (max-width: 768px) {
.hero h1 { font-size: 2rem; }
.nav-links { display: none; }
.features-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<div class="header-inner">
<a href="/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="#features">Features</a>
<a href="/landing/pricing.php">Preise</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<!-- Hero -->
<section class="hero">
<h1>Ihre Webcam als Service - in 5 Minuten online</h1>
<p>Erstellen Sie Ihre eigene Live-Webcam-Website mit Wetter-Widget, Timelapse, Analytics und mehr. Keine Programmierkenntnisse erforderlich.</p>
<div class="hero-buttons">
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt starten
</a>
<a href="#features" class="btn-hero btn-hero-secondary">
Features ansehen
</a>
</div>
<div class="trial-badge">
<?php echo $trialDays; ?> Tage kostenlos testen - Keine Kreditkarte erforderlich
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="section-title">
<h2>Alles was Sie brauchen</h2>
<p>Professionelle Features für Ihre Live-Webcam</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📹</div>
<h3>Live-Streaming</h3>
<p>HLS, RTMP oder WebRTC - verbinden Sie jeden Stream in Sekunden. Automatische Qualitätsanpassung inklusive.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌤️</div>
<h3>Wetter-Widget</h3>
<p>Zeigen Sie Temperatur, Wind, Luftdruck und mehr an. Kostenlose Open-Meteo Integration ohne API-Key.</p>
</div>
<div class="feature-card">
<div class="feature-icon">⏱️</div>
<h3>Timelapse</h3>
<p>Automatische Zeitraffer-Erstellung. Scrubben Sie durch den ganzen Tag mit variabler Geschwindigkeit.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔍</div>
<h3>Zoom & Pan</h3>
<p>Lassen Sie Besucher in Ihren Stream hineinzoomen. Unterstützt Touch-Gesten und Maus-Steuerung.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Analytics</h3>
<p>Sehen Sie wer Ihre Webcam besucht. Echtzeit-Zuschauerzähler und detaillierte Statistiken.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Custom Branding</h3>
<p>Ihr Logo, Ihre Farben, Ihre Domain. Machen Sie die Webcam zu Ihrer eigenen.</p>
</div>
</div>
</section>
<!-- How it works -->
<section class="how-it-works">
<div class="section-title">
<h2>So einfach geht's</h2>
<p>In 3 Schritten zur eigenen Livecam</p>
</div>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<h4>Registrieren</h4>
<p>Erstellen Sie in 30 Sekunden Ihr kostenloses Konto.</p>
</div>
<div class="step">
<div class="step-number">2</div>
<h4>Stream verbinden</h4>
<p>Fügen Sie Ihre Stream-URL ein. Wir unterstützen alle gängigen Formate.</p>
</div>
<div class="step">
<div class="step-number">3</div>
<h4>Anpassen & Teilen</h4>
<p>Personalisieren Sie Ihre Seite und teilen Sie den Link.</p>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<h2>Bereit loszulegen?</h2>
<p><?php echo $trialDays; ?> Tage kostenlos testen - keine Kreditkarte erforderlich</p>
<a href="/onboarding/register.php" class="btn-hero btn-hero-primary">
Jetzt kostenlos starten
</a>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-inner">
<div>
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</div>
<div class="footer-links">
<a href="/terms">AGB</a>
<a href="/privacy">Datenschutz</a>
<a href="/imprint">Impressum</a>
<a href="mailto:support@aurora-livecam.com">Kontakt</a>
</div>
</div>
</footer>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
<?php
/**
* Landing Page - Preise
*/
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/SettingsManager.php';
if (file_exists(dirname(__DIR__) . '/src/bootstrap.php')) {
require_once dirname(__DIR__) . '/src/bootstrap.php';
}
use AuroraLivecam\Billing\SubscriptionManager;
$settingsManager = new SettingsManager();
// Pläne laden
$plans = [];
try {
$subscriptions = new SubscriptionManager();
$plans = $subscriptions->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',
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preise - Aurora Livecam</title>
<link rel="stylesheet" href="/dashboard/assets/dashboard.css">
<style>
:root {
--gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a202c;
background: #f7fafc;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: var(--gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-decoration: none;
}
.nav-links a {
color: #4a5568;
text-decoration: none;
margin-left: 1.5rem;
}
.page-header {
text-align: center;
padding: 4rem 2rem;
background: var(--gradient);
color: white;
}
.page-header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.page-header p {
font-size: 1.1rem;
opacity: 0.9;
}
.pricing-toggle {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
align-items: center;
}
.pricing-toggle span {
font-size: 0.9rem;
}
.pricing-toggle .active {
font-weight: 600;
}
.toggle-switch {
width: 60px;
height: 30px;
background: rgba(255,255,255,0.3);
border-radius: 15px;
position: relative;
cursor: pointer;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 26px;
height: 26px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: 0.3s;
}
.toggle-switch.yearly::after {
left: 32px;
}
.save-badge {
background: #48bb78;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-container {
max-width: 1200px;
margin: -3rem auto 4rem;
padding: 0 2rem;
}
.pricing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.pricing-card {
background: white;
border-radius: 1rem;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
}
.pricing-card.featured {
border: 2px solid #667eea;
transform: scale(1.05);
}
.pricing-card.featured::before {
content: 'Beliebt';
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
background: var(--gradient);
color: white;
padding: 0.25rem 1rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.pricing-card h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.pricing-card .price {
font-size: 3rem;
font-weight: 800;
margin: 1rem 0;
}
.pricing-card .price span {
font-size: 1rem;
font-weight: 400;
color: #718096;
}
.pricing-card .price-yearly {
display: none;
}
.yearly-mode .price-monthly { display: none; }
.yearly-mode .price-yearly { display: block; }
.pricing-card ul {
list-style: none;
flex: 1;
margin: 1.5rem 0;
}
.pricing-card li {
padding: 0.5rem 0;
color: #4a5568;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pricing-card li.included::before {
content: '✓';
color: #48bb78;
font-weight: bold;
}
.pricing-card li.not-included {
color: #a0aec0;
text-decoration: line-through;
}
.pricing-card li.not-included::before {
content: '✗';
color: #e53e3e;
}
.pricing-card .btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: all 0.2s;
}
.pricing-card .btn-primary {
background: var(--gradient);
color: white;
}
.pricing-card .btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.pricing-card .btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.faq {
max-width: 800px;
margin: 0 auto 4rem;
padding: 0 2rem;
}
.faq h2 {
text-align: center;
margin-bottom: 2rem;
}
.faq-item {
background: white;
border-radius: 0.5rem;
margin-bottom: 1rem;
overflow: hidden;
}
.faq-question {
padding: 1.25rem;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.faq-answer {
padding: 0 1.25rem 1.25rem;
color: #718096;
display: none;
}
.faq-item.open .faq-answer {
display: block;
}
.footer {
background: #1a202c;
color: #a0aec0;
padding: 2rem;
text-align: center;
}
@media (max-width: 768px) {
.pricing-card.featured {
transform: none;
}
.pricing-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="/landing/" class="logo">Aurora Livecam</a>
<nav class="nav-links">
<a href="/landing/">Home</a>
<a href="/dashboard/login.php">Login</a>
<a href="/onboarding/register.php" class="btn btn-primary btn-sm">Kostenlos starten</a>
</nav>
</div>
</header>
<section class="page-header">
<h1>Einfache, transparente Preise</h1>
<p><?php echo $trialDays; ?> Tage kostenlos testen - jederzeit kündbar</p>
<div class="pricing-toggle">
<span class="monthly-label active">Monatlich</span>
<div class="toggle-switch" id="billing-toggle"></div>
<span class="yearly-label">Jährlich</span>
<span class="save-badge">2 Monate gratis</span>
</div>
</section>
<div class="pricing-container" id="pricing-container">
<div class="pricing-grid">
<?php foreach ($plans as $index => $plan): ?>
<?php $isFeatured = $plan['slug'] === 'professional'; ?>
<div class="pricing-card <?php echo $isFeatured ? 'featured' : ''; ?>">
<h3><?php echo htmlspecialchars($plan['name']); ?></h3>
<div class="price price-monthly">
<?php if ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'], 0); ?><span>/Monat</span>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<div class="price price-yearly">
<?php if (isset($plan['price_yearly']) && $plan['price_yearly'] > 0): ?>
CHF <?php echo number_format($plan['price_yearly'] / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_yearly'], 0); ?> jährlich
</div>
<?php elseif ($plan['price_monthly'] > 0): ?>
CHF <?php echo number_format($plan['price_monthly'] * 10 / 12, 0); ?><span>/Monat</span>
<div style="font-size: 0.875rem; color: #718096;">
CHF <?php echo number_format($plan['price_monthly'] * 10, 0); ?> jährlich
</div>
<?php else: ?>
Kostenlos
<?php endif; ?>
</div>
<ul>
<?php
$features = is_array($plan['features']) ? $plan['features'] : json_decode($plan['features'], true) ?? [];
$allFeatures = ['max_viewers', 'weather_widget', 'timelapse', 'analytics', 'custom_domain', 'branding', 'priority_support'];
foreach ($allFeatures as $feature):
$hasFeature = !empty($features[$feature]);
$value = $features[$feature] ?? null;
?>
<li class="<?php echo $hasFeature ? 'included' : 'not-included'; ?>">
<?php
if ($feature === 'max_viewers' && $value) {
echo $value === -1 ? 'Unbegrenzte Zuschauer' : "Bis $value Zuschauer";
} elseif ($feature === 'storage_gb' && $value) {
echo "$value GB Speicher";
} else {
echo $featureLabels[$feature] ?? ucfirst(str_replace('_', ' ', $feature));
}
?>
</li>
<?php endforeach; ?>
</ul>
<a href="/onboarding/register.php?plan=<?php echo $plan['slug']; ?>"
class="btn <?php echo $isFeatured || $plan['price_monthly'] > 0 ? 'btn-primary' : 'btn-secondary'; ?>">
<?php echo $plan['price_monthly'] > 0 ? 'Jetzt starten' : 'Kostenlos starten'; ?>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- FAQ -->
<section class="faq">
<h2>Häufige Fragen</h2>
<div class="faq-item">
<div class="faq-question">
Kann ich jederzeit wechseln oder kündigen?
<span>+</span>
</div>
<div class="faq-answer">
Ja! Sie können Ihren Plan jederzeit upgraden oder downgraden. Bei einer Kündigung bleibt Ihr Zugang bis zum Ende der Abrechnungsperiode aktiv.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Was passiert nach dem Trial?
<span>+</span>
</div>
<div class="faq-answer">
Nach Ablauf der <?php echo $trialDays; ?> Tage werden Sie automatisch auf den kostenlosen Plan umgestellt, sofern Sie kein Abo abschliessen. Keine Sorge, Ihre Daten bleiben erhalten.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Welche Zahlungsmethoden werden akzeptiert?
<span>+</span>
</div>
<div class="faq-answer">
Wir akzeptieren alle gängigen Kreditkarten (Visa, Mastercard, American Express) sowie TWINT und Banküberweisung bei Jahresabos.
</div>
</div>
<div class="faq-item">
<div class="faq-question">
Brauche ich technisches Wissen?
<span>+</span>
</div>
<div class="faq-answer">
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.
</div>
</div>
</section>
<footer class="footer">
© <?php echo date('Y'); ?> Aurora Livecam. Alle Rechte vorbehalten.
</footer>
<script>
// Billing toggle
const toggle = document.getElementById('billing-toggle');
const container = document.getElementById('pricing-container');
toggle.addEventListener('click', () => {
toggle.classList.toggle('yearly');
container.classList.toggle('yearly-mode');
document.querySelector('.monthly-label').classList.toggle('active');
document.querySelector('.yearly-label').classList.toggle('active');
});
// FAQ accordion
document.querySelectorAll('.faq-question').forEach(q => {
q.addEventListener('click', () => {
q.parentElement.classList.toggle('open');
q.querySelector('span').textContent = q.parentElement.classList.contains('open') ? '' : '+';
});
});
</script>
</body>
</html>
@@ -0,0 +1,290 @@
<?php
/**
* StripeService - Stripe API Wrapper
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class StripeService
{
private ?string $secretKey;
private ?string $publicKey;
private ?string $webhookSecret;
private string $currency;
private Database $db;
public function __construct(?Database $db = null)
{
$this->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;
}
}
@@ -0,0 +1,285 @@
<?php
/**
* SubscriptionManager - Verwaltet Subscriptions
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class SubscriptionManager
{
private Database $db;
private StripeService $stripe;
public function __construct(?Database $db = null)
{
$this->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]
);
}
}
@@ -0,0 +1,250 @@
<?php
/**
* WebhookHandler - Verarbeitet Stripe Webhooks
*/
namespace AuroraLivecam\Billing;
use AuroraLivecam\Core\Database;
class WebhookHandler
{
private Database $db;
private StripeService $stripe;
private SubscriptionManager $subscriptions;
public function __construct(?Database $db = null)
{
$this->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';
}
}