/** * 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, SubscriptionUsage } from '../../api/subscriptionApi'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import styles from './Billing.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; // ============================================================================ // Helpers // ============================================================================ 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; } }; function _getStatusMap(t: (k: string) => string): Record { return { PENDING: { label: t('Zahlung ausstehend'), color: '#f59e0b' }, SCHEDULED: { label: t('Geplant'), color: '#8b5cf6' }, ACTIVE: { label: t('Aktiv'), color: '#22c55e' }, TRIALING: { label: t('Testphase'), color: '#38bdf8' }, PAST_DUE: { label: t('Zahlung ausstehend'), color: '#f59e0b' }, EXPIRED: { label: t('Abgelaufen'), color: '#6b7280' }, }; } function _getPeriodMap(t: (k: string) => string): Record { return { MONTHLY: t('Monatlich'), YEARLY: t('Jährlich'), NONE: '—', }; } const _storageOveragePerGbMonth = 0.5; const _isEnterpriseSub = (sub: MandateSubscription | null): boolean => !!sub && (sub.isEnterprise === true || sub.planKey === 'ENTERPRISE'); // ============================================================================ // Plan Card // ============================================================================ interface PlanCardProps { plan: SubscriptionPlan; isCurrent: boolean; onActivate: (planKey: string) => void; activatingPlanKey: string | null; } const _PlanCard: React.FC = ({ plan, isCurrent, onActivate, activatingPlanKey }) => { const { t } = useLanguage(); const period = _getPeriodMap(t); const activating = activatingPlanKey === plan.planKey; const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0; const isYearly = plan.billingPeriod === 'YEARLY'; const monthlyEquivalent = isYearly ? plan.pricePerUserCHF / 12 : null; return (
{/* Header */}
{plan.title} {isCurrent && ( {t('Aktuell')} )}

{plan.description}

