527 lines
20 KiB
TypeScript
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>
|
|
);
|
|
};
|