diff --git a/aos-shots/v13-accounting-bank.png b/aos-shots/v13-accounting-bank.png new file mode 100644 index 0000000..3c5b9d3 Binary files /dev/null and b/aos-shots/v13-accounting-bank.png differ diff --git a/aos-shots/v13-accounting-dunning.png b/aos-shots/v13-accounting-dunning.png new file mode 100644 index 0000000..246261b Binary files /dev/null and b/aos-shots/v13-accounting-dunning.png differ diff --git a/aos-shots/v13-accounting-eur.png b/aos-shots/v13-accounting-eur.png new file mode 100644 index 0000000..dcadc2c Binary files /dev/null and b/aos-shots/v13-accounting-eur.png differ diff --git a/aos-shots/v13-accounting-vat.png b/aos-shots/v13-accounting-vat.png new file mode 100644 index 0000000..40b24c3 Binary files /dev/null and b/aos-shots/v13-accounting-vat.png differ diff --git a/aos-verify10.sh b/aos-verify10.sh new file mode 100644 index 0000000..bc2d9ca --- /dev/null +++ b/aos-verify10.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Verify Accounting module (Haben-inspired German accounting) +set -u +cd /home/z/my-project +BASE=http://127.0.0.1:3000 +SHOTS=/home/z/my-project/aos-shots +mkdir -p "$SHOTS" + +echo "=== Start server ===" +pkill -f "next dev" 2>/dev/null; pkill -f "tee dev.log" 2>/dev/null; sleep 2 +bun run dev > dev.log 2>&1 & +SRV=$! +for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done +echo " ready after ${i}s"; sleep 2 + +echo "=== API tests ===" +echo -n " /api/aos/accounting -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/accounting" +curl -s "$BASE/api/aos/accounting" | python3 -c " +import sys,json +d=json.load(sys.stdin) +v=d['vat'] +print(f' VAT: current period {v[\"current\"][\"periodMonth\"]}/{v[\"current\"][\"periodYear\"]} status={v[\"current\"][\"status\"]} payable={v[\"current\"][\"payableCents\"]/100:.2f}€') +print(f' VAT history: {len(v[\"history\"])} accepted, YTD total: {v[\"totalPayableYTD\"]/100:.2f}€') +e=d['eur'] +print(f' EÜR: profit {e[\"totalProfit\"]/100:.2f}€, income {e[\"totalIncome\"]/100:.2f}€, cost {e[\"totalCost\"]/100:.2f}€, {len(e[\"costsByCategory\"])} categories') +b=d['bank'] +print(f' Bank: {b[\"stats\"][\"matched\"]} matched, {b[\"stats\"][\"suggested\"]} suggested, {b[\"stats\"][\"unmatched\"]} unmatched, inflow {b[\"stats\"][\"inflowCents\"]/100:.0f}€') +du=d['dunning'] +print(f' Dunning: {du[\"stats\"][\"overdueCount\"]} overdue, {du[\"stats\"][\"overdueCents\"]/100:.2f}€, {du[\"stats\"][\"dunningSent\"]} runs') +" 2>/dev/null + +echo "" +echo "=== Test VAT submit to ELSTER ===" +VAT_ID=$(curl -s "$BASE/api/aos/accounting" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['vat']['current']['id'])" 2>/dev/null) +echo " current VAT id: $VAT_ID" +if [ -n "$VAT_ID" ]; then + RES=$(curl -s -X PATCH "$BASE/api/aos/accounting/vat/$VAT_ID/submit") + echo " submit result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"vat\"][\"status\"]}, elsterRef={d[\"elsterRef\"]}')" 2>/dev/null)" +fi + +echo "" +echo "=== Test bank match ===" +# find a suggested bank tx +TX_ID=$(curl -s "$BASE/api/aos/accounting" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for t in d['bank']['transactions']: + if t['matchedStatus']=='suggested': + print(t['id']); break +" 2>/dev/null) +echo " suggested tx id: $TX_ID" +# find an unpaid invoice to match against +INV_ID=$(curl -s "$BASE/api/aos/finance" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for i in d['invoices']: + if i['status'] in ('sent','overdue'): + print(i['id']); break +" 2>/dev/null) +echo " invoice id to match: $INV_ID" +if [ -n "$TX_ID" ] && [ -n "$INV_ID" ]; then + RES=$(curl -s -X POST "$BASE/api/aos/accounting/bank/$TX_ID/match" -H 'content-type: application/json' -d "{\"invoiceId\":\"$INV_ID\"}") + echo " match result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'tx status={d[\"transaction\"][\"matchedStatus\"]}, invoice={d[\"invoiceNumber\"]}')" 2>/dev/null)" +fi + +echo "" +echo "=== Browser: Accounting view ===" +agent-browser open "$BASE/" >/dev/null 2>&1 +agent-browser wait --load networkidle >/dev/null 2>&1 || true +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser set viewport 1440 900 >/dev/null 2>&1 +# navigate via snapshot ref (more reliable than find text) +agent-browser snapshot -i >/tmp/s.txt 2>&1 +ACCT=$(grep -i 'button "Buchhaltung"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1) +echo " Buchhaltung nav ref: $ACCT" +[ -n "$ACCT" ] && agent-browser click "$ACCT" >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " Accounting H1: $H1" +agent-browser screenshot --full "$SHOTS/v13-accounting-vat.png" >/dev/null 2>&1 && echo " VAT shot OK" + +echo "=== Switch to EÜR tab ===" +agent-browser find role tab click --name "EÜR" >/dev/null 2>&1 || agent-browser find text "EÜR" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v13-accounting-eur.png" >/dev/null 2>&1 && echo " EÜR shot OK" + +echo "=== Switch to Bankabgleich tab ===" +agent-browser find role tab click --name "Bankabgleich" >/dev/null 2>&1 || agent-browser find text "Bankabgleich" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v13-accounting-bank.png" >/dev/null 2>&1 && echo " Bank shot OK" + +echo "=== Switch to Mahnwesen tab ===" +agent-browser find role tab click --name "Mahnwesen" >/dev/null 2>&1 || agent-browser find text "Mahnwesen" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v13-accounting-dunning.png" >/dev/null 2>&1 && echo " Dunning shot OK" + +echo "" +echo "=== Errors ===" +agent-browser errors 2>&1 | head -8 +echo "--- console last 5 ---" +agent-browser console 2>&1 | tail -5 + +echo "" +agent-browser close >/dev/null 2>&1 +kill $SRV 2>/dev/null +echo "=== dev.log errors ===" +grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -8 +echo "=== DONE ===" +ls -la "$SHOTS"/v13-* 2>/dev/null | tail -6 diff --git a/db/custom.db b/db/custom.db index 0bf3e04..131e9ab 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d95a8e7..6670a31 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -264,6 +264,7 @@ model Invoice { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lines InvoiceLine[] + dunningRuns DunningRun[] } model InvoiceLine { @@ -412,4 +413,65 @@ model EstimateLine { totalCents Int } +// --------------------------------------------------------------------------- +// Accounting / Buchhaltung (adapted from Haben, German-law compliant) +// VAT return (Umsatzsteuer-Voranmeldung), EÜR, bank reconciliation, dunning. +// PRD §6.6: double-entry + ELSTER out of scope; this covers the agency-facing +// compliance layer (compute, validate, mock-submit) + bank matching. +// --------------------------------------------------------------------------- + +// Bank transactions imported from CAMT.053 or PSD2 (FinTS). +model BankTransaction { + id String @id @default(cuid()) + amountCents Int // signed: + credit (incoming), - debit (outgoing) + bookingDate DateTime + valueDate DateTime + partnerName String? + partnerIban String? + reference String? + purpose String? + matchedInvoiceId String? + matchedStatus String @default("unmatched") + matchReason String? + importBatchId String? + importedAt DateTime @default(now()) + createdAt DateTime @default(now()) +} + +// VAT pre-registration periods (Umsatzsteuer-Voranmeldung) +model VatReturn { + id String @id @default(cuid()) + periodYear Int + periodMonth Int + periodType String @default("monthly") + status String @default("draft") + k81StandardNetCents Int @default(0) + k86ReducedNetCents Int @default(0) + k66TaxFreeCents Int @default(0) + k83TaxStandardCents Int @default(0) + k65TaxReducedCents Int @default(0) + k37InputTaxCents Int @default(0) + outputTaxCents Int @default(0) + inputTaxCents Int @default(0) + payableCents Int @default(0) + submittedAt DateTime? + elsterRef String? + acceptedAt DateTime? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Dunning levels for overdue invoices (Mahnwesen) +model DunningRun { + id String @id @default(cuid()) + invoiceId String + invoice Invoice @relation(fields: [invoiceId], references: [id]) + level Int + status String @default("sent") + feesCents Int @default(0) + sentAt DateTime @default(now()) + createdAt DateTime @default(now()) +} + diff --git a/src/app/api/aos/accounting/bank/[id]/match/route.ts b/src/app/api/aos/accounting/bank/[id]/match/route.ts new file mode 100644 index 0000000..49fbca0 --- /dev/null +++ b/src/app/api/aos/accounting/bank/[id]/match/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// POST /api/aos/accounting/bank/[id]/match — match a bank transaction to an invoice. +// Body: { invoiceId } — marks the transaction matched + invoice paid. +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const { invoiceId } = (await req.json()) as { invoiceId: string } + + const tx = await db.bankTransaction.findUnique({ where: { id } }) + if (!tx) return NextResponse.json({ error: 'transaction not found' }, { status: 404 }) + + const invoice = await db.invoice.findUnique({ where: { id: invoiceId } }) + if (!invoice) return NextResponse.json({ error: 'invoice not found' }, { status: 404 }) + + const updated = await db.bankTransaction.update({ + where: { id }, + data: { + matchedInvoiceId: invoiceId, + matchedStatus: 'matched', + matchReason: 'Manuell zugeordnet', + }, + }) + + // mark invoice as paid + await db.invoice.update({ + where: { id: invoiceId }, + data: { status: 'paid', paidDate: tx.valueDate }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'bank.match:invoice', + entity: 'BankTransaction', + entityId: id, + after: JSON.stringify({ invoiceId, invoiceNumber: invoice.number, status: 'matched', invoiceStatus: 'paid' }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ transaction: updated, invoiceNumber: invoice.number }) +} diff --git a/src/app/api/aos/accounting/route.ts b/src/app/api/aos/accounting/route.ts new file mode 100644 index 0000000..2f27ece --- /dev/null +++ b/src/app/api/aos/accounting/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/accounting — accounting overview: +// - VAT returns (Umsatzsteuer-Voranmeldung) with Kennzahlen +// - EÜR (Einnahmen-Überschuss-Rechnung) from invoices + expenses +// - Bank transactions (CAMT.053 import) with match status +// - Dunning (Mahnwesen) for overdue invoices +export async function GET() { + const [vatReturns, bankTx, invoices, expenses, dunning] = await Promise.all([ + db.vatReturn.findMany({ orderBy: [{ periodYear: 'desc' }, { periodMonth: 'desc' }] }), + db.bankTransaction.findMany({ orderBy: { bookingDate: 'desc' } }), + db.invoice.findMany({ where: { status: { in: ['paid', 'sent', 'overdue'] } }, include: { company: true } }), + db.expense.findMany(), + db.dunningRun.findMany({ orderBy: { sentAt: 'desc' }, include: { invoice: { include: { company: true } } } }), + ]) + + // --- VAT stats --- + const currentVat = vatReturns.find((v) => v.status === 'computed' || v.status === 'draft') + const vatHistory = vatReturns.filter((v) => v.status === 'accepted') + const totalPayableYTD = vatReturns.filter((v) => v.status === 'accepted').reduce((s, v) => s + v.payableCents, 0) + + // --- EÜR (income statement) --- + // Income = paid invoices (net), Cost = expenses (net, non-billable internal) + const now = new Date() + const eurMonths: { label: string; incomeCents: number; costCents: number; profitCents: number }[] = [] + for (let i = 5; i >= 0; i--) { + const m = new Date(now.getFullYear(), now.getMonth() - i, 1) + const label = m.toLocaleDateString('de-DE', { month: 'short' }) + const incomeCents = invoices + .filter((inv) => inv.status === 'paid' && inv.paidDate && inv.paidDate.getMonth() === m.getMonth() && inv.paidDate.getFullYear() === m.getFullYear()) + .reduce((s, i) => s + i.netCents, 0) + const costCents = expenses + .filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear()) + .reduce((s, e) => s + e.amountCents, 0) + eurMonths.push({ label, incomeCents, costCents, profitCents: incomeCents - costCents }) + } + const totalIncome = eurMonths.reduce((s, m) => s + m.incomeCents, 0) + const totalCost = eurMonths.reduce((s, m) => s + m.costCents, 0) + const totalProfit = totalIncome - totalCost + + // EÜR by category (costs) + const costsByCategory: Record = {} + for (const e of expenses) { + costsByCategory[e.categoryId] = (costsByCategory[e.categoryId] ?? 0) + e.amountCents + } + + // --- Bank reconciliation --- + const matched = bankTx.filter((t) => t.matchedStatus === 'matched').length + const suggested = bankTx.filter((t) => t.matchedStatus === 'suggested').length + const unmatched = bankTx.filter((t) => t.matchedStatus === 'unmatched').length + const inflowCents = bankTx.filter((t) => t.amountCents > 0).reduce((s, t) => s + t.amountCents, 0) + const outflowCents = bankTx.filter((t) => t.amountCents < 0).reduce((s, t) => s + t.amountCents, 0) + + // --- Dunning --- + const overdueInvoices = invoices.filter((i) => i.status === 'overdue') + + return NextResponse.json({ + vat: { + current: currentVat, + history: vatHistory, + totalPayableYTD, + stats: { periods: vatReturns.length, accepted: vatHistory.length, pending: currentVat ? 1 : 0 }, + }, + eur: { + months: eurMonths, + totalIncome, + totalCost, + totalProfit, + costsByCategory: Object.entries(costsByCategory).map(([cat, cents]) => ({ category: cat, cents })).sort((a, b) => b.cents - a.cents), + }, + bank: { + transactions: bankTx, + stats: { matched, suggested, unmatched, inflowCents, outflowCents, total: bankTx.length }, + }, + dunning: { + overdue: overdueInvoices.map((i) => ({ id: i.id, number: i.number, companyName: i.company.name, grossCents: i.grossCents, dueDate: i.dueDate, daysOverdue: Math.round((Date.now() - i.dueDate.getTime()) / 86400000) })), + runs: dunning, + stats: { overdueCount: overdueInvoices.length, overdueCents: overdueInvoices.reduce((s, i) => s + i.grossCents, 0), dunningSent: dunning.length }, + }, + }) +} diff --git a/src/app/api/aos/accounting/vat/[id]/submit/route.ts b/src/app/api/aos/accounting/vat/[id]/submit/route.ts new file mode 100644 index 0000000..b817a7f --- /dev/null +++ b/src/app/api/aos/accounting/vat/[id]/submit/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// PATCH /api/aos/accounting/vat/[id]/submit — submit VAT return to ELSTER (mock ERiC). +// Transitions computed → submitted → accepted, generates elsterRef, writes audit. +export async function PATCH( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const vat = await db.vatReturn.findUnique({ where: { id } }) + if (!vat) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (vat.status === 'accepted') return NextResponse.json({ error: 'already accepted' }, { status: 409 }) + + const monthName = new Date(vat.periodYear, vat.periodMonth - 1, 1).toLocaleDateString('de-DE', { month: 'short' }) + const elsterRef = `ERiC-${vat.periodYear}-${String(vat.periodMonth).padStart(2, '0')}-NORDLICHT-${Math.floor(Math.random() * 9000 + 1000)}` + + const updated = await db.vatReturn.update({ + where: { id }, + data: { + status: 'accepted', + submittedAt: new Date(), + acceptedAt: new Date(), + elsterRef, + notes: `Umsatzsteuer-Voranmeldung ${monthName} ${vat.periodYear} via ERiC übermittelt und vom Finanzamt bestätigt.`, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'nora@nordlicht.digital', + action: 'vat.submit:elster', + entity: 'VatReturn', + entityId: id, + before: JSON.stringify({ status: vat.status }), + after: JSON.stringify({ status: 'accepted', elsterRef, payableCents: vat.payableCents }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ vat: updated, elsterRef }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 7be9eeb..57f561c 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -15,6 +15,9 @@ export async function POST() { try { // wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order) const { db } = await import('@/lib/db') + await db.dunningRun.deleteMany() + await db.vatReturn.deleteMany() + await db.bankTransaction.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 c4c7496..be14092 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -18,6 +18,7 @@ import { LicensesView } from './views/licenses' import { ContractsView } from './views/contracts' import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' +import { AccountingView } from './views/accounting' import { AuditView } from './views/audit' import { SettingsView } from './views/settings' import { SetupWizardView } from './views/setup-wizard' @@ -57,6 +58,7 @@ export function AppShell() { {view === 'contracts' && } {view === 'expenses' && } {view === 'finance' && } + {view === 'accounting' && } {view === 'audit' && } {view === 'settings' && } {view === 'setup' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index 405f6e7..f3d39f5 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -20,6 +20,7 @@ import { LifeBuoy, BarChart3, Rocket, + BookOpen, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -45,6 +46,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' }, { id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' }, { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, + { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' }, { id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' }, { id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' }, diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index 3041a3d..e97167f 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -18,6 +18,7 @@ import { LifeBuoy, BarChart3, Rocket, + BookOpen, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { cn } from '@/lib/utils' @@ -57,6 +58,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: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' }, ], }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 47526e9..27d8e6b 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -27,6 +27,7 @@ const TITLES: Record = { contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' }, finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, + accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' }, setup: { title: 'Setup-Wizard', crumb: 'System / Setup' }, diff --git a/src/components/aos/views/accounting.tsx b/src/components/aos/views/accounting.tsx new file mode 100644 index 0000000..e5f3959 --- /dev/null +++ b/src/components/aos/views/accounting.tsx @@ -0,0 +1,585 @@ +'use client' + +import { + BookOpen, + Landmark, + TrendingUp, + ArrowDownLeft, + ArrowUpRight, + Send, + CheckCircle2, + AlertTriangle, + Loader2, + Link2, + Banknote, +} 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { PageHeader } from '../primitives' +import { formatEuro, formatEuroCompact, formatDate, formatDateTime } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { useState } from 'react' +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell, Line, LineChart, CartesianGrid } from 'recharts' + +interface VatReturn { + id: string + periodYear: number + periodMonth: number + periodType: string + status: string + k81StandardNetCents: number + k86ReducedNetCents: number + k66TaxFreeCents: number + k83TaxStandardCents: number + k65TaxReducedCents: number + k37InputTaxCents: number + outputTaxCents: number + inputTaxCents: number + payableCents: number + submittedAt: string | null + elsterRef: string | null + acceptedAt: string | null + notes: string | null +} +interface BankTransaction { + id: string + amountCents: number + bookingDate: string + valueDate: string + partnerName: string | null + partnerIban: string | null + reference: string | null + purpose: string | null + matchedStatus: string + matchReason: string | null +} +interface AccountingData { + vat: { + current: VatReturn | null + history: VatReturn[] + totalPayableYTD: number + stats: { periods: number; accepted: number; pending: number } + } + eur: { + months: { label: string; incomeCents: number; costCents: number; profitCents: number }[] + totalIncome: number + totalCost: number + totalProfit: number + costsByCategory: { category: string; cents: number }[] + } + bank: { + transactions: BankTransaction[] + stats: { matched: number; suggested: number; unmatched: number; inflowCents: number; outflowCents: number; total: number } + } + dunning: { + overdue: { id: string; number: string; companyName: string; grossCents: number; dueDate: string; daysOverdue: number }[] + runs: { id: string; level: number; status: string; feesCents: number; sentAt: string; invoice: { number: string; company: { name: string } } }[] + stats: { overdueCount: number; overdueCents: number; dunningSent: number } + } +} + +const VAT_STATUS_META: Record = { + draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' }, + computed: { label: 'Berechnet', cls: 'bg-foreground/50 text-background' }, + submitted: { label: 'Übermittelt', cls: 'bg-foreground/70 text-background' }, + accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' }, + corrected: { label: 'Korrigiert', cls: 'bg-muted text-muted-foreground' }, +} + +const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'] + +const CATEGORY_LABELS: Record = { + reise: 'Reise', software: 'Software', hardware: 'Hardware', fremdleistung: 'Fremdleistung', buero: 'Büro', marketing: 'Marketing', +} + +export function AccountingView() { + const { data, loading, refetch } = useFetch('/api/aos/accounting') + const [submitting, setSubmitting] = useState(false) + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + const { vat, eur, bank, dunning } = data + + const submitVat = async (vatId: string) => { + setSubmitting(true) + const t = toast.loading('Voranmeldung wird an ELSTER übermittelt (ERiC)…') + try { + const r = await fetch(`/api/aos/accounting/vat/${vatId}/submit`, { method: 'PATCH' }) + if (!r.ok) { + const err = await r.json().catch(() => ({})) + throw new Error(err.error || 'submit failed') + } + const res = await r.json() + toast.success(`Akzeptiert — ELSTER-Ref: ${res.elsterRef}`, { id: t }) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Übermittlung fehlgeschlagen', { id: t }) + } finally { + setSubmitting(false) + } + } + + const matchTx = async (txId: string, invoiceId: string) => { + const t = toast.loading('Bankbewegung wird zugeordnet…') + try { + const r = await fetch(`/api/aos/accounting/bank/${txId}/match`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ invoiceId }), + }) + if (!r.ok) throw new Error('match failed') + toast.success('Zugeordnet — Rechnung als bezahlt markiert', { id: t }) + refetch() + } catch { + toast.error('Zuordnung fehlgeschlagen', { id: t }) + } + } + + const currentVat = vat.current + + return ( +
+ + + {/* KPIs */} +
+ +
+

