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:
1 parent
0d0fa6b5e1
commit
6cbf0b2879
6 files changed
+613
No files matched your search
@@ -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?
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
<TabsTrigger value="eur">EÜR</TabsTrigger>
|
||||
<TabsTrigger value="bank">Bankabgleich</TabsTrigger>
|
||||
<TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
|
||||
<TabsTrigger value="buchhaltung">SKR04 / Buchführung</TabsTrigger>
|
||||
<TabsTrigger value="jahresabschluss">Jahresabschluss</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
@@ -659,3 +672,187 @@ function CalcRow({ label, value, negative }: { label: string; value: number; neg
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -732,6 +732,104 @@ async function main() {
|
||||
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) ---------
|
||||
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.' },
|
||||
|
||||
Reference in new issue
Block a user