*/ function billing_plans(): array { return [ 'free' => ['label' => 'Kostenlos', 'max_participants' => 10, 'price_cents' => 0, 'stripe_lookup_key' => null], 'basic' => ['label' => 'Basic', 'max_participants' => 25, 'price_cents' => 399, 'stripe_lookup_key' => 'kaffeeliste_basic'], 'plus' => ['label' => 'Plus', 'max_participants' => 50, 'price_cents' => 799, 'stripe_lookup_key' => 'kaffeeliste_plus'], 'pro' => ['label' => 'Pro', 'max_participants' => 150, 'price_cents' => 1299, 'stripe_lookup_key' => 'kaffeeliste_pro'], 'enterprise' => ['label' => 'Enterprise (auf Anfrage)', 'max_participants' => null, 'price_cents' => 0, 'stripe_lookup_key' => null], ]; } /** * Resolves the Stripe Price id for a plan by its lookup_key. Returns null * for plans without a Stripe price (free, enterprise) or on API failure. */ function billing_stripe_price_id(string $planCode): ?string { $plans = billing_plans(); $lookupKey = $plans[$planCode]['stripe_lookup_key'] ?? null; if ($lookupKey === null) { return null; } $result = stripe_request('GET', 'prices', ['lookup_keys' => [$lookupKey], 'active' => 'true']); if ($result['ok'] && !empty($result['data']['data'][0]['id'])) { return (string)$result['data']['data'][0]['id']; } return null; } /** * Returns the cheapest plan whose participant limit still covers * $activeParticipantCount. Used both to show customers which plan they * need and, once Stripe is wired up, to detect required upgrades. */ function billing_required_plan_code(int $activeParticipantCount): string { foreach (billing_plans() as $code => $plan) { if ($plan['max_participants'] === null || $activeParticipantCount <= $plan['max_participants']) { return $code; } } return 'enterprise'; } function billing_count_active_participants(PDO $pdo, int $tenantId): int { $stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND active = 1'); $stmt->execute([$tenantId]); return (int)$stmt->fetchColumn(); } /** * @return array{tenant_id: int, plan_code: string, subscription_status: string, stripe_customer_id: ?string, stripe_subscription_id: ?string, current_period_end: ?string} */ function billing_fetch_or_init(PDO $pdo, int $tenantId): array { $stmt = $pdo->prepare('SELECT * FROM tenant_billing WHERE tenant_id = ?'); $stmt->execute([$tenantId]); $row = $stmt->fetch(); if ($row === false) { $pdo->prepare("INSERT INTO tenant_billing (tenant_id, plan_code, subscription_status) VALUES (?, 'free', 'active')") ->execute([$tenantId]); return [ 'tenant_id' => $tenantId, 'plan_code' => 'free', 'subscription_status' => 'active', 'stripe_customer_id' => null, 'stripe_subscription_id' => null, 'current_period_end' => null, ]; } return $row; } function billing_update(PDO $pdo, int $tenantId, array $fields): void { billing_fetch_or_init($pdo, $tenantId); $setClauses = []; $params = []; foreach ($fields as $column => $value) { $setClauses[] = "{$column} = ?"; $params[] = $value; } $params[] = $tenantId; $pdo->prepare('UPDATE tenant_billing SET ' . implode(', ', $setClauses) . ' WHERE tenant_id = ?') ->execute($params); } /** * Ordnet einen Stripe-Preis (per lookup_key) wieder einem Plan-Code zu. Wird * gebraucht, um einen Tarifwechsel aus dem Stripe-Kundenportal lokal * nachzuziehen. Liefert null, wenn kein Plan zum lookup_key passt. */ function billing_plan_code_by_lookup_key(string $lookupKey): ?string { if ($lookupKey === '') { return null; } foreach (billing_plans() as $code => $plan) { if (($plan['stripe_lookup_key'] ?? null) === $lookupKey) { return $code; } } return null; } /** * Idempotenz-Schranke fuer den Stripe-Webhook: reserviert die Event-ID per * INSERT IGNORE. Liefert true, wenn das Event bereits verarbeitet wurde (also * uebersprungen werden soll), sonst false (frisch reserviert, darf verarbeitet * werden). Eine leere Event-ID gilt als "neu", damit ein unerwartetes Payload * nicht faelschlich als Duplikat durchrutscht. */ function billing_webhook_event_seen(PDO $pdo, string $eventId, string $eventType): bool { if ($eventId === '') { return false; } $stmt = $pdo->prepare( 'INSERT IGNORE INTO stripe_webhook_events (event_id, event_type) VALUES (?, ?)' ); $stmt->execute([$eventId, $eventType]); return $stmt->rowCount() === 0; } function billing_find_tenant_id_by_stripe_customer(PDO $pdo, string $stripeCustomerId): ?int { $stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?'); $stmt->execute([$stripeCustomerId]); $tenantId = $stmt->fetchColumn(); return $tenantId !== false ? (int)$tenantId : null; } function billing_find_tenant_id_by_stripe_subscription(PDO $pdo, string $stripeSubscriptionId): ?int { $stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_subscription_id = ?'); $stmt->execute([$stripeSubscriptionId]); $tenantId = $stmt->fetchColumn(); return $tenantId !== false ? (int)$tenantId : null; } /** * Hard cap enforcement: whether the tenant's current plan still has room * for $additional more active participants. There is no automatic plan * upgrade - tenants that outgrow their plan must upgrade themselves * (mandant-einstellungen.php) before adding/reactivating more members. */ function billing_has_capacity_for(PDO $pdo, int $tenantId, int $additional = 1): bool { $billing = billing_fetch_or_init($pdo, $tenantId); $max = billing_plans()[$billing['plan_code']]['max_participants'] ?? null; if ($max === null) { return true; } return billing_count_active_participants($pdo, $tenantId) + $additional <= $max; } function billing_capacity_error_message(PDO $pdo, int $tenantId): string { $billing = billing_fetch_or_init($pdo, $tenantId); $plan = billing_plans()[$billing['plan_code']] ?? null; $label = $plan['label'] ?? $billing['plan_code']; $max = $plan['max_participants'] ?? null; return "Euer aktueller Tarif „{$label}\" erlaubt maximal {$max} aktive Mitglieder. " . 'Bitte upgradet unter Mandant-Einstellungen, um mehr Mitglieder freizuschalten.'; } /** * Compares the tenant's currently booked plan against what their active * participant count actually requires. Purely informational until the * Stripe integration lands — nothing here blocks usage or auto-upgrades. * * @return array{current_plan: string, required_plan: string, active_participants: int, needs_upgrade: bool} */ function billing_check_tenant(PDO $pdo, int $tenantId): array { $billing = billing_fetch_or_init($pdo, $tenantId); $activeCount = billing_count_active_participants($pdo, $tenantId); $requiredPlan = billing_required_plan_code($activeCount); $plans = array_keys(billing_plans()); $currentRank = array_search($billing['plan_code'], $plans, true); $requiredRank = array_search($requiredPlan, $plans, true); return [ 'current_plan' => $billing['plan_code'], 'required_plan' => $requiredPlan, 'active_participants' => $activeCount, 'needs_upgrade' => $requiredRank !== false && $currentRank !== false && $requiredRank > $currentRank, ]; } /** * Whether a tenant could self-service switch to $planCode given its current * active participant count - i.e. the plan's limit (if any) still covers * them. Used to grey out/reject plan selections that would immediately put * the tenant over their own member count (a downgrade or lateral move that * doesn't fit). Unknown plan codes are treated as not selectable. */ function billing_plan_selectable_for(PDO $pdo, int $tenantId, string $planCode): bool { $plans = billing_plans(); if (!isset($plans[$planCode])) { return false; } $max = $plans[$planCode]['max_participants']; if ($max === null) { return true; } return billing_count_active_participants($pdo, $tenantId) <= $max; }