USt-Zahllast (lfd. Monat)

+ +
+

{currentVat ? formatEuroCompact(currentVat.payableCents) : '—'}

+

{currentVat ? `${MONTH_NAMES[currentVat.periodMonth - 1]} ${currentVat.periodYear}` : 'keine offene Periode'}

+
+ +
+

USt YTD (abgeführt)

+ +
+

{formatEuroCompact(vat.totalPayableYTD)}

+

{vat.stats.accepted} Perioden akzeptiert

+
+ +
+

EÜR Gewinn (6 Mo)

+ +
+

{formatEuroCompact(eur.totalProfit)}

+

{formatEuroCompact(eur.totalIncome)} Einnahmen

+
+ +
+

Überfällige Posten

+ +
+

{formatEuroCompact(dunning.stats.overdueCents)}

+

{dunning.stats.overdueCount} Rechnungen

+
+
+ + + + Umsatzsteuer + EÜR + Bankabgleich + Mahnwesen + + + {/* VAT tab */} + + {currentVat && ( + + +
+
+ + Voranmeldung {MONTH_NAMES[currentVat.periodMonth - 1]} {currentVat.periodYear} + +

+ {currentVat.periodType === 'monthly' ? 'Monatlich' : 'Quartalsweise'} ·{' '} + + {(VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).label} + +

+
+ {currentVat.status === 'computed' && ( + + )} +
+
+ + {/* Kennzahlen */} +
+

Kennzahlen (UStVA)

+
+ + + + + + +
+
+ {/* Calculation */} +
+ + +
+ Zahllast / Gutschrift + + {currentVat.payableCents < 0 ? '−' : ''}{formatEuro(Math.abs(currentVat.payableCents))} + +
+
+ {currentVat.notes && ( +

{currentVat.notes}

+ )} +
+

ELSTER-Übermittlung (ERiC)

+

Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Übermittlung via ERiC-Schnittstelle. Vor dem Absenden: Vorprüfung jeder Kennzahl mit Herkunftsnachweis.

+
+
+
+ )} + + {/* VAT history */} + + + Historie ({vat.history.length} Perioden) + + +
+ + + + + + + + + + + + + {vat.history.map((v) => ( + + + + + + + + + ))} + +
Periode81 (19% netto)37 (Vorsteuer)ZahllastELSTER-RefStatus
{MONTH_NAMES[v.periodMonth - 1]} {v.periodYear}{formatEuro(v.k81StandardNetCents)}{formatEuro(v.k37InputTaxCents)}{formatEuro(v.payableCents)}{v.elsterRef ?? '—'} + + {(VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).label} + +
+
+
+
+
+ + {/* EÜR tab */} + +
+ + + Einnahmen vs. Ausgaben (6 Monate, netto) + + + + + + formatEuroCompact(v).replace(' €', '')} width={50} /> + [formatEuro(v), name === 'incomeCents' ? 'Einnahmen' : 'Ausgaben']} + /> + + + + + + + + + + Gewinnverlauf + + + + + + + formatEuroCompact(v).replace(' €', '')} width={50} /> + [formatEuro(v), 'Gewinn']} + /> + + + + + +
+ +
+ + + EÜR-Summe (6 Monate) + + + + +
+ Gewinn (Einnahmen-Überschuss) + {formatEuro(eur.totalProfit)} +
+
+
+ + + + Ausgaben nach Kategorie + + +
+ {eur.costsByCategory.map((c) => { + const max = eur.costsByCategory[0]?.cents ?? 1 + const pct = Math.round((c.cents / max) * 100) + return ( +
+
+ {CATEGORY_LABELS[c.category] ?? c.category} + {formatEuro(c.cents)} +
+
+
+
+
+ ) + })} +
+ + +
+ +
+

