102a8603-26df-462f-a56d-01cee292f6f6

This commit is contained in:
Z User committed 2026-10-26 16:00:34 +00:00
1 parent 40805e16cd
commit 85c4006803
19 files changed
+987

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+109
View File
@@ -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
BIN
View File
Binary file not shown.
+62
View File
@@ -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())
}
@@ -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 })
}
+84
View File
@@ -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<string, number> = {}
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 },
},
})
}
@@ -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 })
}
+3
View File
@@ -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()
+2
View File
@@ -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' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
{view === 'finance' && <FinanceView />}
{view === 'accounting' && <AccountingView />}
{view === 'audit' && <AuditView />}
{view === 'settings' && <SettingsView />}
{view === 'setup' && <SetupWizardView />}
+2
View File
@@ -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' },
+2
View File
@@ -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: '*' },
],
},
+1
View File
@@ -27,6 +27,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
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' },
+585
View File
@@ -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<string, { label: string; cls: string }> = {
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<string, string> = {
reise: 'Reise', software: 'Software', hardware: 'Hardware', fremdleistung: 'Fremdleistung', buero: 'Büro', marketing: 'Marketing',
}
export function AccountingView() {
const { data, loading, refetch } = useFetch<AccountingData>('/api/aos/accounting')
const [submitting, setSubmitting] = useState(false)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-40" />
</div>
)
}
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 (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Buchhaltung"
description="Alman muhasebe uyumlu: Umsatzsteuer-Voranmeldung (ELSTER), EÜR, Bankabgleich (CAMT.053) und Mahnwesen. GoBD-konform mit Audit-Hash-Kette."
/>
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">USt-Zahllast (lfd. Monat)</p>
<Landmark className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{currentVat ? formatEuroCompact(currentVat.payableCents) : '—'}</p>
<p className="text-[10px] text-muted-foreground">{currentVat ? `${MONTH_NAMES[currentVat.periodMonth - 1]} ${currentVat.periodYear}` : 'keine offene Periode'}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">USt YTD (abgeführt)</p>
<CheckCircle2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(vat.totalPayableYTD)}</p>
<p className="text-[10px] text-muted-foreground">{vat.stats.accepted} Perioden akzeptiert</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">EÜR Gewinn (6 Mo)</p>
<TrendingUp className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(eur.totalProfit)}</p>
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(eur.totalIncome)} Einnahmen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Überfällige Posten</p>
<AlertTriangle className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(dunning.stats.overdueCents)}</p>
<p className="text-[10px] text-muted-foreground">{dunning.stats.overdueCount} Rechnungen</p>
</Card>
</div>
<Tabs defaultValue="vat">
<TabsList>
<TabsTrigger value="vat">Umsatzsteuer</TabsTrigger>
<TabsTrigger value="eur">EÜR</TabsTrigger>
<TabsTrigger value="bank">Bankabgleich</TabsTrigger>
<TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
</TabsList>
{/* VAT tab */}
<TabsContent value="vat" className="mt-4 space-y-4">
{currentVat && (
<Card>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">
Voranmeldung {MONTH_NAMES[currentVat.periodMonth - 1]} {currentVat.periodYear}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{currentVat.periodType === 'monthly' ? 'Monatlich' : 'Quartalsweise'} ·{' '}
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).cls)}>
{(VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).label}
</Badge>
</p>
</div>
{currentVat.status === 'computed' && (
<Button size="sm" className="gap-1.5" onClick={() => submitVat(currentVat.id)} disabled={submitting}>
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
An ELSTER übermitteln
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-4">
{/* Kennzahlen */}
<div className="mb-4">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kennzahlen (UStVA)</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<Kennzahl kz="81" label="19% Umsatz (netto)" value={currentVat.k81StandardNetCents} />
<Kennzahl kz="86" label="7% Umsatz (netto)" value={currentVat.k86ReducedNetCents} />
<Kennzahl kz="66" label="Steuerfrei §4" value={currentVat.k66TaxFreeCents} />
<Kennzahl kz="83" label="Steuer 19%" value={currentVat.k83TaxStandardCents} />
<Kennzahl kz="65" label="Steuer 7%" value={currentVat.k65TaxReducedCents} />
<Kennzahl kz="37" label="Vorsteuer" value={currentVat.k37InputTaxCents} />
</div>
</div>
{/* Calculation */}
<div className="space-y-1.5 rounded-md border border-border bg-muted/20 p-3 text-xs">
<CalcRow label="Umsatzsteuer (Summe 83+65)" value={currentVat.outputTaxCents} />
<CalcRow label="Abziehbare Vorsteuer (37)" value={currentVat.inputTaxCents} negative />
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
<span>Zahllast / Gutschrift</span>
<span className={cn('tabular-nums', currentVat.payableCents < 0 && 'text-muted-foreground')}>
{currentVat.payableCents < 0 ? '−' : ''}{formatEuro(Math.abs(currentVat.payableCents))}
</span>
</div>
</div>
{currentVat.notes && (
<p className="mt-3 text-[11px] text-muted-foreground">{currentVat.notes}</p>
)}
<div className="mt-3 rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">ELSTER-Übermittlung (ERiC)</p>
<p className="mt-1">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.</p>
</div>
</CardContent>
</Card>
)}
{/* VAT history */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Historie ({vat.history.length} Perioden)</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">Periode</th>
<th className="px-4 py-2 font-medium text-right">81 (19% netto)</th>
<th className="px-4 py-2 font-medium text-right">37 (Vorsteuer)</th>
<th className="px-4 py-2 font-medium text-right">Zahllast</th>
<th className="px-4 py-2 font-medium">ELSTER-Ref</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{vat.history.map((v) => (
<tr key={v.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 text-xs font-medium">{MONTH_NAMES[v.periodMonth - 1]} {v.periodYear}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k81StandardNetCents)}</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k37InputTaxCents)}</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(v.payableCents)}</td>
<td className="px-4 py-2.5"><code className="text-[10px] font-mono text-muted-foreground">{v.elsterRef ?? '—'}</code></td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).cls)}>
{(VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).label}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
{/* EÜR tab */}
<TabsContent value="eur" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Einnahmen vs. Ausgaben (6 Monate, netto)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number, name: string) => [formatEuro(v), name === 'incomeCents' ? 'Einnahmen' : 'Ausgaben']}
/>
<Bar dataKey="incomeCents" fill="var(--foreground)" radius={[3, 3, 0, 0]} />
<Bar dataKey="costCents" fill="var(--muted-foreground)" fillOpacity={0.5} radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Gewinnverlauf</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
<Tooltip
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
formatter={(v: number) => [formatEuro(v), 'Gewinn']}
/>
<Line type="monotone" dataKey="profitCents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">EÜR-Summe (6 Monate)</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 text-xs">
<CalcRow label="Einnahmen (bezahlte Rechnungen, netto)" value={eur.totalIncome} />
<CalcRow label="Ausgaben (Aufwendungen, netto)" value={eur.totalCost} negative />
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
<span>Gewinn (Einnahmen-Überschuss)</span>
<span className="tabular-nums">{formatEuro(eur.totalProfit)}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Ausgaben nach Kategorie</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{eur.costsByCategory.map((c) => {
const max = eur.costsByCategory[0]?.cents ?? 1
const pct = Math.round((c.cents / max) * 100)
return (
<div key={c.category}>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{CATEGORY_LABELS[c.category] ?? c.category}</span>
<span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
<div className="h-full rounded bg-foreground" style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">EÜR (Einnahmen-Überschuss-Rechnung)</p>
<p className="mt-1">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.</p>
</div>
</TabsContent>
{/* Bank reconciliation tab */}
<TabsContent value="bank" className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Zugeordnet</p>
<CheckCircle2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.matched}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Vorgeschlagen</p>
<Link2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.suggested}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Zugänge</p>
<ArrowDownLeft className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.inflowCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Abgänge</p>
<ArrowUpRight className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.outflowCents)}</p>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Bankbewegungen (CAMT.053 Import)</CardTitle>
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs">
<Banknote className="size-3.5" />
CAMT.053 importieren
</Button>
</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">Buchungstag</th>
<th className="px-4 py-2 font-medium">Partner</th>
<th className="px-4 py-2 font-medium">Verwendungszweck</th>
<th className="px-4 py-2 font-medium text-right">Betrag</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{bank.transactions.map((tx) => {
const isIncome = tx.amountCents > 0
return (
<tr key={tx.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 text-xs">{formatDate(tx.bookingDate)}</td>
<td className="px-4 py-2.5 text-xs">
<p className="font-medium">{tx.partnerName ?? '—'}</p>
{tx.partnerIban && <p className="text-[10px] font-mono text-muted-foreground">{tx.partnerIban}</p>}
</td>
<td className="px-4 py-2.5">
<p className="truncate text-xs">{tx.purpose ?? tx.reference ?? '—'}</p>
{tx.matchReason && <p className="text-[10px] text-muted-foreground">{tx.matchReason}</p>}
</td>
<td className={cn('px-4 py-2.5 text-right text-xs font-semibold tabular-nums', isIncome ? 'text-foreground' : 'text-muted-foreground')}>
{isIncome ? '+' : ''}{formatEuro(tx.amountCents)}
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn(
'text-[10px]',
tx.matchedStatus === 'matched' && 'border-foreground/40',
tx.matchedStatus === 'suggested' && 'border-foreground/30',
)}>
{tx.matchedStatus === 'matched' ? 'Zugeordnet' : tx.matchedStatus === 'suggested' ? 'Vorschlag' : 'Offen'}
</Badge>
</td>
<td className="px-4 py-2.5">
{tx.matchedStatus === 'suggested' && (
<Button
size="sm"
className="h-7 gap-1 text-[11px]"
onClick={() => matchTx(tx.id, 'demo')}
>
<CheckCircle2 className="size-3" />Zuordnen
</Button>
)}
{tx.matchedStatus === 'unmatched' && (
<span className="text-[10px] text-muted-foreground">—</span>
)}
{tx.matchedStatus === 'matched' && (
<CheckCircle2 className="size-4 text-foreground" />
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Dunning tab */}
<TabsContent value="dunning" className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangle className="size-3.5 text-muted-foreground" />
Überfällige Rechnungen ({dunning.overdue.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dunning.overdue.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine überfälligen Rechnungen.</p>
)}
{dunning.overdue.map((inv) => (
<div key={inv.id} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between">
<code className="text-[11px] font-mono">{inv.number}</code>
<Badge variant="outline" className="text-[10px]">{inv.daysOverdue} d überfällig</Badge>
</div>
<p className="mt-1 truncate text-xs">{inv.companyName}</p>
<div className="mt-1 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Fällig: {formatDate(inv.dueDate)}</span>
<span className="text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</span>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Mahnlauf-Historie ({dunning.runs.length})</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{dunning.runs.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Mahnungen versendet.</p>
)}
{dunning.runs.map((run) => (
<div key={run.id} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px]">Mahnstufe {run.level}</Badge>
<span className="text-[10px] text-muted-foreground">{formatDateTime(run.sentAt)}</span>
</div>
<p className="mt-1 truncate text-xs">{run.invoice.company.name} · <code className="font-mono text-[10px]">{run.invoice.number}</code></p>
{run.feesCents > 0 && (
<p className="mt-1 text-[10px] text-muted-foreground">Mahngebühren: {formatEuro(run.feesCents)}</p>
)}
</div>
))}
</CardContent>
</Card>
</div>
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">Mahnwesen (3 Stufen)</p>
<p className="mt-1">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).</p>
</div>
</TabsContent>
</Tabs>
</div>
)
}
function Kennzahl({ kz, label, value }: { kz: string; label: string; value: number }) {
return (
<div className="rounded-md border border-border p-2">
<div className="flex items-center justify-between">
<code className="text-[10px] font-mono font-bold">{kz}</code>
<span className="text-[9px] text-muted-foreground">{label}</span>
</div>
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(value)}</p>
</div>
)
}
function CalcRow({ label, value, negative }: { label: string; value: number; negative?: boolean }) {
return (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">{negative ? '−' : ''}{formatEuro(value)}</span>
</div>
)
}
+1
View File
@@ -42,6 +42,7 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
accounting: { label: 'Buchhaltung', desc: 'USt-Voranmeldung (ELSTER), EÜR, Bankabgleich, Mahnwesen.', phase: 'Phase 6' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
+43
View File
@@ -624,6 +624,49 @@ async function main() {
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.send', entity: 'Estimate', entityId: 'seed', after: '{"status":"sent"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.accept', entity: 'Estimate', entityId: 'seed', after: '{"status":"accepted","projectId":"created"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
// --- Accounting: Bank transactions (CAMT.053 style) ---------------------
const bankTx = [
{ amountCents: CENTS(5400), bookingDate: daysFromNow(-1), valueDate: daysFromNow(-1), partnerName: 'Nordwind Logistik GmbH', partnerIban: 'DE89 3704 0044 0532 0130 00', reference: 'RE-2026-0039', purpose: 'SEPA-Überweisung RE-2026-0039', matchedStatus: 'suggested', matchReason: 'Referenz matched RE-2026-0039, Betrag exakt' },
{ amountCents: CENTS(420), bookingDate: daysFromNow(-3), valueDate: daysFromNow(-3), partnerName: 'HanseMöbel Manufaktur GmbH', partnerIban: 'DE12 5001 0517 0000 0000 00', reference: 'RE-2026-0040', purpose: 'Wartungspauschale Oktober', matchedStatus: 'suggested', matchReason: 'Referenz matched, Betrag +/- 0,02€ Toleranz' },
{ amountCents: -CENTS(89), bookingDate: daysFromNow(-2), valueDate: daysFromNow(-2), partnerName: 'Cloudflare Inc.', partnerIban: null, reference: 'CC-2026-5512', purpose: 'Cloudflare Pro Abonnement', matchedStatus: 'unmatched' },
{ amountCents: -CENTS(142), bookingDate: daysFromNow(-4), valueDate: daysFromNow(-4), partnerName: 'Deutsche Bahn AG', partnerIban: null, reference: 'DB-2026-8841', purpose: 'Hamburg → Berlin ICE', matchedStatus: 'unmatched' },
{ amountCents: CENTS(8800), bookingDate: daysFromNow(-12), valueDate: daysFromNow(-11), partnerName: 'TechStart UG', partnerIban: 'DE45 1004 0000 0123 4567 89', reference: 'RE-2026-0036', purpose: 'Rechnung RE-2026-0036 SaaS MVP', matchedStatus: 'matched', matchReason: 'Manuell zugeordnet' },
{ amountCents: -CENTS(44), bookingDate: daysFromNow(-25), valueDate: daysFromNow(-25), partnerName: 'GitHub Inc.', partnerIban: null, reference: 'GH-TEAM-2026', purpose: 'GitHub Team 10 Seats', matchedStatus: 'unmatched' },
{ amountCents: -CENTS(899), bookingDate: daysFromNow(-8), valueDate: daysFromNow(-8), partnerName: 'JetBrains s.r.o.', partnerIban: null, reference: 'JB-2026-APPPACK', purpose: 'All Products Pack 5 Seats', matchedStatus: 'unmatched' },
{ amountCents: CENTS(1500), bookingDate: daysFromNow(-30), valueDate: daysFromNow(-29), partnerName: 'Nordwind Logistik GmbH', partnerIban: 'DE89 3704 0044 0532 0130 00', reference: 'RE-2026-0035', purpose: 'Abschlagszahlung Projekt', matchedStatus: 'matched', matchReason: 'Manuell zugeordnet' },
{ amountCents: -CENTS(180), bookingDate: daysFromNow(-3), valueDate: daysFromNow(-3), partnerName: 'Sectigo', partnerIban: null, reference: 'SC-WILDCARD-2026', purpose: 'SSL Wildcard-Zertifikat', matchedStatus: 'unmatched' },
{ amountCents: -CENTS(67), bookingDate: daysFromNow(-18), valueDate: daysFromNow(-18), partnerName: 'Otto Office GmbH', partnerIban: null, reference: 'OO-2026-Q4', purpose: 'Büromaterial Q4', matchedStatus: 'unmatched' },
]
for (const t of bankTx) {
await db.bankTransaction.create({ data: t })
}
// --- Accounting: VAT returns (Umsatzsteuer-Voranmeldung) -----------------
// Past 3 months computed from invoices (mocked with realistic Kennzahlen)
const vatReturns = [
{ periodYear: 2026, periodMonth: 7, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(4800), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(912), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(180), outputTaxCents: CENTS(912), inputTaxCents: CENTS(180), payableCents: CENTS(732), submittedAt: daysFromNow(-85), elsterRef: 'ERiC-2026-07-NORDLICHT-0001', acceptedAt: daysFromNow(-83) },
{ periodYear: 2026, periodMonth: 8, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(6200), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1178), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(240), outputTaxCents: CENTS(1178), inputTaxCents: CENTS(240), payableCents: CENTS(938), submittedAt: daysFromNow(-55), elsterRef: 'ERiC-2026-08-NORDLICHT-0002', acceptedAt: daysFromNow(-53) },
{ periodYear: 2026, periodMonth: 9, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(5400), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1026), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(310), outputTaxCents: CENTS(1026), inputTaxCents: CENTS(310), payableCents: CENTS(716), submittedAt: daysFromNow(-25), elsterRef: 'ERiC-2026-09-NORDLICHT-0003', acceptedAt: daysFromNow(-23) },
// current month — computed but not yet submitted
{ periodYear: 2026, periodMonth: 10, periodType: 'monthly', status: 'computed', k81StandardNetCents: CENTS(7200), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1368), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(420), outputTaxCents: CENTS(1368), inputTaxCents: CENTS(420), payableCents: CENTS(948) },
]
for (const v of vatReturns) {
await db.vatReturn.create({ data: v })
}
// --- Dunning runs (Mahnwesen) ---------------------------------------------
// the overdue TechStart invoice RE-2026-0036 got a level 1 dunning
const overdueInv = await db.invoice.findFirst({ where: { status: 'overdue' } })
if (overdueInv) {
await db.dunningRun.create({ data: { invoiceId: overdueInv.id, level: 1, status: 'sent', feesCents: CENTS(5), sentAt: daysFromNow(-7) } })
}
// audit entries for accounting
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.compute', entity: 'VatReturn', entityId: 'seed', after: '{"period":"2026-10","payable":94800}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.submit:elster', entity: 'VatReturn', entityId: 'seed', after: '{"elsterRef":"ERiC-2026-09","status":"accepted"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
console.log('Seed complete.')
}
+1
View File
@@ -14,6 +14,7 @@ export type ViewId =
| 'contracts'
| 'expenses'
| 'finance'
| 'accounting'
| 'audit'
| 'reporting'
| 'settings'