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
@@ -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;
}
}