diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fce59e6..0df510b 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -568,6 +568,106 @@ model Article { updatedAt DateTime @updatedAt } +// --------------------------------------------------------------------------- +// Haben-cloned features: Belege, Pauschalen, Anlagen/AfA, Kasse +// --------------------------------------------------------------------------- +// Belege (receipts/documents) — content-addressed storage (PRD §5.5) +model Beleg { + id String @id @default(cuid()) + number String @unique // gapless, e.g. BEL-2026-0001 + type String // rechnung-eingang|rechnung-ausgang|quittung|kassenbeleg|bankbeleg|sonstiges + // content-addressed (SHA-256 hash of the file content) + fileHash String @unique + fileName String + fileType String // application/pdf, image/jpeg, etc. + fileSizeBytes Int + // metadata extracted from the document + vendor String? // Rechnungssteller + invoiceNumber String? // Rechnungsnummer des Lieferanten + amountCents Int? // Bruttobetrag auf dem Beleg + amountNetCents Int? // Netto (falls extrahiert) + taxBp Int? // USt % + bookingDate DateTime? + // linking + expenseId String? // link to Expense if billable + invoiceId String? // link to Invoice if outgoing + // OCR / AI + ocrText String? // extracted text (optional, via Jina/AI) + // GoBD + archivedAt DateTime? + retentionUntil DateTime? // Aufbewahrung bis + createdAt DateTime @default(now()) +} +// Pauschalen (flat-rate allowances) — Homeoffice, Kilometer, Verpflegung +model Pauschale { + id String @id @default(cuid()) + type String // homeoffice|kilometer|verpflegung + // homeoffice: €4.17/day (2026: €6.92/day up to €1.260/year) + // kilometer: €0.30/km (car), €0.38/km (car w/ pass), or actual + // verpflegung: €14 (8-14h), €24 (>14h, same day), €28 (overnight) + date DateTime + userId String? // employee + amountCents Int // computed amount + // details per type + days Int? // homeoffice: 1; verpflegung: 1 + km Int? // kilometer: distance + vehicleType String? // kilometer: auto|rad|fuss + tripPurpose String? // kilometer: geschäftlich|fortbildung|umzug + hoursAway Int? // verpflegung: hours away from office + overnight Boolean @default(false) // verpflegung: overnight stay + description String? + projectId String? // link to project if billable + expenseId String? // link to generated expense + createdAt DateTime @default(now()) +} + +// Anlagenverzeichnis & AfA (asset register + depreciation) +model Anlage { + id String @id @default(cuid()) + number String @unique // e.g. ANL-2026-0001 + name String // "MacBook Pro 16" + category String // hardware|software|mobiliar|fahrzeug|immobilie + // acquisition + purchaseDate DateTime + purchaseCostCents Int // Anschaffungskosten (netto) + taxBp Int @default(1900) // USt bei Anschaffung + // AfA (depreciation) + afaMethod String @default("linear") // linear|degressiv + usefulLifeYears Int @default(3) // Nutzungsdauer (AfA-Tabelle) + salvageValueCents Int @default(0) // Restwert + // GWG (Geringwertiger Wirtschaftsgüter ≤ €800 net → Sofortabschreibung) + isGWG Boolean @default(false) // GWG ≤ €800 → immediate expense + // Sammelposten (€251-€1000 → Sammelabschreibung über 5 Jahre) + isSammelposten Boolean @default(false) + // computed + annualAfaCents Int @default(0) // jährliche AfA + accumulatedAfaCents Int @default(0) // kumulierte AfA + bookValueCents Int @default(0) // Buchwert (purchase - accumulated AfA) + // status + status String @default("active") // active|sold|scrapped|fully-depreciated + soldDate DateTime? + soldPriceCents Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Kassenbuch (cash book for Barzahlungen — § 146 AO, § 22 UStG) +model KassenbuchEntry { + id String @id @default(cuid()) + number String @unique // gapless, e.g. KAS-2026-0001 + date DateTime + type String // einnahme|ausgabe + description String + amountCents Int // signed: + for einnahme, - for ausgabe + taxBp Int? // USt % (if applicable) + taxFree Boolean @default(false) // steuerfrei + // receipt link + belegId String? // link to Beleg (Kassenbeleg) + // balance after this entry (laufender Kassenstand) + balanceCents Int @default(0) + // GoBD: Kassenbuch ist unveränderlich (elektronische Aufzeichnung) + createdAt DateTime @default(now()) +} diff --git a/src/app/api/aos/anlagen/route.ts b/src/app/api/aos/anlagen/route.ts new file mode 100644 index 0000000..3f3b849 --- /dev/null +++ b/src/app/api/aos/anlagen/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/anlagen — list asset register with AfA +export async function GET() { + try { + const anlagen = await db.anlage.findMany({ orderBy: { purchaseDate: 'desc' } }) + const stats = { + total: anlagen.length, + totalPurchaseCents: anlagen.reduce((s, a) => s + a.purchaseCostCents, 0), + totalBookValueCents: anlagen.reduce((s, a) => s + a.bookValueCents, 0), + totalAnnualAfaCents: anlagen.reduce((s, a) => s + a.annualAfaCents, 0), + gwgCount: anlagen.filter((a) => a.isGWG).length, + sammelpostenCount: anlagen.filter((a) => a.isSammelposten).length, + activeCount: anlagen.filter((a) => a.status === 'active').length, + } + return NextResponse.json({ anlagen, stats }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// POST /api/aos/anlagen — create an asset with AfA computation +export async function POST(req: Request) { + try { + const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = (await req.json()) as { + name: string; category: string; purchaseDate: string; purchaseCostCents: number + usefulLifeYears?: number; afaMethod?: string + } + + if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 }) + if (!purchaseCostCents || purchaseCostCents <= 0) return NextResponse.json({ error: 'purchaseCostCents must be > 0' }, { status: 400 }) + + const cost = Math.round(purchaseCostCents) + const years = usefulLifeYears ?? 3 + + // GWG (Geringwertiger Wirtschaftsgüter): ≤ €800 net → Sofortabschreibung (§ 6 Abs. 2 EStG) + const isGWG = cost <= 80000 // €800.00 in cents + // Sammelposten: €251-€1000 net → Sammelabschreibung über 5 Jahre (§ 6 Abs. 2a EStG) + const isSammelposten = !isGWG && cost > 80000 && cost <= 100000 + + // Compute AfA + let annualAfaCents = 0 + let bookValueCents = cost + + if (isGWG) { + // Sofortabschreibung: full cost in year 1 + annualAfaCents = cost + bookValueCents = 0 + } else if (isSammelposten) { + // Sammelabschreibung: 1/5 per year over 5 years + annualAfaCents = Math.round(cost / 5) + bookValueCents = cost - annualAfaCents // after year 1 + } else { + // Lineare AfA: cost / usefulLifeYears + annualAfaCents = Math.round(cost / years) + bookValueCents = cost - annualAfaCents // after year 1 + } + + const count = await db.anlage.count() + const number = `ANL-2026-${String(count + 1).padStart(4, '0')}` + + const anlage = await db.anlage.create({ + data: { + number, name: name.trim(), category: category || 'hardware', + purchaseDate: new Date(purchaseDate), + purchaseCostCents: cost, + afaMethod: afaMethod ?? 'linear', + usefulLifeYears: isGWG ? 1 : isSammelposten ? 5 : years, + isGWG, isSammelposten, + annualAfaCents, + accumulatedAfaCents: annualAfaCents, // year 1 + bookValueCents, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', action: 'anlage.create', entity: 'Anlage', entityId: anlage.id, + after: JSON.stringify({ number, name, cost, isGWG, annualAfaCents, bookValueCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ anlage }) + } 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/belege/route.ts b/src/app/api/aos/belege/route.ts new file mode 100644 index 0000000..93e2218 --- /dev/null +++ b/src/app/api/aos/belege/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHash } from 'crypto' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/belege — list receipts/documents +export async function GET() { + try { + const belege = await db.beleg.findMany({ orderBy: { createdAt: 'desc' } }) + const stats = { + total: belege.length, + totalAmountCents: belege.reduce((s, b) => s + (b.amountCents ?? 0), 0), + byType: belege.reduce((acc, b) => { acc[b.type] = (acc[b.type] ?? 0) + 1; return acc }, {} as Record), + } + return NextResponse.json({ belege, stats }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// POST /api/aos/belege — register a receipt (content-addressed) +export async function POST(req: Request) { + try { + const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = (await req.json()) as { + fileName: string; fileType: string; fileSizeBytes: number; fileContent: string // base64 + type: string; vendor?: string; invoiceNumber?: string; amountCents?: number; bookingDate?: string + } + + if (!fileName?.trim()) return NextResponse.json({ error: 'fileName required' }, { status: 400 }) + + // Compute SHA-256 hash of file content (content-addressed storage) + const fileHash = createHash('sha256').update(fileContent ?? fileName).digest('hex') + + // Check for duplicate (same file hash) + const existing = await db.beleg.findUnique({ where: { fileHash } }) + if (existing) return NextResponse.json({ error: 'duplicate file (same SHA-256 hash)', beleg: existing }, { status: 409 }) + + const count = await db.beleg.count() + const number = `BEL-2026-${String(count + 1).padStart(4, '0')}` + + // GoBD retention: 8 years for invoices, 6 for business letters + const retentionUntil = new Date() + retentionUntil.setFullYear(retentionUntil.getFullYear() + (type.includes('rechnung') ? 8 : 6)) + + const beleg = await db.beleg.create({ + data: { + number, type, fileHash, fileName, fileType, fileSizeBytes, + vendor: vendor ?? null, invoiceNumber: invoiceNumber ?? null, + amountCents: amountCents ?? null, + bookingDate: bookingDate ? new Date(bookingDate) : null, + retentionUntil, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', action: 'beleg.create', entity: 'Beleg', entityId: beleg.id, + after: JSON.stringify({ number, type, fileHash: fileHash.slice(0, 16), amountCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ beleg }) + } 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/kassenbuch/route.ts b/src/app/api/aos/kassenbuch/route.ts new file mode 100644 index 0000000..6ae6eea --- /dev/null +++ b/src/app/api/aos/kassenbuch/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/kassenbuch — list cash book entries (GoBD: unveränderlich) +export async function GET() { + try { + const entries = await db.kassenbuchEntry.findMany({ orderBy: { date: 'desc' } }) + const totalIn = entries.filter((e) => e.type === 'einnahme').reduce((s, e) => s + e.amountCents, 0) + const totalOut = entries.filter((e) => e.type === 'ausgabe').reduce((s, e) => s + Math.abs(e.amountCents), 0) + const balance = entries[0]?.balanceCents ?? 0 // latest entry has current balance + + return NextResponse.json({ + entries, + stats: { + total: entries.length, + totalInCents: totalIn, + totalOutCents: totalOut, + balanceCents: balance, + taxFreeCount: entries.filter((e) => e.taxFree).length, + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// POST /api/aos/kassenbuch — add a cash book entry (§ 146 AO, § 22 UStG) +export async function POST(req: Request) { + try { + const { date, type, description, amountCents, taxBp, taxFree, belegId } = (await req.json()) as { + date: string; type: 'einnahme' | 'ausgabe'; description: string; amountCents: number + taxBp?: number; taxFree?: boolean; belegId?: string + } + + if (!description?.trim()) return NextResponse.json({ error: 'description required' }, { status: 400 }) + if (!amountCents || amountCents <= 0) return NextResponse.json({ error: 'amountCents must be > 0' }, { status: 400 }) + if (!['einnahme', 'ausgabe'].includes(type)) return NextResponse.json({ error: 'type must be einnahme|ausgabe' }, { status: 400 }) + + // Compute running balance + const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } }) + const signedAmount = type === 'einnahme' ? amountCents : -amountCents + const newBalance = (lastEntry?.balanceCents ?? 0) + signedAmount + + const count = await db.kassenbuchEntry.count() + const number = `KAS-2026-${String(count + 1).padStart(4, '0')}` + + const entry = await db.kassenbuchEntry.create({ + data: { + number, date: new Date(date), type, description: description.trim(), + amountCents: signedAmount, taxBp: taxBp ?? null, taxFree: taxFree ?? false, + belegId: belegId ?? null, balanceCents: newBalance, + }, + }) + + // GoBD: Kassenbuch is immutable — audit log every entry + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', action: 'kassenbuch.create', entity: 'KassenbuchEntry', entityId: entry.id, + after: JSON.stringify({ number, type, amountCents: signedAmount, balanceCents: newBalance }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ entry }) + } 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/pauschalen/route.ts b/src/app/api/aos/pauschalen/route.ts new file mode 100644 index 0000000..5ff5f61 --- /dev/null +++ b/src/app/api/aos/pauschalen/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +const CENTS = (e: number) => Math.round(e * 100) + +// GET /api/aos/pauschalen — list flat-rate allowances +export async function GET() { + try { + const pauschalen = await db.pauschale.findMany({ orderBy: { date: 'desc' } }) + const stats = { + total: pauschalen.length, + totalAmountCents: pauschalen.reduce((s, p) => s + p.amountCents, 0), + homeoffice: pauschalen.filter((p) => p.type === 'homeoffice').length, + kilometer: pauschalen.filter((p) => p.type === 'kilometer').length, + verpflegung: pauschalen.filter((p) => p.type === 'verpflegung').length, + } + return NextResponse.json({ pauschalen, stats, rates: getRates() }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// POST /api/aos/pauschalen — create a pauschale (auto-computes amount) +export async function POST(req: Request) { + try { + const body = (await req.json()) as { + type: 'homeoffice' | 'kilometer' | 'verpflegung' + date: string + userId?: string + days?: number + km?: number + vehicleType?: string + tripPurpose?: string + hoursAway?: number + overnight?: boolean + description?: string + projectId?: string + } + + if (!body.type || !body.date) return NextResponse.json({ error: 'type + date required' }, { status: 400 }) + + // Auto-compute amount based on German tax law (2026 rates) + const rates = getRates() + let amountCents = 0 + + if (body.type === 'homeoffice') { + const days = body.days ?? 1 + amountCents = rates.homeofficePerDay * days + } else if (body.type === 'kilometer') { + const km = body.km ?? 0 + const rate = body.vehicleType === 'auto' ? rates.kilometerAuto : rates.kilometerBike + amountCents = rate * km + } else if (body.type === 'verpflegung') { + const hours = body.hoursAway ?? 0 + if (body.overnight) { + amountCents = rates.verpflegungOvernight + } else if (hours >= 14) { + amountCents = rates.verpflegungOver14h + } else if (hours >= 8) { + amountCents = rates.verpflegung8to14h + } + } + + const pauschale = await db.pauschale.create({ + data: { + type: body.type, + date: new Date(body.date), + userId: body.userId ?? null, + amountCents, + days: body.days ?? null, + km: body.km ?? null, + vehicleType: body.vehicleType ?? null, + tripPurpose: body.tripPurpose ?? null, + hoursAway: body.hoursAway ?? null, + overnight: body.overnight ?? false, + description: body.description ?? null, + projectId: body.projectId ?? null, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', action: 'pauschale.create', entity: 'Pauschale', entityId: pauschale.id, + after: JSON.stringify({ type: body.type, amountCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ pauschale }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// German tax rates 2026 (EStG § 4 Abs. 5, § 9 Abs. 1) +function getRates() { + return { + homeofficePerDay: CENTS(6.92), // 2026: €6.92/day (up from €4.17), max €1.260/year + homeofficeMaxYear: CENTS(1260), + kilometerAuto: CENTS(0.30), // €0.30/km (car, up to 5000km → €0.38/km for carpooling) + kilometerBike: CENTS(0.30), // €0.30/km (bicycle, § 9 EStG) + verpflegung8to14h: CENTS(14), // €14 for 8-14h away + verpflegungOver14h: CENTS(24), // €24 for >14h same day + verpflegungOvernight: CENTS(28), // €28 for overnight (additional) + } +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 0b088f3..0403346 100755 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -23,6 +23,10 @@ export async function POST() { await db.portalApproval.deleteMany() await db.article.deleteMany() await db.taskComment.deleteMany() + await db.kassenbuchEntry.deleteMany() + await db.anlage.deleteMany() + await db.pauschale.deleteMany() + await db.beleg.deleteMany() await db.estimateLine.deleteMany() await db.estimate.deleteMany() await db.ticketMessage.deleteMany() diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index efae75d..ec5d2f3 100755 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -22,6 +22,7 @@ import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' import { RecurringView } from './views/recurring' import { AccountingView } from './views/accounting' +import { FinanzamtView } from './views/finanzamt' import { AuditView } from './views/audit' import { DataView } from './views/data' import { KbView } from './views/kb' @@ -75,6 +76,7 @@ export function AppShell() { {view === 'finance' && } {view === 'recurring' && } {view === 'accounting' && } + {view === 'finanzamt' && } {view === 'audit' && } {view === 'data' && } {view === 'kb' && } diff --git a/src/components/aos/portal-shell.tsx b/src/components/aos/portal-shell.tsx index 667420d..1839098 100755 --- a/src/components/aos/portal-shell.tsx +++ b/src/components/aos/portal-shell.tsx @@ -131,14 +131,14 @@ function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id: Zur Agentur-Ansicht
- Client-Portal · projektbezogener Zugriff + Sichere Verbindung

