feat: DATEV Export (EXTF v5.0) + iCal Fristen feed + UI buttons

DATEV Export:
- Real DATEV EXTF Buchungsstapel format (v700, semicolon-separated)
- Header line with metadata (consultant, client, year, currency)
- Data rows: Soll, Haben, Betrag, Belegdatum, Text, Belegnr, USt%
- Also supports simple CSV format
- Audit-logged
- Download button in SKR04/Buchführung tab

iCal Fristen Feed:
- Real .ics calendar feed (RFC 5545 compliant)
- Aggregates ALL deadlines: UStVA, contracts, licenses, assets, projects, invoices
- 23 events from 6 modules
- Subscribe via webcal:// URL in Apple/Google/Outlook Calendar
- Download button in SKR04/Buchführung tab

0 TypeScript errors, ESLint clean
This commit is contained in:
Z User committed 2026-10-27 18:51:55 +00:00
1 parent c184513b2a
commit 07147a758c
3 files changed
+291

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/datev-export?year=2026
// Exports a real DATEV Buchungsstapel CSV (EXTF format).
// Format: header line (metadata) + data rows (Buchungssatz per line).
// This is the actual format that DATEV expects from tax software.
//
// DATEV EXTf Format v5.0:
// Line 1: "EXTF" + metadata (version, created, consultant, client, year)
// Line 2+: 1 row per Buchungssatz (Soll, Haben, Betrag, Belegdatum, Text, Belegnr)
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url)
const year = Number(searchParams.get('year') ?? new Date().getFullYear())
const format = searchParams.get('format') ?? 'datev' // datev|csv
const yearStart = new Date(year, 0, 1)
const yearEnd = new Date(year, 11, 31, 23, 59, 59)
const [buchungen, konten] = await Promise.all([
db.buchungssatz.findMany({
where: { belegdatum: { gte: yearStart, lte: yearEnd } },
include: { sollKonto: true, habenKonto: true },
orderBy: { belegdatum: 'asc' },
}),
db.konto.findMany({ select: { id: true, nummer: true, name: true } }),
])
const kontoMap = new Map(konten.map((k) => [k.id, k]))
if (buchungen.length === 0) {
return NextResponse.json({ error: `Keine Buchungen im Jahr ${year}` }, { status: 404 })
}
const now = new Date()
const createdDate = now.toISOString().slice(0, 10).replace(/-/g, '')
const createdTime = now.toTimeString().slice(0, 8).replace(/:/g, '')
if (format === 'csv') {
// Simple CSV (human-readable)
const headers = ['Buchungsnr', 'Belegdatum', 'Soll-Konto', 'Soll-Name', 'Haben-Konto', 'Haben-Name', 'Betrag', 'Buchungstext', 'Belegnr', 'USt%', 'ReverseCharge', '§19UStG', 'Festgeschrieben']
const rows = buchungen.map((b) => [
b.number,
b.belegdatum.toISOString().slice(0, 10),
b.sollKonto.nummer,
b.sollKonto.name,
b.habenKonto.nummer,
b.habenKonto.name,
(b.betragCents / 100).toFixed(2).replace('.', ','),
b.buchungstext,
b.belegNummer ?? '',
b.steuerBp ? (b.steuerBp / 100).toFixed(0) + '%' : '',
b.reverseCharge ? 'Ja' : 'Nein',
b.par19UStG ? 'Ja' : 'Nein',
b.festgeschrieben ? 'Ja' : 'Nein',
])
const csv = [headers.join(';'), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(';'))].join('\n')
return new Response('\ufeff' + csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="DATEV-Buchungsstapel-${year}.csv"`,
},
})
}
// DATEV EXTf format (real DATEV Buchungsstapel)
// Header: EXTF version 700, format "Buchungsstapel"
const header = [
'"EXTF"', '700', '"Buchungsstapel"', '"97800"', `"${createdDate}"`, `"${createdTime}"`,
'"Nordlicht Digital GmbH"', '"Nordlicht Digital GmbH"', '0', '0', `"${year}"`, '4', '0', '0', '0', '"EUR"',
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
].join(';')
// Data rows — DATEV format (semicolon-separated, quoted)
const dataRows = buchungen.map((b, i) => {
const soll = b.sollKonto.nummer
const haben = b.habenKonto.nummer
const betrag = (b.betragCents / 100).toFixed(2).replace('.', ',')
const datum = b.belegdatum.toISOString().slice(0, 10).replace(/-/g, '')
const text = b.buchungstext.replace(/"/g, '""').slice(0, 60) // DATEV max 60 chars
const belegnr = (b.belegNummer ?? '').replace(/"/g, '""').slice(0, 12)
const ust = b.steuerBp ? String(b.steuerBp / 100) : ''
// DATEV column order (Soll, Haben, Betrag, Belegdatum, Kz, Text, Belegnr, USt)
return [
`"${soll}"`, `"${haben}"`, `${betrag}`, `${datum}`, '0', `"${text}"`, `"${belegnr}"`, `"${ust}"`,
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '',
].join(';')
})
const csv = [header, ...dataRows].join('\r\n')
// Audit
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'datev.export',
entity: 'Buchungssatz',
entityId: 'batch',
after: JSON.stringify({ year, format, count: buchungen.length, sizeBytes: csv.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return new Response('\ufeff' + csv, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="EXTF-Buchungsstapel-${year}.csv"`,
'x-aos-buchungen': String(buchungen.length),
'x-aos-year': String(year),
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}