EÜR (Einnahmen-Überschuss-Rechnung)

+

Für Freiberufler und Kleingewerbe (§ 4 Abs. 3 EStG). Einnahmen bei Ist-Versteuerung nach Zufluss, Ausgaben nach Abfluss. Jahres-EÜR mit AVEÜR an das Finanzamt (Anlage EÜR). AfA und pauschalen werden separat berechnet.

+
+ + + {/* Bank reconciliation tab */} + +
+ +
+

Zugeordnet

+ +
+

{bank.stats.matched}

+
+ +
+

Vorgeschlagen

+ +
+

{bank.stats.suggested}

+
+ +
+

Zugänge

+ +
+

{formatEuroCompact(bank.stats.inflowCents)}

+
+ +
+

Abgänge

+ +
+

{formatEuroCompact(bank.stats.outflowCents)}

+
+
+ + + + Bankbewegungen (CAMT.053 Import) + + + +
+ + + + + + + + + + + + + {bank.transactions.map((tx) => { + const isIncome = tx.amountCents > 0 + return ( + + + + + + + + + ) + })} + +
BuchungstagPartnerVerwendungszweckBetragStatusAktion
{formatDate(tx.bookingDate)} +

{tx.partnerName ?? '—'}

+ {tx.partnerIban &&

{tx.partnerIban}

} +
+

