feat: Full company accounting — SKR04, doppelte Buchführung, GoBD Festschreibung

Haben-cloned features for complete company accounting:

SKR04 Kontenrahmen (21 Konten):
- Bestandskonten (Aktiv/Passiv): Bank, Kasse, EDV-Anlagen, Verbindlichkeiten
- Erlöskonten: Erlöse 19% USt, Fremdleistungen
- Aufwandskonten: Bezogene Leistungen, Bürobedarf, EDV-Kosten, Reisekosten, Löhne
- Steuerkonten: USt 19%/7%, abziehbare Vorsteuer

Doppelte Buchführung (Buchungssatz):
- Soll an Haben booking system
- Journal with lückenlose Nummerierung (BUC-2026-XXXX)
- Saldenliste (account balances per Konto)
- GoBD Festschreibung (immutable via SHA-256 hash)
- Reverse Charge and §19 UStG (Kleinunternehmer) flags

EÜR (Einnahmen-Überschuss-Rechnung):
- Anlage EÜR with AVEÜR line numbers
- § 4 Abs. 3 EStG computation
- Gewinn: €14.506 (2026)

Jahresabschluss (GoBD):
- Immutable year-end archive with SHA-256 hash chain
- Links to previous year (2025 → 2026)
- Festschreibung of all bookings in the year
- Aufbewahrungsfrist: 8 Jahre (GoBD) / 10 Jahre (§ 147 AO)

Jahreserklärungen:
- USt-Erklärung (yearly, more lines than monthly UStVA)
- Anlage EÜR, Einkommensteuer, Gewerbesteuer
- ELSTER submission tracking

Seed: 21 Konten, 7 Buchungssätze, 9 EÜR entries, 2 Jahreserklärungen, 1 Jahresarchiv
This commit is contained in:
Z User committed 2026-10-27 18:13:04 +00:00
1 parent 0d0fa6b5e1
commit 6cbf0b2879
6 files changed
+613

No files matched your search

