ui-nyla/src/pages/billing/BillingAdmin.tsx
ValueOn AG 9b99020686 feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway
- InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF /
  CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck()
- HTTP 402 + JSON detail für globale API-Fehlerbehandlung
- AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails
  (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify
- Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload
- datamodelBilling: notifyEmails-Doku für Pool-Alerts
frontend_nyla
- useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En
  und Hinweis auf Billing-Pfad bei TOP_UP_SELF
2026-03-21 01:34:47 +01:00

672 lines
22 KiB
TypeScript

/**
* 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 { Link, 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 api from '../../api';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Billing.module.css';
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<string, string>;
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<MandateSelectorProps> = ({
mandates,
loading,
selectedMandateId,
onSelect,
}) => (
<div className={styles.formGroup}>
<label>Mandant auswählen</label>
<select
className={styles.select}
value={selectedMandateId || ''}
onChange={e => onSelect(e.target.value)}
disabled={loading}
>
<option value="">-- Mandant wählen --</option>
{mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}>
{_mandateDisplayLabel(mandate)}
</option>
))}
</select>
</div>
);
// ============================================================================
// SETTINGS EDITOR
// ============================================================================
interface SettingsEditorProps {
settings: BillingSettings | null;
onSave: (settings: Partial<BillingSettings>) => Promise<void>;
loading: boolean;
}
const SettingsEditor: React.FC<SettingsEditorProps> = ({ 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 (
<div className={styles.adminSection}>
<h3>Billing-Einstellungen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Abrechnungsmodell</label>
<select
className={styles.select}
value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
</select>
</div>
<div className={styles.formGroup}>
<label>Standard-Guthaben (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.defaultUserCredit}
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Warnschwelle (%)</label>
<input
type="number"
className={styles.input}
value={formData.warningThresholdPercent}
onChange={(e) => setFormData(prev => ({ ...prev, warningThresholdPercent: Number(e.target.value) }))}
min="0"
max="100"
step="1"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>&nbsp;</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.notifyOnWarning}
onChange={(e) => setFormData(prev => ({ ...prev, notifyOnWarning: e.target.checked }))}
/>
Bei Warnung benachrichtigen
</label>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || loading}
>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</form>
</div>
);
};
// ============================================================================
// CREDIT ADDER
// ============================================================================
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('Manuelles Aufladen 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<string, AccountSummary>);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const numAmount = parseFloat(amount);
if (!numAmount || numAmount <= 0) {
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
return;
}
setSaving(true);
setMessage(null);
try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
setAmount('');
} catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false);
}
};
return (
<div className={styles.adminSection}>
<h3>Guthaben manuell aufladen</h3>
{message && (
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
{message.text}
</div>
)}
<form onSubmit={_handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer</label>
<select
className={styles.select}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
required
>
<option value="">-- Benutzer wählen --</option>
{users.map((user) => {
const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
return (
<option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo}
</option>
);
})}
</select>
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="z.B. 50"
min="0.01"
step="0.01"
required
/>
</div>
<div className={styles.formGroup}>
<label>Beschreibung</label>
<input
type="text"
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Beschreibung der Gutschrift"
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
>
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
</button>
</form>
</div>
);
};
// ============================================================================
// ACCOUNTS OVERVIEW
// ============================================================================
interface AccountsOverviewProps {
accounts: AccountSummary[];
users: MandateUserSummary[];
loading: boolean;
}
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ 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<string, string>();
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 <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
}
if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>;
}
return (
<div className={styles.adminSection}>
<h3>Konten</h3>
<div className={styles.accountsGrid}>
{accounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================================================
// 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<MandateStripeTopUpProps> = ({ mandateId, createCheckout }) => {
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const [localMsg, setLocalMsg] = useState<string | null>(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 (
<div className={styles.adminSection}>
<h3>Guthaben via Stripe aufladen</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary, #64748b)', marginTop: 0 }}>
Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.
</p>
{localMsg && <div className={styles.errorMessage}>{localMsg}</div>}
<form onSubmit={_handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="z.B. 50"
min="0.01"
step="0.01"
required
/>
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={busy || !amount}
>
{busy ? 'Weiterleitung...' : 'Mit Stripe bezahlen'}
</button>
</form>
</div>
);
};
// ============================================================================
// MAIN COMPONENT
// ============================================================================
export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { user: currentUser } = useCurrentUser();
const isSysAdmin = currentUser?.isSysAdmin === true;
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
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<BillingSettings>) => {
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');
useEffect(() => {
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;
};
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
const _clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
setStripeReturnMessage(null);
}, [searchParams, setSearchParams]);
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
return (
<div className={styles.billingDashboard}>
<header className={styles.pageHeader}>
<h1>Billing Administration</h1>
<p className={styles.subtitle}>
{isSysAdmin
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
: 'Guthaben und Konten für Ihre Mandanten'}
</p>
{isSysAdmin && (
<p style={{ marginTop: '8px' }}>
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
Mandanten-Übersicht (Balances &amp; Transaktionen)
</Link>
</p>
)}
</header>
{stripeReturnMessage && (
<div
className={
stripeReturnMessage.type === 'success' ? styles.successMessage : styles.errorMessage
}
style={{ marginBottom: '1rem' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'center' }}>
<span>{stripeReturnMessage.text}</span>
<button type="button" className={styles.button} onClick={_clearStripeParams}>
OK
</button>
</div>
</div>
)}
<section className={styles.section}>
<MandateSelector
mandates={mandateList}
loading={mandatesLoading}
selectedMandateId={selectedMandateId}
onSelect={handleMandateSelect}
/>
</section>
{selectedMandateId && (
<>
{isSysAdmin && (
<SettingsEditor
settings={settings}
onSave={handleSaveSettings}
loading={loading}
/>
)}
{isSysAdmin && (
<CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={_handleAddCredit}
/>
)}
{showStripeForMandateAdmin && (
<MandateStripeTopUp mandateId={selectedMandateId} createCheckout={createCheckout} />
)}
<AccountsOverview accounts={accounts} users={users} loading={loading} />
</>
)}
{!selectedMandateId && (
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
)}
</div>
);
};
export default BillingAdmin;