/** * Billing Admin Page * * Admin-Seite für Billing-Verwaltung (SysAdmin only). * - Settings verwalten * - Guthaben aufladen * - Konten übersicht */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling'; import type { CheckoutCreateRequest } from '../../api/billingApi'; import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates'; import { useCurrentUser } from '../../hooks/useUsers'; import { useApiRequest } from '../../hooks/useApi'; import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { SubscriptionTab } from './SubscriptionTab'; import api from '../../api'; import { getUserDataCache } from '../../utils/userCache'; import styles from './Billing.module.css'; type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions'; const _formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); }; const _mandateDisplayLabel = (m: UserMandateRow): string => { if (m.label) return m.label; if (typeof m.name === 'object' && m.name) { const n = m.name as Record; return n.de || n.en || Object.values(n)[0] || m.id; } return (m.name as string) || m.id; }; // ============================================================================ // MANDATE SELECTOR // ============================================================================ interface MandateSelectorProps { mandates: UserMandateRow[]; loading: boolean; selectedMandateId: string | null; onSelect: (mandateId: string) => void; } const MandateSelector: React.FC = ({ mandates, loading, selectedMandateId, onSelect, }) => (
); // ============================================================================ // SETTINGS EDITOR // ============================================================================ interface SettingsEditorProps { settings: BillingSettings | null; onSave: (settings: Partial) => Promise; loading: boolean; } const SettingsEditor: React.FC = ({ settings, onSave, loading }) => { const [formData, setFormData] = useState({ billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'], defaultUserCredit: Number(settings?.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), notifyOnWarning: settings?.notifyOnWarning ?? true, }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); useEffect(() => { if (settings) { setFormData({ billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE', defaultUserCredit: Number(settings.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), notifyOnWarning: settings.notifyOnWarning ?? true, }); } }, [settings]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSaving(true); setMessage(null); try { await onSave(formData); setMessage({ type: 'success', text: 'Einstellungen gespeichert!' }); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler beim Speichern' }); } finally { setSaving(false); } }; return (

Billing-Einstellungen

{message && (
{message.text}
)}
setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))} min="0" step="0.01" />
setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))} min="0" max="100" step="1" />
); }; // ============================================================================ // CREDIT ADDER // ============================================================================ interface CreditAdderProps { settings: BillingSettings | null; accounts: AccountSummary[]; users: MandateUserSummary[]; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); const [amount, setAmount] = useState(''); const [description, setDescription] = useState('Manuelle Buchung durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; const accountsByUserId = accounts .filter(acc => acc.accountType === 'USER') .reduce((map, acc) => { if (acc.userId) map[acc.userId] = acc; return map; }, {} as Record); const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const numAmount = parseFloat(amount); if (!numAmount || numAmount === 0) { setMessage({ type: 'error', text: 'Betrag darf nicht null sein' }); return; } setSaving(true); setMessage(null); try { await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); const label = numAmount > 0 ? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` : `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`; setMessage({ type: 'success', text: label }); setAmount(''); } catch (err: any) { setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' }); } finally { setSaving(false); } }; return (

Guthaben manuell verwalten

{message && (
{message.text}
)}
{isPrepayUser && (
)}
setAmount(e.target.value)} placeholder="z.B. 50 oder -20" step="0.01" required />
setDescription(e.target.value)} placeholder="Beschreibung der Gutschrift" />
); }; // ============================================================================ // ACCOUNTS OVERVIEW // ============================================================================ interface AccountsOverviewProps { accounts: AccountSummary[]; users: MandateUserSummary[]; loading: boolean; } const AccountsOverview: React.FC = ({ accounts, users, loading }) => { const formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount); }; // Build a lookup map: userId -> display name const _userNameMap = useMemo(() => { const map = new Map(); for (const user of users) { const displayName = user.displayName || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username || user.id; map.set(user.id, displayName); } return map; }, [users]); if (loading) { return
Lade Konten...
; } if (accounts.length === 0) { return
Keine Konten vorhanden
; } return (

Konten

{accounts.map((account) => (

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

{account.userId && User: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}
))}
); }; // ============================================================================ // MANDATE ADMIN — STRIPE TOP-UP (same URL as SysAdmin billing admin) // ============================================================================ interface MandateStripeTopUpProps { mandateId: string; createCheckout: ( checkoutRequest: CheckoutCreateRequest, targetMandateId?: string ) => Promise<{ redirectUrl?: string } | null>; } const MandateStripeTopUp: React.FC = ({ mandateId, createCheckout }) => { const [amount, setAmount] = useState(''); const [busy, setBusy] = useState(false); const [localMsg, setLocalMsg] = useState(null); const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const n = parseFloat(amount); if (!n || n <= 0) { setLocalMsg('Betrag muss positiv sein'); return; } setBusy(true); setLocalMsg(null); try { const currentUser = getUserDataCache(); const currentUrl = new URL(window.location.href); currentUrl.searchParams.delete('success'); currentUrl.searchParams.delete('canceled'); currentUrl.searchParams.delete('session_id'); currentUrl.hash = ''; const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; const result = await createCheckout( { userId: currentUser?.id, amount: n, returnUrl }, mandateId ); if (result?.redirectUrl) { window.location.href = result.redirectUrl; } } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Checkout fehlgeschlagen'; setLocalMsg(msg); setBusy(false); } }; return (

Guthaben via Stripe aufladen

Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.