{/* Pricing */} {!isFreePlan && (
{_formatCurrency(plan.pricePerUserCHF)}
{t('pro User')} / {period[plan.billingPeriod] || plan.billingPeriod}
{monthlyEquivalent != null && (
≈ {_formatCurrency(monthlyEquivalent)} {t('pro User / Monat')}
)}
)} {/* Features list */} {!isFreePlan && (
<_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules ?? 0 })} /> {(plan.pricePerFeatureInstanceCHF ?? 0) > 0 && ( <_FeatureRow icon="➕" text={t('Zusatzmodul: {price} / Monat', { price: _formatCurrency(plan.pricePerFeatureInstanceCHF) })} /> )} <_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User / Monat', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} /> <_FeatureRow icon="💾" text={ plan.maxDataVolumeMB == null ? t('Speicher: unbegrenzt') : t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) }) } /> {plan.maxUsers != null && ( <_FeatureRow icon="👥" text={t('Max. {count} User', { count: plan.maxUsers })} /> )}
)} {/* Trial info */} {isFreePlan && plan.trialDays && (
<_FeatureRow icon="🎁" text={t('{days} Tage kostenlos', { days: plan.trialDays })} /> {plan.maxUsers && <_FeatureRow icon="👤" text={t('{count} Users maximal', { count: plan.maxUsers })} />} {(plan.includedModules ?? 0) > 0 && <_FeatureRow icon="📦" text={t('{count} Module inklusive', { count: plan.includedModules })} />} {(plan.budgetAiPerUserCHF ?? 0) > 0 && <_FeatureRow icon="🤖" text={t('AI-Budget: {price} / User', { price: _formatCurrency(plan.budgetAiPerUserCHF ?? 0) })} />} {plan.maxDataVolumeMB != null && <_FeatureRow icon="💾" text={t('Speicher: {size}', { size: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB) })} />}
)} {/* Action */} {!isCurrent && ( )}
); }; const _FeatureRow: React.FC<{ icon: string; text: string }> = ({ icon, text }) => (
{icon} {text}
); // ============================================================================ // Usage Metric // ============================================================================ interface UsageMetricProps { label: string; value: number; max?: number; formatValue?: (v: number) => string; } const _UsageMetric: React.FC = ({ label, value, max, formatValue }) => { const fmt = formatValue ?? ((v: number) => String(v)); const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null; const isWarning = percent != null && percent >= 80; const barColor = isWarning ? '#f59e0b' : 'var(--primary-color, #F25843)'; return (
{label}
{fmt(value)} {max != null && ( / {fmt(max)} )}
{percent != null && (
)}
); }; // ============================================================================ // Subscription Info Card // ============================================================================ interface SubInfoProps { sub: MandateSubscription; plan: SubscriptionPlan | null; usage: SubscriptionUsage | null; label: string; onCancel?: (id: string) => void; onReactivate?: (id: string) => void; cancelling: boolean; reactivating: boolean; justPaid?: boolean; } const _SubInfoCard: React.FC = ({ sub, plan, usage, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => { const { t } = useLanguage(); const statusMap = _getStatusMap(t); const periodMap = _getPeriodMap(t); const statusInfo = statusMap[sub.status] || statusMap.EXPIRED; const isActive = sub.status === 'ACTIVE'; const isPending = sub.status === 'PENDING'; const isScheduled = sub.status === 'SCHEDULED'; return (
{/* Header */}
{label}
{plan ? plan.title : sub.planKey}
{_isEnterpriseSub(sub) && ( Enterprise )} {isActive && !sub.recurring && ( {t('Gekündigt')} )} {statusInfo.label}
{/* Pending notice */} {isPending && (
{justPaid ? t('Zahlung erfolgreich – Abonnement wird aktiviert') : t('Zahlung noch nicht eingegangen')}
)} {/* Scheduled notice */} {isScheduled && sub.effectiveFrom && (
{t('Wird am {date} aktiv, wenn das aktuelle Abonnement ausläuft.', { date: _formatDate(sub.effectiveFrom) })}
)} {/* Details grid */} {!isPending && !isScheduled && (
{t('Gestartet:')} {_formatDate(sub.startedAt)} {plan && {t('Periode:')} {periodMap[plan.billingPeriod] || '—'}} {sub.currentPeriodEnd && {t('Periodenende:')} {_formatDate(sub.currentPeriodEnd)}} {sub.trialEndsAt && {t('Trial endet:')} {_formatDate(sub.trialEndsAt)}} {isActive && !sub.recurring && sub.currentPeriodEnd && ( {t('Läuft aus am:')} {_formatDate(sub.currentPeriodEnd)} )}
)} {/* Plan details — enterprise vs. standard */} {!isPending && !isScheduled && _isEnterpriseSub(sub) && (
{t('Pauschalpreis:')} {_formatCurrency(sub.enterpriseFlatPriceCHF ?? 0)} {t('Max. Benutzer:')} {sub.enterpriseMaxUsers != null ? sub.enterpriseMaxUsers : t('unbegrenzt')} {t('Max. Module:')} {sub.enterpriseMaxFeatureInstances != null ? sub.enterpriseMaxFeatureInstances : t('unbegrenzt')} {t('Speicher:')}{' '} {sub.enterpriseMaxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sub.enterpriseMaxDataVolumeMB) : t('unbegrenzt')} {sub.enterpriseBudgetAiCHF != null && ( {t('AI-Budget:')} {_formatCurrency(sub.enterpriseBudgetAiCHF)} )} {sub.enterpriseNote && ( {sub.enterpriseNote} )}
)} {!isPending && !isScheduled && !_isEnterpriseSub(sub) && plan && (
{t('AI-Budget:')} {_formatCurrency(plan.budgetAiPerUserCHF ?? 0)} {t('/ User / Monat')} {t('Module inkl.:')} {plan.includedModules ?? 0} {t('Speicher:')}{' '} {plan.maxDataVolumeMB == null ? t('unbegrenzt') : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} {t('Speicher über Plan:')} {_formatCurrency(_storageOveragePerGbMonth)} {t('/ GB / Monat')}
)} {/* Current usage */} {usage && !isPending && !isScheduled && (
{t('Aktuelle Nutzung')}
<_UsageMetric label={t('User')} value={usage.activeUsers} max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxUsers ?? undefined) : (plan?.maxUsers ?? undefined)} /> <_UsageMetric label={t('Module')} value={usage.activeInstances} max={_isEnterpriseSub(sub) ? (sub.enterpriseMaxFeatureInstances ?? undefined) : (plan?.includedModules ?? undefined)} /> <_UsageMetric label={t('Speicher')} value={usage.usedStorageMB} max={usage.maxStorageMB ?? undefined} formatValue={(v) => formatBinaryDataSizeFromMebibytes(v)} />
)} {/* Actions — enterprise subscriptions are sysadmin-managed, no self-service */} {_isEnterpriseSub(sub) ? (
{t('Dieses Abonnement wird vom Plattform-Administrator verwaltet.')}
) : (
{isActive && !sub.recurring && onReactivate && ( )} {isActive && sub.recurring && onCancel && ( )} {(isPending || isScheduled) && onCancel && ( )}
)}
); }; // ============================================================================ // Subscription Tab // ============================================================================ interface SubscriptionTabProps { mandateId: string; } export const SubscriptionTab: React.FC = ({ mandateId }) => { const { t } = useLanguage(); const { plans, subscription, plan: currentPlan, scheduled, usage, 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: t('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: t('Abonnement wurde aktiviert.') }); setJustPaid(false); return; } } catch { /* 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: t('Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.') }); const url = new URL(window.location.href); url.searchParams.delete('canceled'); window.history.replaceState({}, '', url.toString()); } }, [verifyCheckout, t]); useEffect(() => { if (!justPaid) return; if (subscription && subscription.status !== 'PENDING') { setJustPaid(false); setCheckoutMessage({ type: 'success', text: t('Abonnement wurde aktiviert.') }); } }, [justPaid, subscription, t]); const _handleActivate = useCallback(async (planKey: string) => { setActivatingPlanKey(planKey); setActionError(null); try { await activatePlan(planKey); } catch (err: any) { setActionError(err?.response?.data?.detail || err.message || t('Fehler beim Aktivieren')); } finally { setActivatingPlanKey(null); } }, [activatePlan, t]); 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 ? t('Diesen Vorgang abbrechen?') : t('Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.'), { title: isPendingOrScheduled ? t('Vorgang abbrechen') : t('Abonnement kündigen'), confirmLabel: isPendingOrScheduled ? t('Ja, abbrechen') : t('Kündigen'), cancelLabel: isPendingOrScheduled ? t('Nein, zurück') : t('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 || t('Fehler')); } finally { setCancelling(false); } }, [cancelSubscription, subscription, scheduled, confirm, t]); const _handleReactivate = useCallback(async (subscriptionId: string) => { setReactivating(true); setActionError(null); try { await reactivateSubscription(subscriptionId); } catch (err: any) { setActionError(err?.response?.data?.detail || err.message || t('Fehler beim Reaktivieren')); } finally { setReactivating(false); } }, [reactivateSubscription, t]); if (loading && !subscription) { return
{t('Abonnementdaten werden geladen…')}
; } return (
{/* Checkout feedback */} {checkoutMessage && (
{checkoutMessage.text}
)} {/* Error display */} {(error || actionError) && (
{actionError || error}
)} {/* Current subscription */}

{t('Aktuelles Abonnement')}

{subscription ? ( <_SubInfoCard sub={subscription} plan={currentPlan} usage={usage} label={subscription.status === 'PENDING' ? (justPaid ? t('Zahlung wird verarbeitet…') : t('Checkout läuft…')) : t('Operatives Abonnement')} onCancel={_handleCancel} onReactivate={_handleReactivate} cancelling={cancelling} reactivating={reactivating} justPaid={justPaid} /> ) : (
{t('Kein aktives Abonnement. Wählen Sie unten einen Plan.')}
)}
{/* Scheduled successor */} {scheduled && (

{t('Geplanter Nachfolgeplan')}

<_SubInfoCard sub={scheduled} plan={null} usage={null} label={t('Startet nach Ablauf des aktuellen Plans')} onCancel={_handleCancel} cancelling={cancelling} reactivating={false} />
)} {/* Available plans — hidden for enterprise subscriptions */} {!_isEnterpriseSub(subscription) && (

{t('Verfügbare Pläne')}

{plans.length === 0 ? (
{t('Keine Pläne verfügbar')}
) : (
{plans.map((p) => ( <_PlanCard key={p.planKey} plan={p} isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'} onActivate={_handleActivate} activatingPlanKey={activatingPlanKey} /> ))}
)}
)}
); };