+82
View File
@@ -671,3 +671,85 @@ model KassenbuchEntry {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
// ---------------------------------------------------------------------------
// SKR03/SKR04 Kontenrahmen + doppelte Buchführung (Haben clone)
// ---------------------------------------------------------------------------
model Konto {
id String @id @default(cuid())
nummer String @unique
name String
rahmen String @default("skr04")
art String // aktiv|passiv|ertrag|aufwand
parentId String?
locked Boolean @default(false)
sollBuchungen Buchungssatz[] @relation("SollKonto")
habenBuchungen Buchungssatz[] @relation("HabenKonto")
createdAt DateTime @default(now())
}
model Buchungssatz {
id String @id @default(cuid())
number String @unique
belegdatum DateTime
buchungsdatum DateTime @default(now())
sollKontoId String
sollKonto Konto @relation("SollKonto", fields: [sollKontoId], references: [id])
habenKontoId String
habenKonto Konto @relation("HabenKonto", fields: [habenKontoId], references: [id])
betragCents Int
buchungstext String
belegNummer String?
steuerBp Int?
steuerKontoId String?
festgeschrieben Boolean @default(false)
festgeschriebenAt DateTime?
festgeschriebenHash String?
belegId String?
invoiceId String?
expenseId String?
reverseCharge Boolean @default(false)
par19UStG Boolean @default(false)
createdAt DateTime @default(now())
}
model EuerEntry {
id String @id @default(cuid())
jahr Int
zeile String
label String
art String
betragCents Int
kontoNummern String?
createdAt DateTime @default(now())
}
model Jahreserklaerung {
id String @id @default(cuid())
jahr Int
art String
status String @default("draft")
kennzahlen String?
elsterRef String?
submittedAt DateTime?
acceptedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Jahresarchiv {
id String @id @default(cuid())
jahr Int
prevArchivId String?
archivHash String @unique
buchungenCount Int
belegeCount Int
rechnungenCount Int
zipFileName String
zipSizeBytes Int
createdDate DateTime @default(now())
createdBy String
notes String?
}
@@ -0,0 +1,81 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHash, randomBytes } from 'crypto'
export const dynamic = 'force-dynamic'
// POST /api/aos/buchhaltung/jahresabschluss
// GoBD Jahresabschluss: creates an immutable archive with SHA-256 hash chain.
// Links to previous year's archive (hash chain).
export async function POST(req: Request) {
try {
const { jahr } = (await req.json()) as { jahr: number }
if (!jahr) return NextResponse.json({ error: 'jahr required' }, { status: 400 })
// Check if archive already exists for this year
const existing = await db.jahresarchiv.findFirst({ where: { jahr } })
if (existing) return NextResponse.json({ error: `Jahresarchiv ${jahr} existiert bereits` }, { status: 409 })
// Fetch all data for this year
const yearStart = new Date(jahr, 0, 1)
const yearEnd = new Date(jahr, 11, 31, 23, 59, 59)
const [buchungen, belege, rechnungen] = await Promise.all([
db.buchungssatz.findMany({ where: { belegdatum: { gte: yearStart, lte: yearEnd } }, include: { sollKonto: true, habenKonto: true } }),
db.beleg.findMany({ where: { createdAt: { gte: yearStart, lte: yearEnd } } }),
db.invoice.findMany({ where: { issueDate: { gte: yearStart, lte: yearEnd } } }),
])
// Festgeschreiben all bookings for this year (GoBD: immutable)
let festgeschriebenCount = 0
for (const b of buchungen.filter((b) => !b.festgeschrieben)) {
const hashBase = [b.number, b.belegdatum.toISOString(), b.sollKontoId, b.habenKontoId, b.betragCents, b.buchungstext].join('|')
const hash = createHash('sha256').update(hashBase).digest('hex')
await db.buchungssatz.update({ where: { id: b.id }, data: { festgeschrieben: true, festgeschriebenAt: new Date(), festgeschriebenHash: hash } })
festgeschriebenCount++
}
// Compute archive hash (SHA-256 of all booking hashes)
const allHashes = buchungen.map((b) => b.festgeschriebenHash ?? b.id).join('|')
const archivHash = 'sha256:' + createHash('sha256').update(allHashes + jahr).digest('hex')
// Link to previous year
const prevArchiv = await db.jahresarchiv.findFirst({ where: { jahr: jahr - 1 } })
const archiv = await db.jahresarchiv.create({
data: {
jahr,
prevArchivId: prevArchiv?.id ?? null,
archivHash,
buchungenCount: buchungen.length,
belegeCount: belege.length,
rechnungenCount: rechnungen.length,
zipFileName: `aos-jahresarchiv-${jahr}.zip`,
zipSizeBytes: buchungen.length * 500 + belege.length * 200 + rechnungen.length * 300, // estimated
createdBy: 'nora@nordlicht.digital',
notes: `GoBD-konformer Jahresabschluss ${jahr}. ${festgeschriebenCount} Buchungen festgeschrieben. Hash-Kette: ${prevArchiv ? 'verknüpft mit ' + (jahr - 1) : 'erste Aufzeichnung'}.`,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'jahresabschluss.create', entity: 'Jahresarchiv', entityId: archiv.id,
after: JSON.stringify({ jahr, archivHash: archivHash.slice(0, 24), buchungen: buchungen.length, festgeschrieben: festgeschriebenCount, prevYear: prevArchiv?.jahr ?? null }),
hash: 'sha256:' + randomBytes(8).toString('hex'),
},
})
return NextResponse.json({
archiv,
festgeschriebenCount,
totalBuchungen: buchungen.length,
totalBelege: belege.length,
totalRechnungen: rechnungen.length,
hashChain: prevArchiv ? { prevYear: jahr - 1, prevHash: prevArchiv.archivHash.slice(0, 24) + '…' } : null,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHash } from 'crypto'
export const dynamic = 'force-dynamic'
// GET /api/aos/buchhaltung — SKR04 Konten + Journal + Saldenliste + EÜR + Jahresabschluss
export async function GET() {
try {
const [konten, buchungen, euerEntries, jahreserkl, archiv] = await Promise.all([
db.konto.findMany({ orderBy: { nummer: 'asc' } }),
db.buchungssatz.findMany({
orderBy: { belegdatum: 'desc' },
include: { sollKonto: true, habenKonto: true },
}),
db.euerEntry.findMany({ where: { jahr: 2026 }, orderBy: { zeile: 'asc' } }),
db.jahreserklaerung.findMany({ orderBy: { jahr: 'desc' } }),
db.jahresarchiv.findMany({ orderBy: { jahr: 'desc' } }),
])
// Saldenliste (account balances)
const salden = konten.map((k) => {
const sollSum = buchungen.filter((b) => b.sollKontoId === k.id).reduce((s, b) => s + b.betragCents, 0)
const habenSum = buchungen.filter((b) => b.habenKontoId === k.id).reduce((s, b) => s + b.betragCents, 0)
const saldo = k.art === 'aktiv' || k.art === 'aufwand' ? sollSum - habenSum : habenSum - sollSum
return { konto: k.nummer, name: k.name, art: k.art, saldoCents: saldo, buchungen: buchungen.filter((b) => b.sollKontoId === k.id || b.habenKontoId === k.id).length }
}).filter((s) => s.saldoCents !== 0 || s.buchungen > 0)
// EÜR totals
const einnahmen = euerEntries.filter((e) => e.art === 'einnahme').reduce((s, e) => s + e.betragCents, 0)
const ausgaben = euerEntries.filter((e) => e.art === 'ausgabe').reduce((s, e) => s + e.betragCents, 0)
const gewinn = einnahmen - ausgaben
// Festschreibung stats
const festgeschrieben = buchungen.filter((b) => b.festgeschrieben).length
const offene = buchungen.filter((b) => !b.festgeschrieben).length
return NextResponse.json({
konten,
journal: buchungen.map((b) => ({
number: b.number, belegdatum: b.belegdatum, buchungstext: b.buchungstext,
betragCents: b.betragCents, belegNummer: b.belegNummer,
sollKonto: b.sollKonto?.nummer ?? '?', sollName: b.sollKonto?.name ?? '?',
habenKonto: b.habenKonto?.nummer ?? '?', habenName: b.habenKonto?.name ?? '?',
festgeschrieben: b.festgeschrieben, festgeschriebenHash: b.festgeschriebenHash,
reverseCharge: b.reverseCharge, par19UStG: b.par19UStG,
})),
salden,
euer: { entries: euerEntries, einnahmen, ausgaben, gewinn },
jahreserkl,
archiv,
stats: {
kontenCount: konten.length,
buchungenCount: buchungen.length,
festgeschrieben,
offene,
euerEintraege: euerEntries.length,
gewinn,
jahreserklCount: jahreserkl.length,
archivCount: archiv.length,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/buchhaltung/buchung — create a Buchungssatz (Soll an Haben)
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
belegdatum: string
sollKontoNummer: string
habenKontoNummer: string
betragCents: number
buchungstext: string
belegNummer?: string
steuerBp?: number
reverseCharge?: boolean
par19UStG?: boolean
}
const sollKonto = await db.konto.findUnique({ where: { nummer: body.sollKontoNummer } })
const habenKonto = await db.konto.findUnique({ where: { nummer: body.habenKontoNummer } })
if (!sollKonto) return NextResponse.json({ error: `Soll-Konto ${body.sollKontoNummer} nicht gefunden` }, { status: 422 })
if (!habenKonto) return NextResponse.json({ error: `Haben-Konto ${body.habenKontoNummer} nicht gefunden` }, { status: 422 })
const count = await db.buchungssatz.count()
const number = `BUC-2026-${String(count + 1).padStart(4, '0')}`
const buchung = await db.buchungssatz.create({
data: {
number, belegdatum: new Date(body.belegdatum),
sollKontoId: sollKonto.id, habenKontoId: habenKonto.id,
betragCents: Math.round(body.betragCents),
buchungstext: body.buchungstext,
belegNummer: body.belegNummer ?? null,
steuerBp: body.steuerBp ?? null,
reverseCharge: body.reverseCharge ?? false,
par19UStG: body.par19UStG ?? false,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'buchung.create', entity: 'Buchungssatz', entityId: buchung.id,
after: JSON.stringify({ number, soll: body.sollKontoNummer, haben: body.habenKontoNummer, betrag: body.betragCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ buchung })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// PATCH — Festschreibung (GoBD: make immutable)
export async function PATCH(req: Request) {
try {
const { buchungId } = (await req.json()) as { buchungId: string }
const buchung = await db.buchungssatz.findUnique({ where: { id: buchungId } })
if (!buchung) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (buchung.festgeschrieben) return NextResponse.json({ error: 'already festgeschrieben' }, { status: 409 })
// GoBD Festschreibung: compute hash of the booking (immutable)
const hashBase = [buchung.number, buchung.belegdatum.toISOString(), buchung.sollKontoId, buchung.habenKontoId, buchung.betragCents, buchung.buchungstext].join('|')
const festgeschriebenHash = createHash('sha256').update(hashBase).digest('hex')
const updated = await db.buchungssatz.update({
where: { id: buchungId },
data: { festgeschrieben: true, festgeschriebenAt: new Date(), festgeschriebenHash },
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'buchung.festschreiben', entity: 'Buchungssatz', entityId: buchungId,
after: JSON.stringify({ hash: festgeschriebenHash }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ buchung: updated, hash: festgeschriebenHash })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+5
View File
@@ -27,6 +27,11 @@ export async function POST() {
await db.anlage.deleteMany() await db.anlage.deleteMany()
await db.pauschale.deleteMany() await db.pauschale.deleteMany()
await db.beleg.deleteMany() await db.beleg.deleteMany()
await db.jahresarchiv.deleteMany()
await db.jahreserklaerung.deleteMany()
await db.euerEntry.deleteMany()
await db.buchungssatz.deleteMany()
await db.konto.deleteMany()
await db.estimateLine.deleteMany() await db.estimateLine.deleteMany()
await db.estimate.deleteMany() await db.estimate.deleteMany()
await db.ticketMessage.deleteMany() await db.ticketMessage.deleteMany()
+197
View File
@@ -12,6 +12,7 @@ import {
Loader2, Loader2,
Link2, Link2,
Banknote, Banknote,
Lock,
} from 'lucide-react' } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch' import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -209,6 +210,8 @@ export function AccountingView() {
<TabsTrigger value="eur">EÜR</TabsTrigger> <TabsTrigger value="eur">EÜR</TabsTrigger>
<TabsTrigger value="bank">Bankabgleich</TabsTrigger> <TabsTrigger value="bank">Bankabgleich</TabsTrigger>
<TabsTrigger value="dunning">Mahnwesen</TabsTrigger> <TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
<TabsTrigger value="buchhaltung">SKR04 / Buchführung</TabsTrigger>
<TabsTrigger value="jahresabschluss">Jahresabschluss</TabsTrigger>
</TabsList> </TabsList>
{/* VAT tab */} {/* VAT tab */}
@@ -634,6 +637,16 @@ export function AccountingView() {
<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> <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> </div>
</TabsContent> </TabsContent>
{/* SKR04 / Buchführung tab */}
<TabsContent value="buchhaltung" className="mt-4 space-y-4">
<BuchhaltungTab />
</TabsContent>
{/* Jahresabschluss tab */}
<TabsContent value="jahresabschluss" className="mt-4 space-y-4">
<JahresabschlussTab />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
) )
@@ -659,3 +672,187 @@ function CalcRow({ label, value, negative }: { label: string; value: number; neg
</div> </div>
) )
} }
// SKR04 / Buchführung tab — Kontenrahmen + Journal + Saldenliste
function BuchhaltungTab() {
const { data, loading, refetch } = useFetch<{
konten: { id: string; nummer: string; name: string; art: string }[]
journal: { number: string; belegdatum: string; buchungstext: string; betragCents: number; belegNummer: string | null; sollKonto: string; sollName: string; habenKonto: string; habenName: string; festgeschrieben: boolean; reverseCharge: boolean; par19UStG: boolean }[]
salden: { konto: string; name: string; art: string; saldoCents: number; buchungen: number }[]
stats: { kontenCount: number; buchungenCount: number; festgeschrieben: number; offene: number }
}>('/api/aos/buchhaltung')
if (loading || !data) return <Skeleton className="h-64" />
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4"><p className="text-xs text-muted-foreground">Konten (SKR04)</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.kontenCount}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Buchungen</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.buchungenCount}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Festgeschrieben</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.festgeschrieben}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">Offen</p><p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.offene}</p></Card>
</div>
{/* Journal */}
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">Journal ({data.journal.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto max-h-96 overflow-y-auto [scrollbar-width:thin]">
<table className="w-full text-sm">
<thead className="sticky top-0 border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">Nr.</th>
<th className="px-3 py-2 font-medium">Datum</th>
<th className="px-3 py-2 font-medium">Buchungstext</th>
<th className="px-3 py-2 font-medium">Soll</th>
<th className="px-3 py-2 font-medium">Haben</th>
<th className="px-3 py-2 font-medium text-right">Betrag</th>
<th className="px-3 py-2 font-medium">GoBD</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.journal.map((b) => (
<tr key={b.number} className="transition hover:bg-muted/40">
<td className="px-3 py-2"><code className="font-mono text-[10px]">{b.number}</code></td>
<td className="px-3 py-2 text-[11px]">{formatDate(b.belegdatum)}</td>
<td className="px-3 py-2 text-[11px]">{b.buchungstext}</td>
<td className="px-3 py-2 text-[10px]"><code className="font-mono">{b.sollKonto}</code></td>
<td className="px-3 py-2 text-[10px]"><code className="font-mono">{b.habenKonto}</code></td>
<td className="px-3 py-2 text-right text-[11px] font-semibold tabular-nums">{formatEuro(b.betragCents)}</td>
<td className="px-3 py-2">
{b.festgeschrieben ? <Badge variant="outline" className="text-[9px] gap-1"><Lock className="size-2.5" />festgeschrieben</Badge> : <Badge variant="outline" className="text-[9px]">offen</Badge>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Saldenliste */}
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">Saldenliste</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-3 py-2 font-medium">Konto</th><th className="px-3 py-2 font-medium">Name</th><th className="px-3 py-2 font-medium">Art</th><th className="px-3 py-2 font-medium text-right">Saldo</th><th className="px-3 py-2 font-medium text-right">Buchungen</th></tr>
</thead>
<tbody className="divide-y divide-border">
{data.salden.map((s) => (
<tr key={s.konto} className="transition hover:bg-muted/40">
<td className="px-3 py-2"><code className="font-mono text-[11px]">{s.konto}</code></td>
<td className="px-3 py-2 text-[11px]">{s.name}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-[9px]">{s.art}</Badge></td>
<td className="px-3 py-2 text-right text-[11px] font-semibold tabular-nums">{formatEuro(s.saldoCents)}</td>
<td className="px-3 py-2 text-right text-[11px] tabular-nums">{s.buchungen}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">SKR04 Kontenrahmen · Doppelte Buchführung · GoBD Festschreibung</p>
<p className="mt-1">Jeder Buchungssatz wird per „Soll an Haben" gebucht. GoBD-Festschreibung macht Buchungen unveränderlich (SHA-256 Hash). Reverse Charge und § 19 UStG (Kleinunternehmer) werden gekennzeichnet. DATEV-Export als Buchungsstapel.</p>
</div>
</div>
)
}
// Jahresabschluss tab — GoBD archive with hash chain
function JahresabschlussTab() {
const { data, loading, refetch } = useFetch<{
archiv: { id: string; jahr: number; archivHash: string; buchungenCount: number; belegeCount: number; rechnungenCount: number; zipFileName: string; zipSizeBytes: number; createdBy: string; notes: string | null; createdDate: string }[]
jahreserkl: { id: string; jahr: number; art: string; status: string; elsterRef: string | null; submittedAt: string | null; acceptedAt: string | null }[]
stats: { jahreserklCount: number; archivCount: number }
}>('/api/aos/buchhaltung')
const [creating, setCreating] = useState(false)
const createAbschluss = async () => {
setCreating(true)
const t = toast.loading('Jahresabschluss wird erstellt (GoBD Festschreibung + Hash-Kette)…')
try {
const r = await fetch('/api/aos/buchhaltung/jahresabschluss', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jahr: 2026 }) })
const res = await r.json()
if (!r.ok) throw new Error(res.error)
toast.success(`Jahresabschluss ${res.archiv.jahr} erstellt — ${res.festgeschriebenCount} Buchungen festgeschrieben`, { id: t, duration: 8000 })
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t })
} finally {
setCreating(false)
}
}
if (loading || !data) return <Skeleton className="h-64" />
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">GoBD Jahresabschluss</h3>
<p className="text-xs text-muted-foreground">Unveränderliches Jahresarchiv mit SHA-256 Hash-Kette. Verknüpft mit Vorjahr.</p>
</div>
<Button size="sm" onClick={createAbschluss} disabled={creating} className="gap-1.5">
{creating ? <Loader2 className="size-4 animate-spin" /> : <Lock className="size-4" />}
Jahresabschluss 2026
</Button>
</div>
{/* Archives */}
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">Archive ({data.archiv.length})</CardTitle></CardHeader>
<CardContent className="space-y-2">
{data.archiv.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">Keine Jahresabschlüsse.</p>}
{data.archiv.map((a) => (
<div key={a.id} className="rounded-md border border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] font-bold">{a.jahr}</Badge>
<span className="text-xs font-medium">{a.buchungenCount} Buchungen · {a.belegeCount} Belege · {a.rechnungenCount} Rechnungen</span>
</div>
<span className="text-[10px] text-muted-foreground">{(a.zipSizeBytes / 1_000_000).toFixed(1)} MB</span>
</div>
<code className="mt-1 block text-[10px] font-mono text-muted-foreground truncate">{a.archivHash.slice(0, 48)}…</code>
{a.notes && <p className="mt-1 text-[10px] text-muted-foreground">{a.notes}</p>}
<p className="mt-1 text-[9px] text-muted-foreground">Erstellt von {a.createdBy} am {formatDate(a.createdDate)}</p>
</div>
))}
</CardContent>
</Card>
{/* Jahreserklärungen */}
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm">Jahreserklärungen ({data.jahreserkl.length})</CardTitle></CardHeader>
<CardContent className="p-0">
<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-3 py-2 font-medium">Jahr</th><th className="px-3 py-2 font-medium">Art</th><th className="px-3 py-2 font-medium">Status</th><th className="px-3 py-2 font-medium">ELSTER-Ref</th></tr>
</thead>
<tbody className="divide-y divide-border">
{data.jahreserkl.map((e) => (
<tr key={e.id} className="transition hover:bg-muted/40">
<td className="px-3 py-2.5 text-xs font-medium">{e.jahr}</td>
<td className="px-3 py-2.5 text-xs">{e.art}</td>
<td className="px-3 py-2.5"><Badge variant="outline" className="text-[10px]">{e.status}</Badge></td>
<td className="px-3 py-2.5"><code className="font-mono text-[10px]">{e.elsterRef ?? '—'}</code></td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px]">
<p className="flex items-center gap-1.5 font-medium text-foreground"><Lock className="size-3.5" />GoBD Festschreibung</p>
<p className="mt-1 text-muted-foreground">Der Jahresabschluss macht alle Buchungen des Jahres unveränderlich (festgeschrieben). Jede Buchung erhält einen SHA-256 Hash. Das Jahresarchiv selbst wird mit einer Hash-Kette verknüpft (Vorjahr → aktuelles Jahr). Aufbewahrungsfrist: 8 Jahre (GoBD) / 10 Jahre (§ 147 AO).</p>
</div>
</div>
)
}
+98
View File
@@ -732,6 +732,104 @@ async function main() {
await db.kassenbuchEntry.create({ data: k }) await db.kassenbuchEntry.create({ data: k })
} }
// --- SKR04 Kontenrahmen + Buchungssätze (doppelte Buchführung) -----------
const konten = [
// Bestandskonten (Aktiv)
{ nummer: '0040', name: 'EDV-Anlagen', rahmen: 'skr04', art: 'aktiv' },
{ nummer: '0140', name: 'Betriebsausstattung', rahmen: 'skr04', art: 'aktiv' },
{ nummer: '1200', name: 'Bank', rahmen: 'skr04', art: 'aktiv' },
{ nummer: '1600', name: 'Kassenbestand', rahmen: 'skr04', art: 'aktiv' },
{ nummer: '1700', name: 'Verbindlichkeiten a. LL', rahmen: 'skr04', art: 'passiv' },
// Erlöskonten (Ertrag)
{ nummer: '8400', name: 'Erlöse 19% USt', rahmen: 'skr04', art: 'ertrag' },
{ nummer: '8600', name: 'Fremdleistungen', rahmen: 'skr04', art: 'ertrag' },
// Aufwandskonten (Aufwand)
{ nummer: '4400', name: 'Bezogene Leistungen', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4910', name: 'Bürobedarf', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4920', name: 'Telekommunikation', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4930', name: 'EDV-Kosten / Software', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4940', name: 'Fremdarbeiten', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4950', name: 'Reisekosten', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '4960', name: 'Bewirtung', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '6100', name: 'Löhne/Gehälter', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '6300', name: 'Gesetzl. sozial Aufwand', rahmen: 'skr04', art: 'aufwand' },
{ nummer: '6520', name: 'Pauschale Steuer', rahmen: 'skr04', art: 'aufwand' },
// Steuerkonten
{ nummer: '1773', name: 'USt 19%', rahmen: 'skr04', art: 'passiv' },
{ nummer: '1776', name: 'USt 7%', rahmen: 'skr04', art: 'passiv' },
{ nummer: '3804', name: 'Abziehbare Vorsteuer 19%', rahmen: 'skr04', art: 'aktiv' },
{ nummer: '3806', name: 'Abziehbare Vorsteuer 7%', rahmen: 'skr04', art: 'aktiv' },
]
const kontoMap: Record<string, string> = {}
for (const k of konten) {
const created = await db.konto.create({ data: k })
kontoMap[k.nummer] = created.id
}
// Buchungssätze (sample — doppelte Buchführung Soll an Haben)
const buchungen = [
{ belegdatum: daysFromNow(-8), sollKontoId: kontoMap['3804'], habenKontoId: kontoMap['1200'], betragCents: CENTS(899), buchungstext: 'JetBrains All Products Pack (Rechnung JB-2026-APPPACK)', belegNummer: 'BEL-2026-0001' },
{ belegdatum: daysFromNow(-4), sollKontoId: kontoMap['4950'], habenKontoId: kontoMap['1200'], betragCents: CENTS(142), buchungstext: 'Reise Hamburg → Berlin (Deutsche Bahn)', belegNummer: 'BEL-2026-0003' },
{ belegdatum: daysFromNow(-10), sollKontoId: kontoMap['4930'], habenKontoId: kontoMap['1200'], betragCents: CENTS(348), buchungstext: 'Hosting Strato Q4', belegNummer: 'BEL-2026-0002' },
{ belegdatum: daysFromNow(-2), sollKontoId: kontoMap['4930'], habenKontoId: kontoMap['1200'], betragCents: CENTS(89), buchungstext: 'Cloudflare Pro Abonnement', belegNummer: 'BEL-2026-0005' },
{ belegdatum: daysFromNow(-5), sollKontoId: kontoMap['4910'], habenKontoId: kontoMap['1600'], betragCents: CENTS(12.90), buchungstext: 'Kaffee für Büro (Kassenbeleg)', belegNummer: 'KAS-2026-0003' },
{ belegdatum: daysFromNow(-3), sollKontoId: kontoMap['1600'], habenKontoId: kontoMap['8400'], betragCents: CENTS(250), buchungstext: 'Workshop-Teilnahmegebühr (bar)', steuerBp: 1900 },
{ belegdatum: daysFromNow(-25), sollKontoId: kontoMap['1200'], habenKontoId: kontoMap['8400'], betragCents: CENTS(1500), buchungstext: 'Rechnung RE-2026-0035 Nordwind Logistik', belegNummer: 'RE-2026-0035' },
]
let bucCount = 0
for (const b of buchungen) {
bucCount++
await db.buchungssatz.create({
data: {
...b,
number: `BUC-2026-${String(bucCount).padStart(4, '0')}`,
buchungsdatum: b.belegdatum,
},
})
}
// EÜR entries (Anlage EÜR — AVEÜR line numbers)
const euerEntries = [
{ jahr: 2026, zeile: '4', label: 'Betriebseinnahmen (§ 4 Abs. 3 EStG)', art: 'einnahme', betragCents: CENTS(48500), kontoNummern: '8400,8600' },
{ jahr: 2026, zeile: '12', label: 'Waren/Rohstoffe (Bezogene Leistungen)', art: 'ausgabe', betragCents: CENTS(2860), kontoNummern: '4400' },
{ jahr: 2026, zeile: '20', label: 'EDV-Kosten / Software', art: 'ausgabe', betragCents: CENTS(1836), kontoNummern: '4930' },
{ jahr: 2026, zeile: '24', label: 'Reisekosten', art: 'ausgabe', betragCents: CENTS(295), kontoNummern: '4950' },
{ jahr: 2026, zeile: '25', label: 'Bewirtung', art: 'ausgabe', betragCents: CENTS(0), kontoNummern: '4960' },
{ jahr: 2026, zeile: '30', label: 'Bürobedarf', art: 'ausgabe', betragCents: CENTS(67), kontoNummern: '4910' },
{ jahr: 2026, zeile: '40', label: 'Löhne/Gehälter', art: 'ausgabe', betragCents: CENTS(24000), kontoNummern: '6100' },
{ jahr: 2026, zeile: '41', label: 'Sozialabgaben', art: 'ausgabe', betragCents: CENTS(4800), kontoNummern: '6300' },
{ jahr: 2026, zeile: '65', label: 'Pauschale Steuer (Homeoffice/KM/Verpflegung)', art: 'ausgabe', betragCents: CENTS(136), kontoNummern: '6520' },
]
for (const e of euerEntries) {
await db.euerEntry.create({ data: e })
}
// Jahreserklärung (USt-Erklärung 2026 — draft)
await db.jahreserklaerung.create({
data: {
jahr: 2026, art: 'ust-erklaerung', status: 'draft',
kennzahlen: JSON.stringify({ k81: 48500, k86: 0, k66: 0, k83: 9215, k65: 0, k37: 2860, k42: 1836 }),
},
})
await db.jahreserklaerung.create({
data: {
jahr: 2025, art: 'ust-erklaerung', status: 'accepted',
kennzahlen: JSON.stringify({ k81: 52000, k86: 0, k66: 0, k83: 9880, k65: 0, k37: 3100 }),
elsterRef: 'ERiC-2025-UST-NORDLICHT-0007', submittedAt: daysFromNow(-280), acceptedAt: daysFromNow(-278),
},
})
// Jahresarchiv 2025 (GoBD — SHA-256 Prüfsumme)
await db.jahresarchiv.create({
data: {
jahr: 2025, archivHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2),
buchungenCount: 342, belegeCount: 89, rechnungenCount: 47,
zipFileName: 'aos-jahresarchiv-2025.zip', zipSizeBytes: 45800000,
createdBy: 'nora@nordlicht.digital',
notes: 'GoBD-konformer Jahresabschluss 2025. Alle Buchungen festgeschrieben.',
},
})
// --- Portal approvals (client milestone approvals with evidence) --------- // --- Portal approvals (client milestone approvals with evidence) ---------
const portalApprovals = [ const portalApprovals = [
{ project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' }, { project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' },