diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0df510b..e73391a 100755
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -671,3 +671,85 @@ model KassenbuchEntry {
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?
+}
diff --git a/src/app/api/aos/buchhaltung/jahresabschluss/route.ts b/src/app/api/aos/buchhaltung/jahresabschluss/route.ts
new file mode 100644
index 0000000..da8ce67
--- /dev/null
+++ b/src/app/api/aos/buchhaltung/jahresabschluss/route.ts
@@ -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 })
+ }
+}
diff --git a/src/app/api/aos/buchhaltung/route.ts b/src/app/api/aos/buchhaltung/route.ts
new file mode 100644
index 0000000..5018d32
--- /dev/null
+++ b/src/app/api/aos/buchhaltung/route.ts
@@ -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 })
+ }
+}
diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts
index 0403346..dae0344 100755
--- a/src/app/api/aos/seed/route.ts
+++ b/src/app/api/aos/seed/route.ts
@@ -27,6 +27,11 @@ export async function POST() {
await db.anlage.deleteMany()
await db.pauschale.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.estimate.deleteMany()
await db.ticketMessage.deleteMany()
diff --git a/src/components/aos/views/accounting.tsx b/src/components/aos/views/accounting.tsx
index 5b9eb5a..df367b1 100755
--- a/src/components/aos/views/accounting.tsx
+++ b/src/components/aos/views/accounting.tsx
@@ -12,6 +12,7 @@ import {
Loader2,
Link2,
Banknote,
+ Lock,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -209,6 +210,8 @@ export function AccountingView() {
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).
+ + {/* SKR04 / Buchführung tab */} +Konten (SKR04)
{data.stats.kontenCount}
Buchungen
{data.stats.buchungenCount}
Festgeschrieben
{data.stats.festgeschrieben}
Offen
{data.stats.offene}
| Nr. | +Datum | +Buchungstext | +Soll | +Haben | +Betrag | +GoBD | +
|---|---|---|---|---|---|---|
{b.number} |
+ {formatDate(b.belegdatum)} | +{b.buchungstext} | +{b.sollKonto} |
+ {b.habenKonto} |
+ {formatEuro(b.betragCents)} | +
+ {b.festgeschrieben ? |
+
| Konto | Name | Art | Saldo | Buchungen |
|---|---|---|---|---|
{s.konto} |
+ {s.name} | +{formatEuro(s.saldoCents)} | +{s.buchungen} | +
SKR04 Kontenrahmen · Doppelte Buchführung · GoBD Festschreibung
+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.
+Unveränderliches Jahresarchiv mit SHA-256 Hash-Kette. Verknüpft mit Vorjahr.
+Keine Jahresabschlüsse.
} + {data.archiv.map((a) => ( +{a.archivHash.slice(0, 48)}…
+ {a.notes && {a.notes}
} +Erstellt von {a.createdBy} am {formatDate(a.createdDate)}
+| Jahr | Art | Status | ELSTER-Ref |
|---|---|---|---|
| {e.jahr} | +{e.art} | +{e.elsterRef ?? '—'} |
+
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).
+