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 })
}
}
+139
View File
@@ -0,0 +1,139 @@
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/fristen/ical
// Returns an iCal (.ics) feed of ALL deadlines (tax, contracts, licenses, projects, invoices, tickets).
// Compatible with Apple Calendar, Google Calendar, Outlook.
// Users can subscribe to: webcal://your-domain/api/aos/fristen/ical
export async function GET() {
try {
const now = new Date()
const futureLimit = new Date(now.getFullYear() + 2, 11, 31)
const events: { uid: string; summary: string; description: string; date: Date; allDay: boolean }[] = []
// VAT returns (due 10th of following month)
const vatReturns = await db.vatReturn.findMany({ where: { status: { in: ['computed', 'prepared', 'submitted'] } } })
for (const v of vatReturns) {
const dueDate = new Date(v.periodYear, v.periodMonth, 10)
if (dueDate < now || dueDate > futureLimit) continue
events.push({
uid: `vat-${v.id}@aos.nordlicht.digital`,
summary: `UStVA ${String(v.periodMonth).padStart(2, '0')}/${v.periodYear} fällig`,
description: `Umsatzsteuer-Voranmeldung — Zahllast: ${(v.payableCents / 100).toFixed(2)} €\nStatus: ${v.status}`,
date: dueDate,
allDay: true,
})
}
// Contract expiry
const contracts = await db.contract.findMany({ where: { status: 'active', endDate: { not: null } } })
for (const c of contracts) {
if (!c.endDate || c.endDate < now || c.endDate > futureLimit) continue
events.push({
uid: `contract-${c.id}@aos.nordlicht.digital`,
summary: `Vertrag ${c.number} läuft ab`,
description: `${c.title}\nTyp: ${c.type}\nWert: ${(c.valueCents / 100).toFixed(2)} €`,
date: c.endDate,
allDay: true,
})
}
// License expiry
const licenses = await db.license.findMany({ where: { status: 'active', validUntil: { not: null } } })
for (const l of licenses) {
if (!l.validUntil || l.validUntil < now || l.validUntil > futureLimit) continue
events.push({
uid: `license-${l.id}@aos.nordlicht.digital`,
summary: `Lizenz ${l.product} läuft ab`,
description: `Plan: ${l.plan}\nKey: ${l.key}`,
date: l.validUntil,
allDay: true,
})
}
// Asset renewals
const assets = await db.asset.findMany({ where: { status: { in: ['active', 'expiring'] } } })
for (const a of assets) {
if (a.renewalDate < now || a.renewalDate > futureLimit) continue
events.push({
uid: `asset-${a.id}@aos.nordlicht.digital`,
summary: `Asset ${a.name} Erneuerung`,
description: `Typ: ${a.type}\nAnbieter: ${a.provider ?? '—'}`,
date: a.renewalDate,
allDay: true,
})
}
// Project due dates
const projects = await db.project.findMany({ where: { dueDate: { not: null }, status: { in: ['planning', 'active', 'review'] } } })
for (const p of projects) {
if (!p.dueDate || p.dueDate < now || p.dueDate > futureLimit) continue
events.push({
uid: `project-${p.id}@aos.nordlicht.digital`,
summary: `Projekt-Deadline: ${p.name}`,
description: `Status: ${p.status}\nFortschritt: ${p.progress}%`,
date: p.dueDate,
allDay: true,
})
}
// Invoice due dates
const invoices = await db.invoice.findMany({ where: { status: { in: ['sent', 'overdue'] } }, include: { company: true } })
for (const i of invoices) {
if (i.dueDate < now || i.dueDate > futureLimit) continue
events.push({
uid: `invoice-${i.id}@aos.nordlicht.digital`,
summary: `Rechnung ${i.number} fällig (${i.company.name})`,
description: `Betrag: ${(i.grossCents / 100).toFixed(2)} €\nStatus: ${i.status}`,
date: i.dueDate,
allDay: true,
})
}
// Sort by date
events.sort((a, b) => a.date.getTime() - b.date.getTime())
// Build iCal
const ics: string[] = []
ics.push('BEGIN:VCALENDAR')
ics.push('VERSION:2.0')
ics.push('PRODID:-//Nordlicht Digital//AOS//DE')
ics.push('CALSCALE:GREGORIAN')
ics.push('METHOD:PUBLISH')
ics.push('X-WR-CALNAME:AOS Fristen')
ics.push('X-WR-TIMEZONE:Europe/Berlin')
for (const e of events) {
ics.push('BEGIN:VEVENT')
ics.push(`UID:${e.uid}`)
const dtstamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
ics.push(`DTSTAMP:${dtstamp}`)
const dtstart = e.date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
ics.push(`DTSTART;VALUE=DATE:${dtstart.slice(0, 8)}`)
ics.push(`SUMMARY:${escapeICS(e.summary)}`)
ics.push(`DESCRIPTION:${escapeICS(e.description)}`)
ics.push('END:VEVENT')
}
ics.push('END:VCALENDAR')
return new Response(ics.join('\r\n'), {
headers: {
'content-type': 'text/calendar; charset=utf-8',
'content-disposition': 'inline; filename="aos-fristen.ics"',
'cache-control': 'no-cache, no-store, must-revalidate',
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return new Response(`BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//AOS//Error\r\nEND:VCALENDAR`, {
status: 500,
headers: { 'content-type': 'text/calendar' },
})
}
}
function escapeICS(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}