From 07147a758c63ff8bf89643893edcd5090d7529fc Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 18:51:55 +0000 Subject: [PATCH] feat: DATEV Export (EXTF v5.0) + iCal Fristen feed + UI buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/aos/datev-export/route.ts | 119 ++++++++++++++++++++ src/app/api/aos/fristen/ical/route.ts | 139 ++++++++++++++++++++++++ src/components/aos/views/accounting.tsx | 33 ++++++ 3 files changed, 291 insertions(+) create mode 100644 src/app/api/aos/datev-export/route.ts create mode 100644 src/app/api/aos/fristen/ical/route.ts diff --git a/src/app/api/aos/datev-export/route.ts b/src/app/api/aos/datev-export/route.ts new file mode 100644 index 0000000..764cb68 --- /dev/null +++ b/src/app/api/aos/datev-export/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/fristen/ical/route.ts b/src/app/api/aos/fristen/ical/route.ts new file mode 100644 index 0000000..9304987 --- /dev/null +++ b/src/app/api/aos/fristen/ical/route.ts @@ -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') +} diff --git a/src/components/aos/views/accounting.tsx b/src/components/aos/views/accounting.tsx index df367b1..027528b 100755 --- a/src/components/aos/views/accounting.tsx +++ b/src/components/aos/views/accounting.tsx @@ -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() { + {/* DATEV + iCal export */} +
+ +
+ +
+
+

DATEV Buchungsstapel (EXTF)

+

SKR04 Buchungen als DATEV v5.0 Export. Steuerberater-kompatibel.

+
+ +
+ +
+ +
+
+

Fristen als iCal-Feed

+

Alle Deadlines (UStVA, Verträge, Lizenzen) als Kalender-Abo.

+
+ +
+
+

SKR04 Kontenrahmen · Doppelte Buchführung · GoBD Festschreibung

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.