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:
1 parent
c184513b2a
commit
07147a758c
3 files changed
+291
No files matched your search
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
Link2,
|
||||
Banknote,
|
||||
Lock,
|
||||
Download,
|
||||
FileText,
|
||||
CalendarClock,
|
||||
} from 'lucide-react'
|
||||
import { useFetch } from '@/hooks/use-fetch'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -755,6 +758,36 @@ function BuchhaltungTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DATEV + iCal export */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<a
|
||||
href="/api/aos/datev-export?format=datev&year=2026"
|
||||
className="flex items-center gap-3 rounded-md border border-border p-3 transition hover:border-foreground/40"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<FileText className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">DATEV Buchungsstapel (EXTF)</p>
|
||||
<p className="text-[11px] text-muted-foreground">SKR04 Buchungen als DATEV v5.0 Export. Steuerberater-kompatibel.</p>
|
||||
</div>
|
||||
<Download className="size-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="/api/aos/fristen/ical"
|
||||
className="flex items-center gap-3 rounded-md border border-border p-3 transition hover:border-foreground/40"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<CalendarClock className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Fristen als iCal-Feed</p>
|
||||
<p className="text-[11px] text-muted-foreground">Alle Deadlines (UStVA, Verträge, Lizenzen) als Kalender-Abo.</p>
|
||||
</div>
|
||||
<Download className="size-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user