frontend_nyla/src/pages/billing/SubscriptionTab.tsx
2026-03-31 01:12:29 +02:00

527 lines
20 KiB
TypeScript

/**
* 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<string, string> | 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<string, { label: string; color: string }> = {
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<string, string> = {
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<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
const activating = activatingPlanKey === plan.planKey;
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
return (
<div style={{
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
borderRadius: '8px',
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
minWidth: 220,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1rem' }}>{_t(plan.title)}</strong>
{isCurrent && (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
}}>Aktuell</span>
)}
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
{_t(plan.description)}
</p>
{!isFreePlan && (
<div style={{ fontSize: '0.85rem' }}>
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary, #888)' }}>
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
{' · '}
Speicher (inkl.):{' '}
<strong>
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</strong>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: '0.25rem' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
</div>
</div>
)}
{isFreePlan && plan.trialDays && (
<div style={{ fontSize: '0.85rem' }}>
{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)}</>}
</>
)}
</div>
)}
{!isCurrent && (
<button
onClick={() => onActivate(plan.planKey)}
disabled={!!activatingPlanKey}
style={{
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
cursor: activatingPlanKey ? 'wait' : 'pointer',
opacity: activatingPlanKey ? 0.6 : 1,
}}
>
{activating
? 'Weiterleitung...'
: (!isFreePlan && !plan.trialDays) ? 'Kostenpflichtig abonnieren' : 'Auswählen'}
</button>
)}
</div>
);
};
// ============================================================================
// 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<SubInfoProps> = ({ 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 (
<div style={{
border: '1px solid var(--color-border, #333)',
borderRadius: '8px',
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
background: 'var(--color-surface, #1a1a2e)',
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontSize: '1.1rem' }}>{plan ? _t(plan.title) : sub.planKey}</strong>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{isActive && !sub.recurring && (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
background: '#ef4444', color: '#fff', fontWeight: 600,
}}>Gekündigt</span>
)}
<span style={{
fontSize: '0.75rem', padding: '2px 10px', borderRadius: '4px',
background: statusInfo.color, color: '#fff', fontWeight: 600,
}}>{statusInfo.label}</span>
</div>
</div>
{isPending && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '6px',
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
}}>
{justPaid
? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...'
: 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'}
</div>
)}
{isScheduled && sub.effectiveFrom && (
<div style={{
padding: '0.6rem 0.8rem', borderRadius: '6px',
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
color: '#8b5cf6', fontSize: '0.85rem',
}}>
Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft.
</div>
)}
{!isPending && !isScheduled && (
<div style={{
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
}}>
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
{plan && <span>Periode: {_periodLabel[plan.billingPeriod] || '—'}</span>}
{sub.currentPeriodEnd && <span>Periodenende: {_formatDate(sub.currentPeriodEnd)}</span>}
{sub.trialEndsAt && <span>Trial endet: {_formatDate(sub.trialEndsAt)}</span>}
{isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
)}
{plan && (
<>
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
<span>
Speicher (inkl.):{' '}
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span style={{ gridColumn: '1 / -1' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
</span>
</>
)}
</div>
)}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
{isActive && !sub.recurring && onReactivate && (
<button
onClick={() => onReactivate(sub.id)}
disabled={reactivating}
style={{
padding: '6px 14px', borderRadius: '6px', border: 'none',
background: 'var(--color-primary, #3b82f6)', color: '#fff',
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{reactivating ? 'Wird reaktiviert...' : 'Reaktivieren'}
</button>
)}
{isActive && sub.recurring && onCancel && (
<button
onClick={() => onCancel(sub.id)}
disabled={cancelling}
style={{
padding: '6px 14px', borderRadius: '6px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500,
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{cancelling ? 'Wird gekündigt...' : 'Kündigen'}
</button>
)}
{(isPending || isScheduled) && onCancel && (
<button
onClick={() => onCancel(sub.id)}
disabled={cancelling}
style={{
padding: '6px 14px', borderRadius: '6px',
border: '1px solid #ef4444', background: 'transparent',
color: '#ef4444', fontWeight: 500,
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
{cancelling ? 'Wird abgebrochen...' : 'Abbrechen'}
</button>
)}
</div>
</div>
);
};
// ============================================================================
// Subscription Tab
// ============================================================================
interface SubscriptionTabProps {
mandateId: string;
}
export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) => {
const {
plans,
subscription,
plan: currentPlan,
scheduled,
loading,
error,
activatePlan,
cancelSubscription,
reactivateSubscription,
verifyCheckout,
} = useSubscription(mandateId);
const { confirm, ConfirmDialog } = useConfirm();
const [activatingPlanKey, setActivatingPlanKey] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [reactivating, setReactivating] = useState(false);
const [actionError, setActionError] = useState<string | null>(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 <div className={styles.loadingPlaceholder}>Lade Abonnement-Daten...</div>;
}
return (
<div>
{/* Checkout feedback */}
{checkoutMessage && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
fontSize: '0.9rem',
}}>
{checkoutMessage.text}
</div>
)}
{/* Error display */}
{(error || actionError) && (
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
{actionError || error}
</div>
)}
{/* Current subscription */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Aktuelles Abonnement</h2>
{subscription ? (
<SubInfoCard
sub={subscription}
plan={currentPlan}
label={subscription.status === 'PENDING'
? (justPaid ? 'Zahlung wird verarbeitet' : 'Checkout in Bearbeitung')
: 'Operatives Abonnement'}
onCancel={_handleCancel}
onReactivate={_handleReactivate}
cancelling={cancelling}
reactivating={reactivating}
justPaid={justPaid}
/>
) : (
<div className={styles.noData}>
Kein aktives Abonnement. Wählen Sie unten einen Plan.
</div>
)}
</section>
{/* Scheduled successor */}
{scheduled && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Geplanter Nachfolger</h2>
<SubInfoCard
sub={scheduled}
plan={null}
label="Startet nach Ablauf des aktuellen Abonnements"
onCancel={_handleCancel}
cancelling={cancelling}
reactivating={false}
/>
</section>
)}
{/* Available plans */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Verfügbare Pläne</h2>
{plans.length === 0 ? (
<div className={styles.noData}>Keine Pläne verfügbar</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: '1rem',
}}>
{plans.map((p) => (
<PlanCard
key={p.planKey}
plan={p}
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
onActivate={_handleActivate}
activatingPlanKey={activatingPlanKey}
/>
))}
</div>
)}
</section>
<ConfirmDialog />
</div>
);
};