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,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