Client-Portal

- Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6). + Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben.

@@ -222,7 +222,7 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
- Projekt-isolierter Zugriff + Sicher geschützt
@@ -281,7 +281,7 @@ function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overv

Willkommen, {data.company.name}

- Du siehst ausschließlich deine eigenen Daten — projektbezogen isoliert (PRD §5.6). + Du siehst ausschließlich deine eigenen Daten — sicher und geschützt. {data.kpis.pendingApprovals > 0 && {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.}

@@ -421,7 +421,7 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v

Freigaben

-

Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).

+

Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz.

diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index c7b2188..8981caf 100755 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -27,6 +27,7 @@ import { Clock, Database, RefreshCw, + Landmark, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -71,6 +72,7 @@ const NAV: { { 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: 'finanzamt', label: 'Finanzamt', icon: Landmark, 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 4cbeaf1..0bafc68 100755 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -33,6 +33,7 @@ const TITLES: Record = { finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' }, accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, + finanzamt: { title: 'Finanzamt', crumb: 'Betrieb / Finanzamt' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' }, kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' }, diff --git a/src/components/aos/views/finanzamt.tsx b/src/components/aos/views/finanzamt.tsx new file mode 100644 index 0000000..193f27f --- /dev/null +++ b/src/components/aos/views/finanzamt.tsx @@ -0,0 +1,1722 @@ +'use client' + +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + FileText, + Plus, + Loader2, + Calculator, + Banknote, + Wallet, + Receipt, + Home, + Car, + UtensilsCrossed, + Package, + TrendingDown, + PiggyBank, + Lock, +} from 'lucide-react' +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 { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableHeader, + TableBody, + TableHead, + TableRow, + TableCell, +} from '@/components/ui/table' +import { Checkbox } from '@/components/ui/checkbox' +import { PageHeader } from '../primitives' +import { formatEuro, formatDate } from '@/lib/format' +import { cn } from '@/lib/utils' + +// ===== Types ===== + +interface Beleg { + id: string + number: string + type: string + fileHash: string + fileName: string + fileType: string + fileSizeBytes: number + vendor: string | null + invoiceNumber: string | null + amountCents: number | null + bookingDate: string | null + retentionUntil: string | null + createdAt: string +} +interface BelegeData { + belege: Beleg[] + stats: { + total: number + totalAmountCents: number + byType: Record + } +} + +type PauschaleType = 'homeoffice' | 'kilometer' | 'verpflegung' + +interface Pauschale { + id: string + type: PauschaleType + date: string + amountCents: number + days: number | null + km: number | null + vehicleType: string | null + hoursAway: number | null + overnight: boolean + description: string | null + createdAt: string +} +interface PauschalenRates { + homeofficePerDay: number + homeofficeMaxYear: number + kilometerAuto: number + kilometerBike: number + verpflegung8to14h: number + verpflegungOver14h: number + verpflegungOvernight: number +} +interface PauschalenData { + pauschalen: Pauschale[] + stats: { + total: number + totalAmountCents: number + homeoffice: number + kilometer: number + verpflegung: number + } + rates: PauschalenRates +} + +interface Anlage { + id: string + number: string + name: string + category: string + purchaseDate: string + purchaseCostCents: number + afaMethod: string + usefulLifeYears: number + isGWG: boolean + isSammelposten: boolean + annualAfaCents: number + accumulatedAfaCents: number + bookValueCents: number + status: string +} +interface AnlagenData { + anlagen: Anlage[] + stats: { + total: number + totalPurchaseCents: number + totalBookValueCents: number + totalAnnualAfaCents: number + gwgCount: number + sammelpostenCount: number + activeCount: number + } +} + +interface KassenbuchEntry { + id: string + number: string + date: string + type: 'einnahme' | 'ausgabe' + description: string + amountCents: number + taxBp: number | null + taxFree: boolean + balanceCents: number + createdAt: string +} +interface KassenbuchData { + entries: KassenbuchEntry[] + stats: { + total: number + totalInCents: number + totalOutCents: number + balanceCents: number + taxFreeCount: number + } +} + +// ===== Labels / meta ===== + +const BELEG_TYPE_LABELS: Record = { + 'rechnung-eingang': 'Eingangsrechnung', + 'rechnung-ausgang': 'Ausgangsrechnung', + quittung: 'Quittung', + kassenbeleg: 'Kassenbeleg', + bankbeleg: 'Bankbeleg', + sonstiges: 'Sonstiges', +} + +const PAUSCHALE_TYPE_META: Record = { + homeoffice: { label: 'Homeoffice', icon: Home }, + kilometer: { label: 'Kilometerpauschale', icon: Car }, + verpflegung: { label: 'Verpflegungsmehraufwand', icon: UtensilsCrossed }, +} + +const ANLAGE_CATEGORY_LABELS: Record = { + hardware: 'Hardware', + software: 'Software', + mobiliar: 'Mobiliar', + fahrzeug: 'Fahrzeug', + immobilie: 'Immobilie', +} + +const ANLAGE_STATUS_META: Record = { + active: { label: 'Aktiv', cls: 'bg-foreground text-background' }, + sold: { label: 'Verkauft', cls: 'bg-muted text-muted-foreground' }, + scrapped: { label: 'Ausgeschieden', cls: 'bg-muted text-muted-foreground' }, + 'fully-depreciated': { label: 'Voll abgeschrieben', cls: 'bg-muted/60 text-muted-foreground' }, +} + +const TAX_BP_OPTIONS: { bp: number; label: string }[] = [ + { bp: 700, label: '7 %' }, + { bp: 1900, label: '19 %' }, +] + +// ===== Helpers ===== + +function eurosToCents(s: string): number { + const n = parseFloat((s || '').replace(',', '.')) + if (!Number.isFinite(n) || n <= 0) return 0 + return Math.round(n * 100) +} + +function readFileAsBase64(f: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const result = reader.result + if (typeof result === 'string' && result.includes(',')) { + resolve(result.split(',')[1] ?? '') + } else { + resolve(typeof result === 'string' ? result : '') + } + } + reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden')) + reader.readAsDataURL(f) + }) +} + +// ===== Main view ===== + +export function FinanzamtView() { + const [tab, setTab] = useState('belege') + return ( +
+ + + + Belege + Pauschalen + Anlagen & AfA + Kassenbuch + + + + + + + + + + + + + + +
+ ) +} + +export default FinanzamtView + +// ===== Shared bits ===== + +function ApiErrorBanner({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + +

