diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c6ba8bb..fce59e6 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,6 +81,8 @@ model Company { licenses License[] invoices Invoice[] projects Project[] + contracts Contract[] + expenses Expense[] } model Person { @@ -300,6 +302,7 @@ model Contract { id String @id @default(cuid()) number String @unique // gapless, e.g. VER-2026-0001 companyId String + company Company @relation(fields: [companyId], references: [id]) type String // werkvertrag|dienstvertrag|wartung-sla|avv|nda|dpa title String status String @default("draft") // draft|sent|signed|active|expired|terminated @@ -335,6 +338,7 @@ model Expense { date DateTime @default(now()) // linking companyId String? // customer this is billable to + company Company? @relation(fields: [companyId], references: [id]) projectId String? // project this relates to billable Boolean @default(false) // convert to customer invoice markupPct Int @default(0) // markup when re-billed diff --git a/src/app/api/aos/recurring/route.ts b/src/app/api/aos/recurring/route.ts new file mode 100644 index 0000000..961c025 --- /dev/null +++ b/src/app/api/aos/recurring/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/recurring — list recurring revenue sources + next-run status +export async function GET() { + try { + // Find all active recurring contracts (Wartung/SLA + Dienstvertrag) + const contracts = await db.contract.findMany({ + where: { + status: 'active', + type: { in: ['wartung-sla', 'dienstvertrag'] }, + valueCents: { gt: 0 }, + }, + include: { company: true }, + }) + + // Find all recurring expenses that are billable + const recurringExpenses = await db.expense.findMany({ + where: { recurring: true, billable: true, status: { not: 'invoiced' } }, + include: { company: true }, + }) + + // Find recurring invoices already sent this month + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + const existingInvoices = await db.invoice.findMany({ + where: { type: 'recurring', issueDate: { gte: monthStart } }, + select: { companyId: true, netCents: true }, + }) + + // Compute which contracts need invoicing this month + const toInvoice = contracts.filter((c) => { + // Already invoiced this month for this company + similar amount? + const alreadyInvoiced = existingInvoices.some( + (i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100 + ) + return !alreadyInvoiced + }) + + // Compute MRR from contracts + const contractMrr = contracts.reduce((s, c) => s + Math.round(c.valueCents / 12), 0) + const expenseMrr = recurringExpenses.reduce((s, e) => s + e.amountCents, 0) + + return NextResponse.json({ + contracts: contracts.map((c) => ({ + id: c.id, + number: c.number, + type: c.type, + title: c.title, + company: c.company.name, + companyId: c.companyId, + annualValueCents: c.valueCents, + monthlyValueCents: Math.round(c.valueCents / 12), + startDate: c.startDate, + endDate: c.endDate, + needsInvoice: toInvoice.some((t) => t.id === c.id), + })), + recurringExpenses: recurringExpenses.map((e) => ({ + id: e.id, + number: e.number, + description: e.description, + company: e.company?.name ?? '—', + companyId: e.companyId, + amountCents: e.amountCents, + interval: e.recurringInterval, + })), + stats: { + contractCount: contracts.length, + expenseCount: recurringExpenses.length, + pendingInvoices: toInvoice.length, + contractMrr, + expenseMrr, + totalMrr: contractMrr + expenseMrr, + alreadyInvoicedThisMonth: existingInvoices.length, + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/recurring/run/route.ts b/src/app/api/aos/recurring/run/route.ts new file mode 100644 index 0000000..78dc94e --- /dev/null +++ b/src/app/api/aos/recurring/run/route.ts @@ -0,0 +1,127 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// POST /api/aos/recurring/run +// Generates recurring draft invoices for all eligible contracts this month. +// Also checks dunning level 3 → suspends linked licenses (PRD §6.4: "automatic +// suspend at dunning level 3, reactivate on payment"). +export async function POST() { + try { + const now = new Date() + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + + // 1. Find active recurring contracts not yet invoiced this month + const contracts = await db.contract.findMany({ + where: { + status: 'active', + type: { in: ['wartung-sla', 'dienstvertrag'] }, + valueCents: { gt: 0 }, + }, + include: { company: true }, + }) + + const existingInvoices = await db.invoice.findMany({ + where: { type: 'recurring', issueDate: { gte: monthStart } }, + select: { companyId: true, netCents: true }, + }) + + const toInvoice = contracts.filter((c) => { + return !existingInvoices.some( + (i) => i.companyId === c.companyId && Math.abs(i.netCents - c.valueCents / 12) < 100 + ) + }) + + // 2. Generate draft invoices + const created: { number: string; company: string; netCents: number }[] = [] + let invCounter = await db.invoice.count({ where: { number: { startsWith: `RE-${now.getFullYear()}-` } } }) + + for (const c of toInvoice) { + invCounter++ + const number = `RE-${now.getFullYear()}-${String(invCounter).padStart(4, '0')}` + const netCents = Math.round(c.valueCents / 12) + const taxBp = 1900 + const grossCents = Math.round(netCents * (1 + taxBp / 10000)) + const dueDate = new Date() + dueDate.setDate(dueDate.getDate() + 14) + + const invoice = await db.invoice.create({ + data: { + number, + companyId: c.companyId, + status: 'draft', + type: 'recurring', + issueDate: now, + dueDate, + netCents, + taxBp, + grossCents, + zugferd: false, + xrechnung: false, + datevExported: false, + }, + }) + + await db.invoiceLine.create({ + data: { + invoiceId: invoice.id, + description: `${c.title} — ${now.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} (Wiederkehrend)`, + quantity: 1, + unitCents: netCents, + totalCents: netCents, + }, + }) + + created.push({ number, company: c.company.name, netCents }) + } + + // 3. Dunning level 3 → suspend licenses (PRD §6.4) + const dunningLevel3 = await db.dunningRun.findMany({ + where: { level: 3, status: 'sent' }, + include: { invoice: { include: { company: { include: { licenses: true } } } } }, + }) + + let suspendedCount = 0 + for (const run of dunningLevel3) { + const licenses = run.invoice.company.licenses.filter((l) => l.status === 'active') + for (const lic of licenses) { + await db.license.update({ + where: { id: lic.id }, + data: { status: 'suspended' }, + }) + suspendedCount++ + } + await db.dunningRun.update({ + where: { id: run.id }, + data: { status: 'escalated' }, + }) + } + + // 4. Audit + await db.auditLog.create({ + data: { + actor: 'system:cron', + action: 'recurring.run', + entity: 'Invoice', + entityId: 'batch', + after: JSON.stringify({ + invoicesCreated: created.length, + licensesSuspended: suspendedCount, + month: monthStart.toISOString().slice(0, 7), + }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ + invoicesCreated: created.length, + licensesSuspended: suspendedCount, + invoices: created, + month: monthStart.toISOString().slice(0, 7), + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 3e709cb..efae75d 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -20,6 +20,7 @@ import { LicensesView } from './views/licenses' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' +import { RecurringView } from './views/recurring' import { AccountingView } from './views/accounting' import { AuditView } from './views/audit' import { DataView } from './views/data' @@ -72,6 +73,7 @@ export function AppShell() { {view === 'contracts' && } {view === 'expenses' && } {view === 'finance' && } + {view === 'recurring' && } {view === 'accounting' && } {view === 'audit' && } {view === 'data' && } diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index c7d1efb..c7b2188 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -26,6 +26,7 @@ import { CalendarDays, Clock, Database, + RefreshCw, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -68,6 +69,7 @@ const NAV: { { id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' }, { id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' }, + { id: 'recurring', label: 'Wiederkehrend', icon: RefreshCw, permission: 'finance:read' }, { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, { id: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 78cf8f5..4cbeaf1 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -31,6 +31,7 @@ const TITLES: Record = { contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, + recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' }, accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' }, diff --git a/src/components/aos/views/recurring.tsx b/src/components/aos/views/recurring.tsx new file mode 100644 index 0000000..0747ef8 --- /dev/null +++ b/src/components/aos/views/recurring.tsx @@ -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('/api/aos/recurring') + const [running, setRunning] = useState(false) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + 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 ( +
+ + {running ? : } + Jetzt ausführen + + } + /> + + {/* KPIs */} +
+ +

MRR (Verträge)

+

{formatEuroCompact(stats.contractMrr)}

+
+ +

MRR (Ausgaben)

+

{formatEuroCompact(stats.expenseMrr)}

+
+ +

MRR gesamt

+

{formatEuroCompact(stats.totalMrr)}

+
+ +

ARR (×12)

+

{formatEuroCompact(stats.totalMrr * 12)}

+
+ +

Offen (dieser Monat)

+

{stats.pendingInvoices}

+
+ +

Erstellt (dieser Monat)

+

{stats.alreadyInvoicedThisMonth}

+
+
+ + {/* Dunning → License suspend notice */} +
+ +
+

Automatische Lizenz-Suspendierung bei Mahnstufe 3

+

+ 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. +

+
+ +
+ + {/* Contracts needing invoicing */} + + + Wiederkehrende Verträge ({contracts.length}) + + +
+ + + + + + + + + + + + + {contracts.map((c) => ( + + + + + + + + + ))} + +
VertragKundeTypMonatlichJährlichStatus
+ {c.number} +

{c.title}

+
{c.company}{c.type === 'wartung-sla' ? 'Wartung/SLA' : 'Dienstvertrag'}{formatEuro(c.monthlyValueCents)}{formatEuro(c.annualValueCents)} + {c.needsInvoice ? ( + + Offen + + ) : ( + + Erstellt + + )} +
+
+
+
+ + {/* Recurring billable expenses */} + + + Wiederkehrende belastbare Ausgaben ({recurringExpenses.length}) + + +
+ + + + + + + + + + + + {recurringExpenses.map((e) => ( + + + + + + + + ))} + {recurringExpenses.length === 0 && ( + + )} + +
Nr.BeschreibungKundeIntervallBetrag
{e.number}{e.description}{e.company}{e.interval ?? '—'}{formatEuro(e.amountCents)}
Keine wiederkehrenden belastbaren Ausgaben.
+
+
+
+ + {/* Scheduler info */} + + +
+
+ +
+
+

Automatischer Scheduler (geplant)

+

+ In Produktion läuft dieser Job monatlich am 1. des Monats via pg-boss (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. +

+
+ Cron: 1. des Monats, 06:00 + pg-boss queue + Audit-protokolliert +
+
+
+
+
+
+ ) +} diff --git a/src/store/aos.ts b/src/store/aos.ts index c52a63f..9fdb37a 100755 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -16,6 +16,7 @@ export type ViewId = | 'contracts' | 'expenses' | 'finance' + | 'recurring' | 'accounting' | 'audit' | 'data'