From 439fc3676ffd4904764a82c4bbb275a3c992ab54 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 22 Mar 2026 17:23:47 +0100 Subject: [PATCH] subscription base logic --- src/App.tsx | 3 +- src/api/subscriptionApi.ts | 143 ++++++ src/hooks/useConfirm.tsx | 133 +++++ src/hooks/useSubscription.ts | 161 ++++++ src/pages/admin/PermissionMatrix.tsx | 23 +- src/pages/billing/AdminSubscriptionsPage.tsx | 117 +++++ src/pages/billing/BillingAdmin.tsx | 214 ++++++-- src/pages/billing/BillingDataView.tsx | 1 + src/pages/billing/BillingMandateView.tsx | 22 +- src/pages/billing/SubscriptionTab.tsx | 481 ++++++++++++++++++ src/pages/billing/index.ts | 1 + .../chatbot/ChatbotConversationsView.tsx | 29 +- .../trustee/TrusteeAccountingSettingsView.tsx | 10 +- src/pages/views/workspace/useWorkspace.ts | 13 +- 14 files changed, 1283 insertions(+), 68 deletions(-) create mode 100644 src/api/subscriptionApi.ts create mode 100644 src/hooks/useConfirm.tsx create mode 100644 src/hooks/useSubscription.ts create mode 100644 src/pages/billing/AdminSubscriptionsPage.tsx create mode 100644 src/pages/billing/SubscriptionTab.tsx diff --git a/src/App.tsx b/src/App.tsx index 531ac7f..595379d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,7 +41,7 @@ import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; -import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing'; +import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; function App() { // Load saved theme preference and set app name on app mount useEffect(() => { @@ -188,6 +188,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts new file mode 100644 index 0000000..9fefe9f --- /dev/null +++ b/src/api/subscriptionApi.ts @@ -0,0 +1,143 @@ +import { ApiRequestOptions } from '../hooks/useApi'; + +// ============================================================================ +// TYPES — aligned with State Machine (wiki/concepts/Subscription-State-Machine.md) +// ============================================================================ + +export type SubscriptionStatus = 'PENDING' | 'SCHEDULED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'EXPIRED'; +export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE'; + +export interface SubscriptionPlan { + planKey: string; + selectableByUser: boolean; + title: Record; + description: Record; + currency: string; + billingPeriod: BillingPeriod; + pricePerUserCHF: number; + pricePerFeatureInstanceCHF: number; + autoRenew: boolean; + maxUsers: number | null; + maxFeatureInstances: number | null; + trialDays: number | null; + successorPlanKey: string | null; +} + +export interface MandateSubscription { + id: string; + mandateId: string; + planKey: string; + status: SubscriptionStatus; + recurring: boolean; + startedAt: string; + effectiveFrom: string | null; + endedAt: string | null; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + trialEndsAt: string | null; + snapshotPricePerUserCHF: number; + snapshotPricePerInstanceCHF: number; + stripeSubscriptionId: string | null; +} + +export interface SubscriptionStatusResponse { + active: boolean; + subscription: MandateSubscription | null; + plan: SubscriptionPlan | null; + scheduled: MandateSubscription | null; +} + +export interface ActivatePlanResponse { + redirectUrl?: string; + [key: string]: unknown; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ============================================================================ +// Helpers +// ============================================================================ + +function _mandateConfig(mandateId?: string): Record { + if (!mandateId) return {}; + return { headers: { 'X-Mandate-Id': mandateId } }; +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +export async function fetchSelectablePlans( + request: ApiRequestFunction, + mandateId?: string, +): Promise { + return await request({ + url: '/api/subscription/plans', + method: 'get', + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function fetchSubscriptionStatus( + request: ApiRequestFunction, + mandateId?: string, +): Promise { + return await request({ + url: '/api/subscription/status', + method: 'get', + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function activatePlan( + request: ApiRequestFunction, + planKey: string, + mandateId?: string, + returnUrl?: string, +): Promise { + return await request({ + url: '/api/subscription/activate', + method: 'post', + data: { planKey, returnUrl: returnUrl || '' }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function cancelSubscription( + request: ApiRequestFunction, + subscriptionId: string, + mandateId?: string, +): Promise> { + return await request({ + url: '/api/subscription/cancel', + method: 'post', + data: { subscriptionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function reactivateSubscription( + request: ApiRequestFunction, + subscriptionId: string, + mandateId?: string, +): Promise> { + return await request({ + url: '/api/subscription/reactivate', + method: 'post', + data: { subscriptionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} + +export async function verifyCheckout( + request: ApiRequestFunction, + sessionId: string, + mandateId?: string, +): Promise<{ status: string; message: string }> { + return await request({ + url: '/api/subscription/checkout/verify', + method: 'post', + data: { sessionId }, + additionalConfig: _mandateConfig(mandateId), + }); +} diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx new file mode 100644 index 0000000..bb9fbab --- /dev/null +++ b/src/hooks/useConfirm.tsx @@ -0,0 +1,133 @@ +/** + * useConfirm — application-level confirm dialog replacing native browser confirm(). + * + * Usage: + * const { confirm, ConfirmDialog } = useConfirm(); + * const ok = await confirm('Wirklich löschen?', { confirmLabel: 'Löschen', variant: 'danger' }); + * // Render once in the component tree. + */ + +import React, { useState, useCallback, useRef } from 'react'; + +export interface ConfirmOptions { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'primary' | 'danger'; +} + +interface ConfirmState { + message: string; + options: Required; + resolve: (value: boolean) => void; +} + +const _defaults: Required = { + title: 'Bestätigung', + confirmLabel: 'Bestätigen', + cancelLabel: 'Abbrechen', + variant: 'primary', +}; + +export function useConfirm() { + const [state, setState] = useState(null); + const resolveRef = useRef<((v: boolean) => void) | null>(null); + + const confirm = useCallback((message: string, options?: ConfirmOptions): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setState({ + message, + options: { ..._defaults, ...options }, + resolve, + }); + }); + }, []); + + const _handleConfirm = useCallback(() => { + resolveRef.current?.(true); + resolveRef.current = null; + setState(null); + }, []); + + const _handleCancel = useCallback(() => { + resolveRef.current?.(false); + resolveRef.current = null; + setState(null); + }, []); + + const ConfirmDialog: React.FC = useCallback(() => { + if (!state) return null; + + const { message, options } = state; + const isDanger = options.variant === 'danger'; + + return ( +
+
e.stopPropagation()} + style={{ + background: 'var(--surface-color, #1a1a2e)', + border: '1px solid var(--color-border, #333)', + borderRadius: '12px', + padding: '1.5rem', + minWidth: 340, maxWidth: 480, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'flex', flexDirection: 'column', gap: '1.25rem', + }} + > +

