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