/** * SubscriptionTab — State-machine-aligned subscription management UI. * * Shows: * - Current operative subscription with status, recurring flag, and period info * - Scheduled successor (if plan switch in progress) * - Available plans as cards * - ID-based actions: cancel, reactivate, activate */ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useSubscription } from '../../hooks/useSubscription'; import { useConfirm } from '../../hooks/useConfirm'; import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import styles from './Billing.module.css'; // ============================================================================ // Helpers // ============================================================================ const _lang = (): string => typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en'; const _t = (dict: Record | undefined): string => { if (!dict) return ''; const l = _lang(); return dict[l] || dict['en'] || dict['de'] || Object.values(dict)[0] || ''; }; const _formatCurrency = (amount: number) => new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); const _formatDate = (iso: string | null | undefined): string => { if (!iso) return '—'; try { return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return iso; } }; const _statusLabel: Record = { PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' }, SCHEDULED: { label: 'Geplant', color: '#8b5cf6' }, ACTIVE: { label: 'Aktiv', color: '#22c55e' }, TRIALING: { label: 'Testphase', color: '#3b82f6' }, PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' }, EXPIRED: { label: 'Abgelaufen', color: '#6b7280' }, }; const _periodLabel: Record = { MONTHLY: 'Monatlich', YEARLY: 'Jährlich', NONE: '—', }; /** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */ const storageOverageChfPerGbMonth = 0.5; // ============================================================================ // Plan Card // ============================================================================ interface PlanCardProps { plan: SubscriptionPlan; isCurrent: boolean; onActivate: (planKey: string) => void; activatingPlanKey: string | null; } const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activatingPlanKey }) => { const activating = activatingPlanKey === plan.planKey; const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0; return (
{_t(plan.title)} {isCurrent && ( Aktuell )}

{_t(plan.description)}