{tx.purpose ?? tx.reference ?? '—'}

+ {tx.matchReason &&

{tx.matchReason}

} +
+ {isIncome ? '+' : ''}{formatEuro(tx.amountCents)} + + + {tx.matchedStatus === 'matched' ? 'Zugeordnet' : tx.matchedStatus === 'suggested' ? 'Vorschlag' : 'Offen'} + + + {tx.matchedStatus === 'suggested' && ( + + )} + {tx.matchedStatus === 'unmatched' && ( + — + )} + {tx.matchedStatus === 'matched' && ( + + )} +
+
+
+
+
+ + {/* Dunning tab */} + +
+ + + + + Überfällige Rechnungen ({dunning.overdue.length}) + + + + {dunning.overdue.length === 0 && ( +

Keine überfälligen Rechnungen.

+ )} + {dunning.overdue.map((inv) => ( +
+
+ {inv.number} + {inv.daysOverdue} d überfällig +
+

{inv.companyName}

+
+ Fällig: {formatDate(inv.dueDate)} + {formatEuro(inv.grossCents)} +
+
+ ))} +
+
+ + + + Mahnlauf-Historie ({dunning.runs.length}) + + + {dunning.runs.length === 0 && ( +

Keine Mahnungen versendet.

+ )} + {dunning.runs.map((run) => ( +
+
+ Mahnstufe {run.level} + {formatDateTime(run.sentAt)} +
+

{run.invoice.company.name} · {run.invoice.number}

+ {run.feesCents > 0 && ( +

Mahngebühren: {formatEuro(run.feesCents)}

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

Mahnwesen (3 Stufen)

+

Stufe 1: Zahlungserinnerung (kostenlos) · Stufe 2: Mahnung mit Gebühr (2,50 €) · Stufe 3: Letzte Mahnung + Verzugszinsen (§ 288 BGB, 5 % p.a.). Nach Stufe 3: automatische Suspendierung verknüpfter Lizenzen (konfigurierbar).

+
+
+ +
+ ) +} + +function Kennzahl({ kz, label, value }: { kz: string; label: string; value: number }) { + return ( +
+
+ {kz} + {label} +
+

{formatEuro(value)}

+
+ ) +} + +function CalcRow({ label, value, negative }: { label: string; value: number; negative?: boolean }) { + return ( +
+ {label} + {negative ? '−' : ''}{formatEuro(value)} +
+ ) +} diff --git a/src/components/aos/views/settings.tsx b/src/components/aos/views/settings.tsx index abe4a75..32dd9df 100644 --- a/src/components/aos/views/settings.tsx +++ b/src/components/aos/views/settings.tsx @@ -42,6 +42,7 @@ const MODULE_META: Record