{localMsg &&
{localMsg}
}
setAmount(e.target.value)} placeholder="z.B. 50" min="0.01" step="0.01" required />
); }; // ============================================================================ // MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export) // ============================================================================ const _mandateTxColumns: ColumnConfig[] = [ { key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 }, { key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 }, { key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 }, { key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 }, { key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 }, { key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 }, { key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 }, ]; interface MandateTransactionsTabProps { mandateId: string; } const MandateTransactionsTab: React.FC = ({ mandateId }) => { const { request, isLoading: loading } = useApiRequest(); const [transactions, setTransactions] = useState([]); const [pagination, setPagination] = useState(null); const [error, setError] = useState(null); const _loadTransactions = useCallback(async (params?: any) => { try { setError(null); const requestParams: Record = {}; if (params) { const paginationObj: any = {}; if (params.page !== undefined) paginationObj.page = params.page; if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize; if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); } } const data = await request({ url: `/api/billing/admin/transactions/${mandateId}`, method: 'get', params: requestParams, }); if (data && typeof data === 'object' && 'items' in data) { setTransactions(Array.isArray(data.items) ? data.items : []); if (data.pagination) setPagination(data.pagination); } else { setTransactions(Array.isArray(data) ? data : []); setPagination(null); } } catch (err: any) { setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden'); setTransactions([]); setPagination(null); } }, [request, mandateId]); useEffect(() => { _loadTransactions(); }, [_loadTransactions]); return (

AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.

{error &&
{error}
} _loadTransactions()} hookData={{ refetch: _loadTransactions, pagination }} />
); }; // ============================================================================ // MAIN COMPONENT // ============================================================================ export const BillingAdmin: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const { user: currentUser } = useCurrentUser(); const isSysAdmin = currentUser?.isSysAdmin === true; const [selectedMandateId, setSelectedMandateId] = useState( searchParams.get('mandate') || null ); const [mandateList, setMandateList] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); const { fetchMandates } = useUserMandates(); const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts, createCheckout, } = useBillingAdmin(selectedMandateId || undefined); const handleMandateSelect = (mandateId: string) => { setSelectedMandateId(mandateId || null); }; const handleSaveSettings = useCallback(async (settingsUpdate: Partial) => { if (!selectedMandateId) return; await saveSettings(settingsUpdate); }, [selectedMandateId, saveSettings]); const _handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => { if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt'); const result = await addCredit({ userId, amount, description }); if (!result) throw new Error('Gutschrift konnte nicht erstellt werden'); await loadAccounts(); return result; }, [selectedMandateId, addCredit, loadAccounts]); useEffect(() => { let cancelled = false; (async () => { setMandatesLoading(true); try { const data = await fetchMandates(); if (!cancelled) { setMandateList(Array.isArray(data) ? data : []); } } finally { if (!cancelled) setMandatesLoading(false); } })(); return () => { cancelled = true; }; }, [fetchMandates]); useEffect(() => { if (!isSysAdmin && mandateList.length === 1 && selectedMandateId === null) { setSelectedMandateId(mandateList[0].id); } }, [isSysAdmin, mandateList, selectedMandateId]); const [stripeReturnMessage, setStripeReturnMessage] = useState<{ type: 'success' | 'error'; text: string; } | null>(null); const successParam = searchParams.get('success'); const canceledParam = searchParams.get('canceled'); const sessionIdParam = searchParams.get('session_id'); const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings'; const [adminTab, setAdminTab] = useState( ['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings' ); useEffect(() => { if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return; let cancelled = false; const _confirmCheckoutIfNeeded = async () => { if (successParam !== 'true') { if (canceledParam === 'true' && !cancelled) { setStripeReturnMessage({ type: 'error', text: 'Zahlung abgebrochen.' }); } return; } if (!sessionIdParam) { if (!cancelled) { setStripeReturnMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wird gutgeschrieben.', }); } if (selectedMandateId) await loadAccounts(selectedMandateId); return; } try { await api.post('/api/billing/checkout/confirm', { sessionId: sessionIdParam }); if (!cancelled) { setStripeReturnMessage({ type: 'success', text: 'Zahlung erfolgreich. Guthaben wurde verbucht.', }); } } catch (err: unknown) { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail; if (!cancelled) { setStripeReturnMessage({ type: 'error', text: detail || 'Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.', }); } } finally { if (selectedMandateId) await loadAccounts(selectedMandateId); } }; _confirmCheckoutIfNeeded(); return () => { cancelled = true; }; }, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); const _clearStripeParams = useCallback(() => { searchParams.delete('success'); searchParams.delete('canceled'); searchParams.delete('session_id'); searchParams.delete('mandate'); setSearchParams(searchParams, { replace: true }); setStripeReturnMessage(null); }, [searchParams, setSearchParams]); const showStripeForMandateAdmin = !!selectedMandateId && !!settings; const _tabStyle = (isActive: boolean) => ({ padding: '8px 16px', textDecoration: 'none', borderRadius: '4px', backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent', color: isActive ? 'white' : 'var(--color-text, #e0e0e0)', fontWeight: isActive ? 600 : 400, cursor: 'pointer', border: 'none', fontSize: '14px', }); return (

Billing-Verwaltung

Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant

{stripeReturnMessage && (
{stripeReturnMessage.text}
)}
{selectedMandateId ? ( <> {adminTab === 'settings' && ( )} {adminTab === 'credit' && ( <> {isSysAdmin && ( )} {showStripeForMandateAdmin && ( )} )} {adminTab === 'subscription' && ( )} {adminTab === 'transactions' && ( )} ) : (
Bitte wählen Sie einen Mandanten aus.
)}
); }; export default BillingAdmin;