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,
|
Link2,
|
||||||
Banknote,
|
Banknote,
|
||||||
Lock,
|
Lock,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
CalendarClock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -755,6 +758,36 @@ function BuchhaltungTab() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<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="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>
|
<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