feat: integrated stripe billing

This commit is contained in:
Ida Dittrich 2026-02-25 08:45:12 +01:00
parent 8c28ef791c
commit d82bb4467c
3 changed files with 100 additions and 34 deletions

View file

@ -96,6 +96,15 @@ export interface CreditAddRequest {
description?: string; description?: string;
} }
export interface CheckoutCreateRequest {
userId?: string;
amount: number;
}
export interface CheckoutCreateResponse {
redirectUrl: string;
}
// Type for the request function passed to API functions // Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>; export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
@ -231,6 +240,22 @@ export async function addCreditAdmin(
}); });
} }
/**
* Create Stripe Checkout Session for credit top-up
* Endpoint: POST /api/billing/checkout/create/{mandateId}
*/
export async function createCheckoutSession(
request: ApiRequestFunction,
mandateId: string,
checkoutRequest: CheckoutCreateRequest
): Promise<CheckoutCreateResponse> {
return await request({
url: `/api/billing/checkout/create/${mandateId}`,
method: 'post',
data: checkoutRequest
});
}
/** /**
* Fetch all accounts for a mandate (Admin) * Fetch all accounts for a mandate (Admin)
* Endpoint: GET /api/billing/admin/accounts/{mandateId} * Endpoint: GET /api/billing/admin/accounts/{mandateId}

View file

@ -16,6 +16,7 @@ import {
fetchSettingsAdmin, fetchSettingsAdmin,
updateSettingsAdmin, updateSettingsAdmin,
addCreditAdmin, addCreditAdmin,
createCheckoutSession as createCheckoutSessionApi,
fetchAccountsAdmin, fetchAccountsAdmin,
fetchTransactionsAdmin, fetchTransactionsAdmin,
fetchUsersForMandateAdmin, fetchUsersForMandateAdmin,
@ -26,6 +27,7 @@ import {
type UsageReport, type UsageReport,
type AccountSummary, type AccountSummary,
type CreditAddRequest, type CreditAddRequest,
type CheckoutCreateRequest,
type MandateUserSummary, type MandateUserSummary,
} from '../api/billingApi'; } from '../api/billingApi';
@ -185,7 +187,7 @@ export function useBillingAdmin(mandateId?: string) {
} }
}, [request, mandateId]); }, [request, mandateId]);
// Add credit // Add credit (manual, admin)
const addCredit = useCallback(async ( const addCredit = useCallback(async (
creditRequest: CreditAddRequest, creditRequest: CreditAddRequest,
targetMandateId?: string targetMandateId?: string
@ -204,6 +206,22 @@ export function useBillingAdmin(mandateId?: string) {
} }
}, [request, mandateId]); }, [request, mandateId]);
// Create Stripe Checkout Session (returns redirect URL)
const createCheckout = useCallback(async (
checkoutRequest: CheckoutCreateRequest,
targetMandateId?: string
) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
try {
return await createCheckoutSessionApi(request, mId, checkoutRequest);
} catch (err) {
console.error('Error creating checkout session:', err);
throw err;
}
}, [request, mandateId]);
// Fetch accounts for a mandate // Fetch accounts for a mandate
const loadAccounts = useCallback(async (targetMandateId?: string) => { const loadAccounts = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId; const mId = targetMandateId || mandateId;
@ -272,6 +290,7 @@ export function useBillingAdmin(mandateId?: string) {
loadSettings, loadSettings,
saveSettings, saveSettings,
addCredit, addCredit,
createCheckout,
loadAccounts, loadAccounts,
loadTransactions, loadTransactions,
loadUsers, loadUsers,

View file

@ -8,10 +8,13 @@
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; 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 { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import { useAdminMandates } from '../../hooks/useMandates'; import { useAdminMandates } from '../../hooks/useMandates';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================ // ============================================================================
// MANDATE SELECTOR // MANDATE SELECTOR
// ============================================================================ // ============================================================================
@ -192,13 +195,12 @@ interface CreditAdderProps {
settings: BillingSettings | null; settings: BillingSettings | null;
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[]; users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<void>; onCreateCheckout: (userId: string | undefined, amount: number) => Promise<{ redirectUrl: string }>;
} }
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => { const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onCreateCheckout }) => {
const [selectedUserId, setSelectedUserId] = useState<string>(''); const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<number>(10); const [amount, setAmount] = useState<number>(10);
const [description, setDescription] = useState<string>('Manuelles Aufladen');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@ -223,13 +225,10 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null); setMessage(null);
try { try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, amount, description); const { redirectUrl } = await onCreateCheckout(isPrepayUser ? selectedUserId : undefined, amount);
setMessage({ type: 'success', text: `${amount} CHF erfolgreich gutgeschrieben!` }); window.location.href = redirectUrl;
setAmount(10);
setDescription('Manuelles Aufladen');
} catch (err: any) { } catch (err: any) {
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' }); setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
} finally {
setSaving(false); setSaving(false);
} }
}; };
@ -280,26 +279,18 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<div className={styles.formRow}> <div className={styles.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Betrag (CHF)</label> <label>Betrag (CHF)</label>
<input <select
type="number" className={styles.select}
className={styles.input}
value={amount} value={amount}
onChange={(e) => setAmount(Number(e.target.value))} onChange={(e) => setAmount(Number(e.target.value))}
min="0.01"
step="0.01"
required required
/> >
</div> {STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>
<div className={styles.formGroup}> {preset} CHF
<label>Beschreibung</label> </option>
<input ))}
type="text" </select>
className={styles.input}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Grund für Gutschrift"
/>
</div> </div>
</div> </div>
@ -308,7 +299,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
className={`${styles.button} ${styles.buttonPrimary}`} className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId)} disabled={saving || (isPrepayUser && !selectedUserId)}
> >
{saving ? 'Aufladen...' : 'Guthaben aufladen'} {saving ? 'Weiterleitung zu Stripe...' : 'Mit Stripe aufladen'}
</button> </button>
</form> </form>
</div> </div>
@ -379,8 +370,18 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
// ============================================================================ // ============================================================================
export const BillingAdmin: React.FC = () => { export const BillingAdmin: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null); const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
const { settings, accounts, users, loading, saveSettings, addCredit, loadAccounts } = useBillingAdmin(selectedMandateId || undefined); const { settings, accounts, users, loading, saveSettings, createCheckout, loadAccounts } = useBillingAdmin(selectedMandateId || undefined);
const successParam = searchParams.get('success');
const canceledParam = searchParams.get('canceled');
useEffect(() => {
if (successParam === 'true' && selectedMandateId) {
loadAccounts();
}
}, [successParam, selectedMandateId, loadAccounts]);
const handleMandateSelect = (mandateId: string) => { const handleMandateSelect = (mandateId: string) => {
setSelectedMandateId(mandateId || null); setSelectedMandateId(mandateId || null);
@ -391,12 +392,20 @@ export const BillingAdmin: React.FC = () => {
await saveSettings(settingsUpdate); await saveSettings(settingsUpdate);
}, [selectedMandateId, saveSettings]); }, [selectedMandateId, saveSettings]);
const handleAddCredit = useCallback(async (userId: string | undefined, amount: number, description: string) => { const handleCreateCheckout = useCallback(async (userId: string | undefined, amount: number) => {
if (!selectedMandateId) return; if (!selectedMandateId) throw new Error('Mandant nicht ausgewählt');
await addCredit({ userId, amount, description }); const result = await createCheckout({ userId, amount });
await loadAccounts(); if (!result) throw new Error('Checkout konnte nicht erstellt werden');
}, [selectedMandateId, addCredit, loadAccounts]); return result;
}, [selectedMandateId, createCheckout]);
const clearStripeParams = useCallback(() => {
searchParams.delete('success');
searchParams.delete('canceled');
searchParams.delete('session_id');
setSearchParams(searchParams, { replace: true });
}, [searchParams, setSearchParams]);
return ( return (
<div className={styles.billingDashboard}> <div className={styles.billingDashboard}>
<header className={styles.pageHeader}> <header className={styles.pageHeader}>
@ -404,6 +413,19 @@ export const BillingAdmin: React.FC = () => {
<p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p> <p className={styles.subtitle}>Verwaltung von Abrechnungseinstellungen und Guthaben</p>
</header> </header>
{successParam === 'true' && (
<div className={styles.successMessage} style={{ marginBottom: '1rem' }}>
Zahlung erfolgreich. Guthaben wird gutgeschrieben.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
{canceledParam === 'true' && (
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
Zahlung abgebrochen.
<button type="button" onClick={clearStripeParams} style={{ marginLeft: '1rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}>Schliessen</button>
</div>
)}
<section className={styles.section}> <section className={styles.section}>
<MandateSelector <MandateSelector
selectedMandateId={selectedMandateId} selectedMandateId={selectedMandateId}
@ -423,7 +445,7 @@ export const BillingAdmin: React.FC = () => {
settings={settings} settings={settings}
accounts={accounts} accounts={accounts}
users={users} users={users}
onAddCredit={handleAddCredit} onCreateCheckout={handleCreateCheckout}
/> />
<AccountsOverview <AccountsOverview