+ {options.title} +

+ +

+ {message} +

+ +
+ + +
+
+
+ ); + }, [state, _handleConfirm, _handleCancel]); + + return { confirm, ConfirmDialog }; +} diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts new file mode 100644 index 0000000..ea98e6c --- /dev/null +++ b/src/hooks/useSubscription.ts @@ -0,0 +1,161 @@ +/** + * useSubscription Hook — state-machine-aligned subscription management. + * + * Exposes the operative subscription, any scheduled successor, available plans, + * and ID-based mutation functions (activate, cancel, reactivate). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useApiRequest } from './useApi'; +import { + fetchSelectablePlans, + fetchSubscriptionStatus, + activatePlan as activatePlanApi, + cancelSubscription as cancelSubscriptionApi, + reactivateSubscription as reactivateSubscriptionApi, + verifyCheckout as verifyCheckoutApi, + type SubscriptionPlan, + type MandateSubscription, + type SubscriptionStatusResponse, +} from '../api/subscriptionApi'; + +export interface UseSubscriptionReturn { + plans: SubscriptionPlan[]; + subscription: MandateSubscription | null; + plan: SubscriptionPlan | null; + scheduled: MandateSubscription | null; + active: boolean; + loading: boolean; + error: string | null; + loadPlans: () => Promise; + loadStatus: () => Promise; + activatePlan: (planKey: string) => Promise; + cancelSubscription: (subscriptionId: string) => Promise; + reactivateSubscription: (subscriptionId: string) => Promise; + verifyCheckout: (sessionId: string) => Promise<{ status: string; message: string }>; + refetch: () => Promise; +} + +export function useSubscription(mandateId?: string): UseSubscriptionReturn { + const [plans, setPlans] = useState([]); + const [subscription, setSubscription] = useState(null); + const [plan, setPlan] = useState(null); + const [scheduled, setScheduled] = useState(null); + const [active, setActive] = useState(false); + const { request, isLoading: loading, error: apiError } = useApiRequest(); + const [error, setError] = useState(null); + + const loadPlans = useCallback(async () => { + try { + const data = await fetchSelectablePlans(request, mandateId); + setPlans(Array.isArray(data) ? data : []); + } catch (err) { + console.error('Error loading plans:', err); + setPlans([]); + } + }, [request, mandateId]); + + const loadStatus = useCallback(async () => { + try { + const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId); + setActive(data.active); + setSubscription(data.subscription ?? null); + setPlan(data.plan ?? null); + setScheduled(data.scheduled ?? null); + } catch (err) { + console.error('Error loading subscription status:', err); + setActive(false); + setSubscription(null); + setPlan(null); + setScheduled(null); + } + }, [request, mandateId]); + + const activatePlan = useCallback(async (planKey: string) => { + try { + setError(null); + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('success'); + currentUrl.searchParams.delete('canceled'); + currentUrl.searchParams.delete('session_id'); + currentUrl.searchParams.set('tab', 'subscription'); + if (mandateId) currentUrl.searchParams.set('mandate', mandateId); + const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; + + const result = await activatePlanApi(request, planKey, mandateId, returnUrl); + if (result?.redirectUrl) { + window.location.href = result.redirectUrl; + return; + } + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const cancelSub = useCallback(async (subscriptionId: string) => { + try { + setError(null); + await cancelSubscriptionApi(request, subscriptionId, mandateId); + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Kündigen'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const reactivateSub = useCallback(async (subscriptionId: string) => { + try { + setError(null); + await reactivateSubscriptionApi(request, subscriptionId, mandateId); + await loadStatus(); + } catch (err: any) { + const msg = err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren'; + setError(msg); + throw err; + } + }, [request, mandateId, loadStatus]); + + const verifyCheckout = useCallback(async (sessionId: string) => { + const result = await verifyCheckoutApi(request, sessionId, mandateId); + await loadStatus(); + return result; + }, [request, mandateId, loadStatus]); + + const refetch = useCallback(async () => { + await Promise.all([loadPlans(), loadStatus()]); + }, [loadPlans, loadStatus]); + + useEffect(() => { + if (mandateId) { + loadPlans(); + loadStatus(); + } else { + setPlans([]); + setSubscription(null); + setPlan(null); + setScheduled(null); + setActive(false); + } + }, [mandateId]); + + return { + plans, + subscription, + plan, + scheduled, + active, + loading, + error: error || (apiError ? String(apiError) : null), + loadPlans, + loadStatus, + activatePlan, + cancelSubscription: cancelSub, + reactivateSubscription: reactivateSub, + verifyCheckout, + refetch, + }; +} diff --git a/src/pages/admin/PermissionMatrix.tsx b/src/pages/admin/PermissionMatrix.tsx index 7738610..d7a9b1b 100644 --- a/src/pages/admin/PermissionMatrix.tsx +++ b/src/pages/admin/PermissionMatrix.tsx @@ -4,8 +4,9 @@ * User × Role matrix with inline toggles and edit/remove actions. */ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { FaEdit, FaTrash } from 'react-icons/fa'; +import { useConfirm } from '../../hooks/useConfirm'; import type { FeatureAccessUser } from '../../hooks/useFeatureAccess'; import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess'; import styles from './Admin.module.css'; @@ -29,15 +30,20 @@ export const PermissionMatrix: React.FC = ({ disabled = false, }) => { const [removingId, setRemovingId] = useState(null); + const { confirm, ConfirmDialog } = useConfirm(); - const handleRemove = (user: FeatureAccessUser) => { + const handleRemove = useCallback(async (user: FeatureAccessUser) => { if (removingId) return; - if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) { - setRemovingId(user.userId); - onRemoveUser(user); - setRemovingId(null); - } - }; + const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, { + title: 'Benutzer entfernen', + confirmLabel: 'Entfernen', + variant: 'danger', + }); + if (!ok) return; + setRemovingId(user.userId); + onRemoveUser(user); + setRemovingId(null); + }, [removingId, confirm, onRemoveUser]); if (roles.length === 0) { return ( @@ -135,6 +141,7 @@ export const PermissionMatrix: React.FC = ({ + Benutzer hinzufügen + ); }; diff --git a/src/pages/billing/AdminSubscriptionsPage.tsx b/src/pages/billing/AdminSubscriptionsPage.tsx new file mode 100644 index 0000000..36450d0 --- /dev/null +++ b/src/pages/billing/AdminSubscriptionsPage.tsx @@ -0,0 +1,117 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; +import { useApiRequest } from '../../hooks/useApi'; +import { useConfirm } from '../../hooks/useConfirm'; +import api from '../../api'; +import styles from './Billing.module.css'; + +const _TERMINAL_STATUSES = new Set(['EXPIRED']); + +const _STATUS_LABELS: Record = { + PENDING: 'Ausstehend', + SCHEDULED: 'Geplant', + TRIALING: 'Testphase', + ACTIVE: 'Aktiv', + PAST_DUE: 'Überfällig', + EXPIRED: 'Abgelaufen', +}; + +const _COLUMNS: ColumnConfig[] = [ + { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, width: 180 }, + { key: 'planTitle', label: 'Plan', type: 'text' as any, sortable: true, filterable: true, width: 180 }, + { key: 'status', label: 'Status', type: 'text' as any, sortable: true, filterable: true, width: 110 }, + { key: 'recurring', label: 'Wiederkehrend', type: 'boolean' as any, sortable: true, filterable: true, width: 120 }, + { key: 'activeUsers', label: 'User', type: 'number' as any, sortable: true, width: 70 }, + { key: 'activeInstances', label: 'Instanzen', type: 'number' as any, sortable: true, width: 90 }, + { key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number' as any, sortable: true, width: 140 }, + { key: 'startedAt', label: 'Gestartet', type: 'date' as any, sortable: true, width: 130 }, + { key: 'currentPeriodEnd', label: 'Periodenende', type: 'date' as any, sortable: true, width: 130 }, + { key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number' as any, sortable: true, width: 100 }, + { key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number' as any, sortable: true, width: 110 }, +]; + +const AdminSubscriptionsPage: React.FC = () => { + const navigate = useNavigate(); + const { request } = useApiRequest(); + const { confirm, ConfirmDialog } = useConfirm(); + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadSubscriptions = useCallback(async () => { + setLoading(true); + try { + const data = await request({ url: '/api/subscription/admin/all', method: 'get' }); + const rows = (Array.isArray(data) ? data : []).map((row: any) => ({ + ...row, + status: _STATUS_LABELS[row.status] || row.status, + _rawStatus: row.status, + })); + setSubscriptions(rows); + } catch (err) { + console.error('Failed to load subscriptions:', err); + setSubscriptions([]); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _loadSubscriptions(); }, [_loadSubscriptions]); + + const _handleForceCancel = useCallback(async (row: any) => { + const ok = await confirm( + `Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`, + { confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' }, + ); + if (!ok) return; + + try { + await api.post('/api/subscription/force-cancel', { subscriptionId: row.id }); + await _loadSubscriptions(); + } catch (err) { + console.error('Force cancel failed:', err); + } + }, [confirm, _loadSubscriptions]); + + return ( +
+
+

Subscription-Übersicht

+

Alle Abonnements aller Mandanten

+ +
+ + {loading ? ( +
Lade Subscriptions…
+ ) : ( + _handleForceCancel(row), + isVisible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus), + }, + ]} + emptyMessage="Keine Subscriptions vorhanden." + /> + )} + + +
+ ); +}; + +export default AdminSubscriptionsPage; diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index ce2b743..af26318 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -8,15 +8,20 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { useSearchParams, Link } 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', @@ -206,7 +211,7 @@ interface CreditAdderProps { const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { const [selectedUserId, setSelectedUserId] = useState(''); const [amount, setAmount] = useState(''); - const [description, setDescription] = useState('Manuelles Aufladen durch Admin'); + const [description, setDescription] = useState('Manuelle Buchung durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -222,8 +227,8 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const numAmount = parseFloat(amount); - if (!numAmount || numAmount <= 0) { - setMessage({ type: 'error', text: 'Betrag muss positiv sein' }); + if (!numAmount || numAmount === 0) { + setMessage({ type: 'error', text: 'Betrag darf nicht null sein' }); return; } @@ -232,10 +237,13 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on try { await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); - setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` }); + 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 beim Aufladen' }); + setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' }); } finally { setSaving(false); } @@ -243,7 +251,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on return (
-

Guthaben manuell aufladen

+

Guthaben manuell verwalten

{message && (
@@ -285,8 +293,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on className={styles.input} value={amount} onChange={(e) => setAmount(e.target.value)} - placeholder="z.B. 50" - min="0.01" + placeholder="z.B. 50 oder -20" step="0.01" required /> @@ -308,7 +315,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on className={`${styles.button} ${styles.buttonPrimary}`} disabled={saving || (isPrepayUser && !selectedUserId) || !amount} > - {saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'} + {saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
@@ -456,6 +463,78 @@ const MandateStripeTopUp: React.FC = ({ mandateId, crea ); }; +// ============================================================================ +// 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 [error, setError] = useState(null); + + const _loadTransactions = useCallback(async () => { + try { + setError(null); + const data = await request({ + url: `/api/billing/admin/transactions/${mandateId}`, + method: 'get', + params: { limit: 500 }, + }); + setTransactions(Array.isArray(data) ? data : []); + } catch (err: any) { + setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden'); + setTransactions([]); + } + }, [request, mandateId]); + + useEffect(() => { + _loadTransactions(); + }, [_loadTransactions]); + + const hookData = useMemo(() => ({ + refetch: _loadTransactions, + }), [_loadTransactions]); + + return ( +
+

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

+ {error &&
{error}
} + +
+ ); +}; + // ============================================================================ // MAIN COMPONENT // ============================================================================ @@ -465,7 +544,9 @@ export const BillingAdmin: React.FC = () => { const { user: currentUser } = useCurrentUser(); const isSysAdmin = currentUser?.isSysAdmin === true; - const [selectedMandateId, setSelectedMandateId] = useState(null); + const [selectedMandateId, setSelectedMandateId] = useState( + searchParams.get('mandate') || null + ); const [mandateList, setMandateList] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); @@ -530,7 +611,14 @@ export const BillingAdmin: React.FC = () => { 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 () => { @@ -580,34 +668,51 @@ export const BillingAdmin: React.FC = () => { return () => { cancelled = true; }; - }, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]); + }, [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 = !isSysAdmin && !!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 Administration

-

- {isSysAdmin - ? 'Verwaltung von Abrechnungseinstellungen und Guthaben' - : 'Guthaben und Konten für Ihre Mandanten'} -

- {isSysAdmin && ( -

- - Mandanten-Übersicht (Balances & Transaktionen) +

+
+

Billing-Verwaltung

+

+ Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant +

+
+ {isSysAdmin && ( + + Alle Abonnements → -

- )} + )} +
{stripeReturnMessage && ( @@ -635,9 +740,30 @@ export const BillingAdmin: React.FC = () => { /> - {selectedMandateId && ( + {selectedMandateId ? ( <> - {isSysAdmin && ( + + + {adminTab === 'settings' && ( { /> )} - {isSysAdmin && ( - + {adminTab === 'credit' && ( + <> + {isSysAdmin && ( + + )} + + {showStripeForMandateAdmin && ( + + )} + + + )} - {showStripeForMandateAdmin && ( - + {adminTab === 'subscription' && ( + )} - + {adminTab === 'transactions' && ( + + )} - )} - - {!selectedMandateId && ( + ) : (
Bitte wählen Sie einen Mandanten aus.
)}
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 0a1dfef..5c313ff 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -741,6 +741,7 @@ export const BillingDataView: React.FC = () => { />
)} + ); }; diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index cc84f16..3279505 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -175,7 +175,11 @@ const TransactionTable: React.FC = ({ transactions }) => // MAIN COMPONENT // ============================================================================ -export const BillingMandateView: React.FC = () => { +interface BillingMandateViewProps { + embedded?: boolean; +} + +export const BillingMandateView: React.FC = ({ embedded = false }) => { const { request, isLoading: loading } = useApiRequest(); const [balances, setBalances] = useState([]); const [transactions, setTransactions] = useState([]); @@ -212,13 +216,17 @@ export const BillingMandateView: React.FC = () => { }; return ( -
-
-

Mandanten-Billing

-

Guthaben und Transaktionen pro Mandant

-
+
+ {!embedded && ( + <> +
+

Mandanten-Billing

+

Guthaben und Transaktionen pro Mandant

+
- + + + )} {/* Mandate Balances */}
diff --git a/src/pages/billing/SubscriptionTab.tsx b/src/pages/billing/SubscriptionTab.tsx new file mode 100644 index 0000000..c06d749 --- /dev/null +++ b/src/pages/billing/SubscriptionTab.tsx @@ -0,0 +1,481 @@ +/** + * 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 styles from './Billing.module.css'; + +// ============================================================================ +// Helpers +// ============================================================================ + +const _lang = (): string => + typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en'; + +const _t = (dict: Record | 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 = { + 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 = { + MONTHLY: 'Monatlich', + YEARLY: 'Jährlich', + NONE: '—', +}; + +// ============================================================================ +// Plan Card +// ============================================================================ + +interface PlanCardProps { + plan: SubscriptionPlan; + isCurrent: boolean; + onActivate: (planKey: string) => void; + activatingPlanKey: string | null; +} + +const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activatingPlanKey }) => { + const activating = activatingPlanKey === plan.planKey; + const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0; + + return ( +
+
+ {_t(plan.title)} + {isCurrent && ( + Aktuell + )} +
+ +

+ {_t(plan.description)} +

+ + {!isFreePlan && ( +
+
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
+ )} + + {isFreePlan && plan.trialDays && ( +
+ {plan.trialDays} Tage kostenlos + {plan.maxUsers && <> · max. {plan.maxUsers} User} + {plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen} +
+ )} + + {!isCurrent && ( + + )} +
+ ); +}; + +// ============================================================================ +// 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 = ({ 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 ( +
+
+ {label} +
+
+ {plan ? _t(plan.title) : sub.planKey} +
+ {isActive && !sub.recurring && ( + Gekündigt + )} + {statusInfo.label} +
+
+ + {isPending && ( +
+ {justPaid + ? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...' + : 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'} +
+ )} + + {isScheduled && sub.effectiveFrom && ( +
+ Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft. +
+ )} + + {!isPending && !isScheduled && ( +
+ Gestartet: {_formatDate(sub.startedAt)} + {plan && Periode: {_periodLabel[plan.billingPeriod] || '—'}} + {sub.currentPeriodEnd && Periodenende: {_formatDate(sub.currentPeriodEnd)}} + {sub.trialEndsAt && Trial endet: {_formatDate(sub.trialEndsAt)}} + {isActive && !sub.recurring && sub.currentPeriodEnd && ( + Läuft aus am: {_formatDate(sub.currentPeriodEnd)} + )} +
+ )} + +
+ {isActive && !sub.recurring && onReactivate && ( + + )} + + {isActive && sub.recurring && onCancel && ( + + )} + + {(isPending || isScheduled) && onCancel && ( + + )} +
+
+ ); +}; + +// ============================================================================ +// Subscription Tab +// ============================================================================ + +interface SubscriptionTabProps { + mandateId: string; +} + +export const SubscriptionTab: React.FC = ({ mandateId }) => { + const { + plans, + subscription, + plan: currentPlan, + scheduled, + 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: '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; + verifyCheckout(sessionId) + .then((result) => { + if (result.status === 'activated') { + setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); + setJustPaid(false); + } + }) + .catch(() => {}); + } + } 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' : undefined, + 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
Lade Abonnement-Daten...
; + } + + return ( +
+ {/* Checkout feedback */} + {checkoutMessage && ( +
+ {checkoutMessage.text} +
+ )} + + {/* Error display */} + {(error || actionError) && ( +
+ {actionError || error} +
+ )} + + {/* Current subscription */} +
+

Aktuelles Abonnement

+ {subscription ? ( + + ) : ( +
+ Kein aktives Abonnement. Wählen Sie unten einen Plan. +
+ )} +
+ + {/* Scheduled successor */} + {scheduled && ( +
+

Geplanter Nachfolger

+ +
+ )} + + {/* Available plans */} +
+

Verfügbare Pläne

+ {plans.length === 0 ? ( +
Keine Pläne verfügbar
+ ) : ( +
+ {plans.map((p) => ( + + ))} +
+ )} +
+ + +
+ ); +}; diff --git a/src/pages/billing/index.ts b/src/pages/billing/index.ts index 86d7563..a399597 100644 --- a/src/pages/billing/index.ts +++ b/src/pages/billing/index.ts @@ -11,3 +11,4 @@ export { BillingNav } from './BillingNav'; export { BillingTransactions } from './BillingTransactions'; export { BillingMandateView } from './BillingMandateView'; export { BillingUserView } from './BillingUserView'; +export { default as AdminSubscriptionsPage } from './AdminSubscriptionsPage'; diff --git a/src/pages/views/chatbot/ChatbotConversationsView.tsx b/src/pages/views/chatbot/ChatbotConversationsView.tsx index cfbfa32..db0d660 100644 --- a/src/pages/views/chatbot/ChatbotConversationsView.tsx +++ b/src/pages/views/chatbot/ChatbotConversationsView.tsx @@ -5,8 +5,9 @@ * Similar to trustee views but hardcoded for chatbot feature. */ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { useChatbot } from '../../../hooks/useChatbot'; +import { useConfirm } from '../../../hooks/useConfirm'; import { TextField } from '../../../components/UiComponents/TextField'; import { Button } from '../../../components/UiComponents/Button'; import { AutoScroll } from '../../../components/UiComponents/AutoScroll'; @@ -40,7 +41,8 @@ export const ChatbotConversationsView: React.FC = () => { } = useChatbot(); const [deletingId, setDeletingId] = useState(null); - + const { confirm, ConfirmDialog } = useConfirm(); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!inputValue.trim() || isStreaming) return; @@ -76,17 +78,21 @@ export const ChatbotConversationsView: React.FC = () => { } }; - const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => { + const handleDeleteThread = useCallback(async (e: React.MouseEvent, workflowId: string) => { e.stopPropagation(); - if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) { - setDeletingId(workflowId); - try { - await deleteThread(workflowId); - } finally { - setDeletingId(null); - } + const ok = await confirm('Möchten Sie diese Konversation wirklich löschen?', { + title: 'Konversation löschen', + confirmLabel: 'Löschen', + variant: 'danger', + }); + if (!ok) return; + setDeletingId(workflowId); + try { + await deleteThread(workflowId); + } finally { + setDeletingId(null); } - }; + }, [confirm, deleteThread]); const formatDate = (timestamp?: number) => { if (!timestamp) return ''; @@ -269,6 +275,7 @@ export const ChatbotConversationsView: React.FC = () => { )} +
); }; diff --git a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx index b145a0d..32debd7 100644 --- a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx +++ b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { useToast } from '../../../contexts/ToastContext'; +import { useConfirm } from '../../../hooks/useConfirm'; import { fetchAccountingConnectors, fetchAccountingConfig, @@ -42,6 +43,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => { const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const mountedRef = useRef(true); + const { confirm, ConfirmDialog } = useConfirm(); useEffect(() => { if (!importDone) return; @@ -145,7 +147,12 @@ export const TrusteeAccountingSettingsView: React.FC = () => { const handleRemove = async () => { if (!instanceId) return; - if (!window.confirm('Remove the accounting integration? This does not delete synced data.')) return; + const ok = await confirm('Remove the accounting integration? This does not delete synced data.', { + title: 'Remove Integration', + confirmLabel: 'Remove', + variant: 'danger', + }); + if (!ok) return; setSaving(true); try { await deleteAccountingConfig(request, instanceId); @@ -421,6 +428,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)} + ); }; diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index d8ff7c4..7da9299 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -358,7 +358,18 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { setIsProcessing(false); const item = event.item as Record | undefined; let msg = event.content || 'Unknown error'; - if (item && item.error === 'INSUFFICIENT_BALANCE') { + const subscriptionErrors = new Set([ + 'SUBSCRIPTION_INACTIVE', + 'SUBSCRIPTION_PAYMENT_REQUIRED', + 'SUBSCRIPTION_PAYMENT_PENDING', + 'SUBSCRIPTION_EXPIRED', + ]); + if (item && typeof item.error === 'string' && subscriptionErrors.has(item.error)) { + msg = typeof item.message === 'string' ? item.message : msg; + if (typeof item.subscriptionUiPath === 'string') { + msg += `\n\n→ ${item.subscriptionUiPath}`; + } + } else if (item && item.error === 'INSUFFICIENT_BALANCE') { const preferDe = typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de'); const de = typeof item.messageDe === 'string' ? item.messageDe : '';