{!isFreePlan && (
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode {' · '} Speicher (inkl.):{' '} {plan.maxDataVolumeMB == null ? 'unbegrenzt' : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
)} {isFreePlan && plan.trialDays && (
{plan.trialDays} Tage kostenlos {plan.maxUsers && <> · max. {plan.maxUsers} User} {plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen} {(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && ( <> {plan.maxDataVolumeMB != null && ( <> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} )} {(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}} )}
)} {!isCurrent && ( )}
); }; // ============================================================================ // Subscription Info Card // ============================================================================ interface SubInfoProps { sub: MandateSubscription; plan: SubscriptionPlan | null; label: string; onCancel?: (id: string) => void; onReactivate?: (id: string) => void; cancelling: boolean; reactivating: boolean; justPaid?: boolean; } const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => { const statusInfo = _statusLabel[sub.status] || _statusLabel.EXPIRED; const isActive = sub.status === 'ACTIVE'; const isPending = sub.status === 'PENDING'; const isScheduled = sub.status === 'SCHEDULED'; return (
{label}
{plan ? _t(plan.title) : sub.planKey}
{isActive && !sub.recurring && ( Gekündigt )} {statusInfo.label}
{isPending && (
{justPaid ? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...' : 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'}
)} {isScheduled && sub.effectiveFrom && (
Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft.
)} {!isPending && !isScheduled && (
Gestartet: {_formatDate(sub.startedAt)} {plan && Periode: {_periodLabel[plan.billingPeriod] || '—'}} {sub.currentPeriodEnd && Periodenende: {_formatDate(sub.currentPeriodEnd)}} {sub.trialEndsAt && Trial endet: {_formatDate(sub.trialEndsAt)}} {isActive && !sub.recurring && sub.currentPeriodEnd && ( Läuft aus am: {_formatDate(sub.currentPeriodEnd)} )} {plan && ( <> AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode Speicher (inkl.):{' '} {plan.maxDataVolumeMB == null ? 'unbegrenzt' : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark) )}
)}
{isActive && !sub.recurring && onReactivate && ( )} {isActive && sub.recurring && onCancel && ( )} {(isPending || isScheduled) && onCancel && ( )}
); }; // ============================================================================ // Subscription Tab // ============================================================================ interface SubscriptionTabProps { mandateId: string; } export const SubscriptionTab: React.FC = ({ mandateId }) => { const { plans, subscription, plan: currentPlan, scheduled, loading, error, activatePlan, cancelSubscription, reactivateSubscription, verifyCheckout, } = useSubscription(mandateId); const { confirm, ConfirmDialog } = useConfirm(); const [activatingPlanKey, setActivatingPlanKey] = useState(null); const [cancelling, setCancelling] = useState(false); const [reactivating, setReactivating] = useState(false); const [actionError, setActionError] = useState(null); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'info'; text: string } | null>(null); const [justPaid, setJustPaid] = useState(false); const verifyCalledRef = useRef(false); useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('success') === 'true') { const sessionId = params.get('session_id') || ''; setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich — Abonnement wird aktiviert...' }); setJustPaid(true); const url = new URL(window.location.href); url.searchParams.delete('success'); url.searchParams.delete('session_id'); window.history.replaceState({}, '', url.toString()); if (sessionId && !verifyCalledRef.current) { verifyCalledRef.current = true; const _pollUntilActive = async (retries = 5, delayMs = 2000) => { try { const result = await verifyCheckout(sessionId); if (result.status === 'activated') { setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); setJustPaid(false); return; } } catch { /* handled below via retry */ } if (retries > 0) { await new Promise(r => setTimeout(r, delayMs)); await _pollUntilActive(retries - 1, delayMs); } }; _pollUntilActive(); } } else if (params.get('canceled') === 'true') { setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' }); const url = new URL(window.location.href); url.searchParams.delete('canceled'); window.history.replaceState({}, '', url.toString()); } }, []); useEffect(() => { if (!justPaid) return; if (subscription && subscription.status !== 'PENDING') { setJustPaid(false); setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); } }, [justPaid, subscription]); const _handleActivate = useCallback(async (planKey: string) => { setActivatingPlanKey(planKey); setActionError(null); try { await activatePlan(planKey); } catch (err: any) { setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren'); } finally { setActivatingPlanKey(null); } }, [activatePlan]); const _handleCancel = useCallback(async (subscriptionId: string) => { const sub = subscription?.id === subscriptionId ? subscription : scheduled; const isPendingOrScheduled = sub?.status === 'PENDING' || sub?.status === 'SCHEDULED'; const ok = await confirm( isPendingOrScheduled ? 'Diesen Vorgang abbrechen?' : 'Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.', { title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen', confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen', cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen', variant: 'danger', }, ); if (!ok) return; setCancelling(true); setActionError(null); try { await cancelSubscription(subscriptionId); setCheckoutMessage(null); } catch (err: any) { setActionError(err?.response?.data?.detail || err.message || 'Fehler'); } finally { setCancelling(false); } }, [cancelSubscription, subscription, scheduled]); const _handleReactivate = useCallback(async (subscriptionId: string) => { setReactivating(true); setActionError(null); try { await reactivateSubscription(subscriptionId); } catch (err: any) { setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren'); } finally { setReactivating(false); } }, [reactivateSubscription]); if (loading && !subscription) { return
Lade Abonnement-Daten...
; } return (
{/* Checkout feedback */} {checkoutMessage && (
{checkoutMessage.text}
)} {/* Error display */} {(error || actionError) && (
{actionError || error}
)} {/* Current subscription */}

Aktuelles Abonnement

{subscription ? ( ) : (
Kein aktives Abonnement. Wählen Sie unten einen Plan.
)}
{/* Scheduled successor */} {scheduled && (

Geplanter Nachfolger

)} {/* Available plans */}

Verfügbare Pläne

{plans.length === 0 ? (
Keine Pläne verfügbar
) : (
{plans.map((p) => ( ))}
)}
); };