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;
}
export interface CheckoutCreateRequest {
userId?: string;
amount: number;
}
export interface CheckoutCreateResponse {
redirectUrl: string;
}
// Type for the request function passed to API functions
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)
* Endpoint: GET /api/billing/admin/accounts/{mandateId}

View file

@ -16,6 +16,7 @@ import {
fetchSettingsAdmin,
updateSettingsAdmin,
addCreditAdmin,
createCheckoutSession as createCheckoutSessionApi,
fetchAccountsAdmin,
fetchTransactionsAdmin,
fetchUsersForMandateAdmin,
@ -26,6 +27,7 @@ import {
type UsageReport,
type AccountSummary,
type CreditAddRequest,
type CheckoutCreateRequest,
type MandateUserSummary,
} from '../api/billingApi';
@ -185,7 +187,7 @@ export function useBillingAdmin(mandateId?: string) {
}
}, [request, mandateId]);
// Add credit
// Add credit (manual, admin)
const addCredit = useCallback(async (
creditRequest: CreditAddRequest,
targetMandateId?: string
@ -204,6 +206,22 @@ export function useBillingAdmin(mandateId?: string) {
}
}, [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
const loadAccounts = useCallback(async (targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
@ -272,6 +290,7 @@ export function useBillingAdmin(mandateId?: string) {
loadSettings,
saveSettings,
addCredit,
createCheckout,
loadAccounts,
loadTransactions,
loadUsers,

View file

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