feat: integrated stripe billing
This commit is contained in:
parent
8c28ef791c
commit
d82bb4467c
3 changed files with 100 additions and 34 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue