feat: Recurring invoices + license suspend on dunning level 3 (PRD §6.4/§6.6)

- Recurring API: list contracts needing invoicing + run endpoint
- Auto-generate draft invoices for Wartung/SLA + Dienstvertrag contracts
- Automatic license suspension at dunning level 3 (PRD §6.4)
- Recurring billable expenses tracking
- MRR/ARR computation from contracts + expenses
- Prisma: added Contract.company + Expense.company relations
- Scheduler info (pg-boss monthly cron in production)
This commit is contained in:
Z User committed 2026-10-27 16:51:58 +00:00
1 parent c8b4be55f3
commit 597bfbc3d8
8 files changed
+453

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
'use client'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useState } from 'react'
import { RefreshCw, Loader2, Play, AlertTriangle, CheckCircle2, Clock, TrendingUp, KeyRound } from 'lucide-react'
interface RecurringContract {
id: string
number: string
type: string
title: string
company: string
companyId: string
annualValueCents: number
monthlyValueCents: number
startDate: string | null
endDate: string | null
needsInvoice: boolean
}
interface RecurringData {
contracts: RecurringContract[]
recurringExpenses: { id: string; number: string; description: string; company: string; companyId: string | null; amountCents: number; interval: string | null }[]
stats: {
contractCount: number
expenseCount: number
pendingInvoices: number
contractMrr: number
expenseMrr: number
totalMrr: number
alreadyInvoicedThisMonth: number
}
}
export function RecurringView() {
const { data, loading, refetch } = useFetch<RecurringData>('/api/aos/recurring')
const [running, setRunning] = useState(false)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-64" />
</div>
)
}
const { contracts, recurringExpenses, stats } = data
const runNow = async () => {
setRunning(true)
const t = toast.loading('Wiederkehrende Rechnungen werden erstellt…')
try {
const r = await fetch('/api/aos/recurring/run', { method: 'POST' })
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'run failed')
toast.success(`${res.invoicesCreated} Rechnung(en) erstellt${res.licensesSuspended > 0 ? `, ${res.licensesSuspended} Lizenz(en) suspendiert (Mahnstufe 3)` : ''}`, { id: t, duration: 6000 })
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Ausführung fehlgeschlagen', { id: t })
} finally {
setRunning(false)
}
}
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Wiederkehrende Rechnungen"
description="Automatische Generierung von wiederkehrenden Rechnungen (Wartung/SLA-Verträge). Bei Mahnstufe 3: automatische Lizenz-Suspendierung (PRD §6.4)."
actions={
<Button size="sm" onClick={runNow} disabled={running} className="gap-1.5">
{running ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
Jetzt ausführen
</Button>
}
/>
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-6">
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR (Verträge)</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.contractMrr)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR (Ausgaben)</p><RefreshCw className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.expenseMrr)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">MRR gesamt</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalMrr)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">ARR (×12)</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(stats.totalMrr * 12)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Offen (dieser Monat)</p><Clock className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.pendingInvoices}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Erstellt (dieser Monat)</p><CheckCircle2 className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.alreadyInvoicedThisMonth}</p>
</Card>
</div>
{/* Dunning → License suspend notice */}
<div className="flex items-start gap-2 rounded-md border border-foreground/30 bg-foreground/5 p-3 text-xs">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-foreground" />
<div>
<p className="font-medium">Automatische Lizenz-Suspendierung bei Mahnstufe 3</p>
<p className="mt-0.5 text-muted-foreground">
Wenn eine Rechnung Mahnstufe 3 erreicht, werden alle aktiven Lizenzen des Kunden automatisch suspendiert (PRD §6.4: „automatic suspend at dunning level 3, reactivate on payment"). Bei Zahlungseingang werden sie reaktiviert.
</p>
</div>
<KeyRound className="ml-2 size-4 shrink-0 text-muted-foreground" />
</div>
{/* Contracts needing invoicing */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Wiederkehrende Verträge ({contracts.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Vertrag</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium text-right">Monatlich</th>
<th className="px-4 py-2 font-medium text-right">Jährlich</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{contracts.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<code className="font-mono text-[11px]">{c.number}</code>
<p className="truncate text-[10px] text-muted-foreground">{c.title}</p>
</td>
<td className="px-4 py-2.5 text-xs">{c.company}</td>
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px]">{c.type === 'wartung-sla' ? 'Wartung/SLA' : 'Dienstvertrag'}</Badge></td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(c.monthlyValueCents)}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums text-muted-foreground">{formatEuro(c.annualValueCents)}</td>
<td className="px-4 py-2.5">
{c.needsInvoice ? (
<Badge variant="outline" className="gap-1 text-[10px] border-foreground/40">
<Clock className="size-2.5" />Offen
</Badge>
) : (
<Badge variant="outline" className="gap-1 text-[10px]">
<CheckCircle2 className="size-2.5" />Erstellt
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Recurring billable expenses */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Wiederkehrende belastbare Ausgaben ({recurringExpenses.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Beschreibung</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Intervall</th>
<th className="px-4 py-2 font-medium text-right">Betrag</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{recurringExpenses.map((e) => (
<tr key={e.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
<td className="px-4 py-2.5 text-xs">{e.description}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.company}</td>
<td className="px-4 py-2.5"><Badge variant="outline" className="text-[10px]">{e.interval ?? '—'}</Badge></td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(e.amountCents)}</td>
</tr>
))}
{recurringExpenses.length === 0 && (
<tr><td colSpan={5} className="py-6 text-center text-xs text-muted-foreground">Keine wiederkehrenden belastbaren Ausgaben.</td></tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Scheduler info */}
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<RefreshCw className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Automatischer Scheduler (geplant)</p>
<p className="mt-1 text-xs text-muted-foreground">
In Produktion läuft dieser Job monatlich am 1. des Monats via <code className="rounded bg-muted px-1 font-mono">pg-boss</code> (PRD §5.5: Background jobs). Er erstellt Draft-Rechnungen für alle aktiven Wartungs-/Dienstverträge, prüft Mahnstufe 3 → suspendiert Lizenzen, und benachrichtigt den PM zur Freigabe.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px]">Cron: 1. des Monats, 06:00</Badge>
<Badge variant="outline" className="text-[10px]">pg-boss queue</Badge>
<Badge variant="outline" className="text-[10px]">Audit-protokolliert</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}