+ Fehler beim Laden: {message} +

+ +
+
+ ) +} + +function EmptyRow({ text }: { text: string }) { + return
{text}
+} + +// ===== Tab 1: Belege ===== + +function BelegeTab() { + const { data, loading, error, refetch } = useFetch('/api/aos/belege') + const [open, setOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + + if (loading) { + return ( +
+ + +
+ ) + } + if (error || !data) { + return + } + + const { belege, stats } = data + + const submit = async (form: BelegForm) => { + setSubmitting(true) + const t = toast.loading('Beleg wird registriert…') + try { + const r = await fetch('/api/aos/belege', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(form), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Beleg konnte nicht registriert werden') + toast.success(`Beleg ${res.beleg.number} registriert`, { id: t }) + setOpen(false) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Registrierung fehlgeschlagen', { id: t }) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ +
+ +
+ +
+

Belege gesamt

+ +
+

{stats.total}

+

GoBD Aufbewahrung 8 / 6 Jahre

+
+ +
+

Summe Belegbeträge

+ +
+

{formatEuro(stats.totalAmountCents)}

+

{Object.keys(stats.byType).length} Belegarten

+
+
+ + + + Belege ({belege.length}) + + + {belege.length === 0 ? ( + + ) : ( +
+ + + + Nummer + Typ + Lieferant + Betrag + Hash + Erstellt + + + + {belege.map((b) => ( + + + {b.number} + + + + {BELEG_TYPE_LABELS[b.type] ?? b.type} + + + + {b.vendor ?? '—'} + {b.invoiceNumber && ( + ({b.invoiceNumber}) + )} + + + {b.amountCents != null ? formatEuro(b.amountCents) : '—'} + + + + {b.fileHash.slice(0, 12)}… + + + + {formatDate(b.createdAt)} + + + ))} + +
+
+ )} +
+
+ + + +

GoBD / Content-Addressed Storage

+
    +
  • • Dateien werden über SHA-256-Hash identifiziert (duplicate detection)
  • +
  • • Aufbewahrung: 8 Jahre (Rechnungen) / 6 Jahre (Geschäftsbriefe) — § 147 AO
  • +
  • • Jede Beleg-Registrierung wird im Audit-Log verzeichnet (Hash-Kette)
  • +
+
+
+ + +
+ ) +} + +interface BelegForm { + fileName: string + fileType: string + fileSizeBytes: number + fileContent: string + type: string + vendor?: string + invoiceNumber?: string + amountCents?: number + bookingDate?: string +} + +function BelegDialog({ + open, + onOpenChange, + submitting, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + submitting: boolean + onSubmit: (form: BelegForm) => void +}) { + const [fileName, setFileName] = useState('') + const [type, setType] = useState('rechnung-eingang') + const [vendor, setVendor] = useState('') + const [invoiceNumber, setInvoiceNumber] = useState('') + const [amount, setAmount] = useState('') + const [bookingDate, setBookingDate] = useState('') + const [file, setFile] = useState(null) + + const reset = () => { + setFileName('') + setType('rechnung-eingang') + setVendor('') + setInvoiceNumber('') + setAmount('') + setBookingDate('') + setFile(null) + } + + const onFile = (f: File | null) => { + setFile(f) + if (f && !fileName) setFileName(f.name) + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault() + if (!fileName.trim()) { + toast.error('Dateiname erforderlich') + return + } + let fileType = file?.type ?? 'application/octet-stream' + let fileSizeBytes = file?.size ?? fileName.length + let fileContent = fileName + if (file) { + try { + fileContent = await readFileAsBase64(file) + } catch { + // keep fileName fallback — SHA-256 still computes + } + } + const amountCents = amount ? eurosToCents(amount) : undefined + onSubmit({ + fileName: fileName.trim(), + fileType, + fileSizeBytes, + fileContent, + type, + vendor: vendor.trim() || undefined, + invoiceNumber: invoiceNumber.trim() || undefined, + amountCents: amountCents && amountCents > 0 ? amountCents : undefined, + bookingDate: bookingDate || undefined, + }) + } + + return ( + { + if (!v) reset() + onOpenChange(v) + }} + > + + + Beleg registrieren + + Datei ist content-addressed (SHA-256). GoBD: Aufbewahrung 8 Jahre (Rechnungen) / 6 Jahre (Geschäftsbriefe). + + +
+
+ + +
+
+ + setFileName(e.target.value)} + placeholder="z.B. rechnung-2026-042.pdf" + /> +
+
+ + +
+
+
+ + setVendor(e.target.value)} placeholder="Acme GmbH" /> +
+
+ + setInvoiceNumber(e.target.value)} + placeholder="RE-2026-042" + /> +
+
+
+
+ + setAmount(e.target.value)} + placeholder="199,00" + /> +
+
+ + setBookingDate(e.target.value)} /> +
+
+ + + + +
+
+
+ ) +} + +// ===== Tab 2: Pauschalen ===== + +function PauschalenTab() { + const { data, loading, error, refetch } = useFetch('/api/aos/pauschalen') + const [open, setOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + + if (loading) { + return ( +
+ + +
+ ) + } + if (error || !data) { + return + } + + const { pauschalen, stats, rates } = data + + const submit = async (form: PauschaleForm) => { + setSubmitting(true) + const t = toast.loading('Pauschale wird erfasst…') + try { + const r = await fetch('/api/aos/pauschalen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(form), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Pauschale konnte nicht erfasst werden') + toast.success(`Pauschale gespeichert (${formatEuro(res.pauschale.amountCents)})`, { id: t }) + setOpen(false) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Erfassung fehlgeschlagen', { id: t }) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+ +
+ +
+ +
+

Pauschalen gesamt

+ +
+

{stats.total}

+
+ +
+

Summe Beträge

+ +
+

{formatEuro(stats.totalAmountCents)}

+
+ +
+

Homeoffice

+ +
+

{stats.homeoffice}

+

{formatEuro(rates.homeofficePerDay)}/Tag

+
+ +
+

KM & Verpflegung

+ +
+

+ {stats.kilometer} / {stats.verpflegung} +

+

KM · Verpflegung

+
+
+ + + + Pauschalen ({pauschalen.length}) + + + {pauschalen.length === 0 ? ( + + ) : ( +
+ + + + Typ + Datum + Details + Betrag + Beschreibung + + + + {pauschalen.map((p) => { + const meta = PAUSCHALE_TYPE_META[p.type] ?? { label: p.type, icon: Calculator } + const Icon = meta.icon + return ( + + + + + {meta.label} + + + {formatDate(p.date)} + + {p.type === 'homeoffice' && `${p.days ?? 1} Tag(e)`} + {p.type === 'kilometer' && `${p.km ?? 0} km · ${p.vehicleType ?? 'auto'}`} + {p.type === 'verpflegung' && + (p.overnight ? 'Übernachtung' : `${p.hoursAway ?? 0} h abwesend`)} + + + {formatEuro(p.amountCents)} + + + {p.description ?? '—'} + + + ) + })} + +
+
+ )} +
+
+ + + +

Steuerliche Pauschalen (2026)

+
    +
  • + • Homeoffice: {formatEuro(rates.homeofficePerDay)}/Tag, max.{' '} + {formatEuro(rates.homeofficeMaxYear)}/Jahr — EStG § 4 Abs. 5 +
  • +
  • + • Kilometerpauschale: {formatEuro(rates.kilometerAuto)}/km (Auto & Fahrrad) — + EStG § 9 Abs. 1 +
  • +
  • + • Verpflegungsmehraufwand: {formatEuro(rates.verpflegung8to14h)} (8–14 h),{' '} + {formatEuro(rates.verpflegungOver14h)} (>14 h), {formatEuro(rates.verpflegungOvernight)} (Übernachtung) — + EStG § 9 Abs. 4 Nr. 5 +
  • +
+
+
+ + +
+ ) +} + +interface PauschaleForm { + type: PauschaleType + date: string + days?: number + km?: number + vehicleType?: string + hoursAway?: number + overnight?: boolean + description?: string +} + +function PauschaleDialog({ + open, + onOpenChange, + submitting, + rates, + onSubmit, +}: { + open: boolean + onOpenChange: (v: boolean) => void + submitting: boolean + rates: PauschalenRates + onSubmit: (form: PauschaleForm) => void +}) { + const [type, setType] = useState('homeoffice') + const [date, setDate] = useState('') + const [days, setDays] = useState('1') + const [km, setKm] = useState('') + const [vehicleType, setVehicleType] = useState('auto') + const [hoursAway, setHoursAway] = useState('') + const [overnight, setOvernight] = useState(false) + const [description, setDescription] = useState('') + + const reset = () => { + setType('homeoffice') + setDate('') + setDays('1') + setKm('') + setVehicleType('auto') + setHoursAway('') + setOvernight(false) + setDescription('') + } + + const previewCents = useMemo(() => { + if (type === 'homeoffice') return rates.homeofficePerDay * Math.max(1, parseInt(days) || 0) + if (type === 'kilometer') return rates.kilometerAuto * (parseInt(km) || 0) + if (type === 'verpflegung') { + if (overnight) return rates.verpflegungOvernight + const h = parseInt(hoursAway) || 0 + if (h >= 14) return rates.verpflegungOver14h + if (h >= 8) return rates.verpflegung8to14h + return 0 + } + return 0 + }, [type, days, km, hoursAway, overnight, rates]) + + const submit = (e: React.FormEvent) => { + e.preventDefault() + if (!date) { + toast.error('Datum erforderlich') + return + } + const form: PauschaleForm = { + type, + date, + description: description.trim() || undefined, + } + if (type === 'homeoffice') form.days = Math.max(1, parseInt(days) || 1) + if (type === 'kilometer') { + const kmVal = parseInt(km) || 0 + if (kmVal <= 0) { + toast.error('Kilometer muss > 0 sein') + return + } + form.km = kmVal + form.vehicleType = vehicleType + } + if (type === 'verpflegung') { + form.hoursAway = parseInt(hoursAway) || 0 + form.overnight = overnight + } + onSubmit(form) + } + + return ( + { + if (!v) reset() + onOpenChange(v) + }} + > + + + Pauschale erfassen + + Betrag wird automatisch nach § 4 Abs. 5 / § 9 Abs. 1 EStG berechnet. + + +
+
+
+ + +
+
+ + setDate(e.target.value)} /> +
+
+ + {type === 'homeoffice' && ( +
+ + setDays(e.target.value)} /> +

{formatEuro(rates.homeofficePerDay)}/Tag

+
+ )} + + {type === 'kilometer' && ( +
+
+ + setKm(e.target.value)} + placeholder="42" + /> +
+
+ + +
+

+ {formatEuro(rates.kilometerAuto)}/km +

+
+ )} + + {type === 'verpflegung' && ( +
+ + {!overnight && ( +
+ + setHoursAway(e.target.value)} + placeholder="8" + /> +

+ ≥ 8 h: {formatEuro(rates.verpflegung8to14h)} · ≥ 14 h: {formatEuro(rates.verpflegungOver14h)} +

+
+ )} +
+ )} + +
+ +