diff --git a/aos-shots/v14-mail-real-test.png b/aos-shots/v14-mail-real-test.png
new file mode 100644
index 0000000..6c50619
Binary files /dev/null and b/aos-shots/v14-mail-real-test.png differ
diff --git a/aos-shots/v14-vat-real-xml.png b/aos-shots/v14-vat-real-xml.png
new file mode 100644
index 0000000..33b3567
Binary files /dev/null and b/aos-shots/v14-vat-real-xml.png differ
diff --git a/db/custom.db b/db/custom.db
index 131e9ab..7943369 100644
Binary files a/db/custom.db and b/db/custom.db differ
diff --git a/src/app/api/aos/accounting/bank/import/route.ts b/src/app/api/aos/accounting/bank/import/route.ts
new file mode 100644
index 0000000..dbb155b
--- /dev/null
+++ b/src/app/api/aos/accounting/bank/import/route.ts
@@ -0,0 +1,155 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import { createHash, randomBytes } from 'crypto'
+
+export const dynamic = 'force-dynamic'
+export const maxDuration = 30
+
+interface ParsedTx {
+ amountCents: number
+ bookingDate: Date
+ valueDate: Date
+ partnerName: string | null
+ partnerIban: string | null
+ reference: string | null
+ purpose: string | null
+}
+
+// POST /api/aos/accounting/bank/import
+// Parses a REAL CAMT.053 (ISO 20022 camt.053.001.02) bank statement XML.
+// This is the actual format used by German banks (DKB, Sparkasse, N26, etc.).
+// Accepts the XML as a string body, parses it with the native DOMParser, and
+// imports each Ntry (entry) as a BankTransaction.
+export async function POST(req: Request) {
+ const contentType = req.headers.get('content-type') ?? ''
+
+ let xmlString: string
+ if (contentType.includes('application/json')) {
+ const body = (await req.json()) as { xml?: string }
+ xmlString = body.xml ?? ''
+ } else {
+ xmlString = await req.text()
+ }
+
+ if (!xmlString.trim().startsWith('<')) {
+ return NextResponse.json({ error: 'expected XML body (CAMT.053 format)' }, { status: 400 })
+ }
+
+ // Parse the CAMT.053 XML. We use a tolerant regex-based extraction because
+ // Node's built-in DOMParser isn't available in Next.js server runtime
+ // without 'xmldom'. The ISO 20022 structure is regular enough to parse
+ // reliably with this approach for the standard N64/Ntry blocks.
+ const transactions: ParsedTx[] = []
+
+ // Match each ... block
+ const entryRegex = /([\s\S]*?)<\/Ntry>/g
+ let entryMatch: RegExpExecArray | null
+ while ((entryMatch = entryRegex.exec(xmlString)) !== null) {
+ const entryXml = entryMatch[1]!
+
+ // Extract fields by tag (CAMT.053 v2 names). Tags may have attributes
+ // like so we allow optional attrs in the opening tag.
+ const getTag = (tag: string): string | null => {
+ const m = entryXml.match(new RegExp(`<(?:\\w+:)?${tag}(?:\\s[^>]*)?>([\\s\\S]*?)(?:\\w+:)?${tag}>`))
+ return m ? m[1]!.trim() : null
+ }
+ // For nested tags like
2026-10-20
, we need to
+ // extract the inner Dt text. getTag('BookgDt') returns the inner XML, then
+ // we extract the date from it.
+ const getInnerDate = (parent: string): Date | null => {
+ const inner = getTag(parent)
+ if (!inner) return null
+ const dm = inner.match(/<(?:\w+:)?Dt>(\d{4}-\d{2}-\d{2}))
+ if (!dm) return null
+ const d = dm[1]!
+ return new Date(`${d}T00:00:00Z`)
+ }
+ const getCdtDbtInd = (): 'credit' | 'debit' | null => {
+ const m = entryXml.match(/<(?:\w+:)?CdtDbtInd>(CRDT|DBIT)<\/(?:\w+:)?CdtDbtInd>/)
+ return m ? (m[1] === 'CRDT' ? 'credit' : 'debit') : null
+ }
+
+ const amountStr = getTag('Amt')
+ const cdtDbt = getCdtDbtInd()
+ const bookDate = getInnerDate('BookgDt') ?? getInnerDate('ValDt') ?? new Date()
+ const valDate = getInnerDate('ValDt') ?? bookDate
+
+ // The RltdPties/Nm block has the partner name; RmtInf/Ustrd has the purpose
+ const nameMatch = entryXml.match(/<(?:\w+:)?Nm>([^<]+)<\/(?:\w+:)?Nm>/)
+ const ibanMatch = entryXml.match(/<(?:\w+:)?IBAN>([^<]+)<\/(?:\w+:)?IBAN>/)
+ const refMatch = entryXml.match(/<(?:\w+:)?EndToEndId>([^<]+)<\/(?:\w+:)?EndToEndId>/)
+ const ustrdMatches = entryXml.match(/<(?:\w+:)?Ustrd>([^<]+)<\/(?:\w+:)?Ustrd>/g)
+
+ if (!amountStr || !cdtDbt) continue
+
+ const amount = parseFloat(amountStr.replace(/[^0-9.\-]/g, ''))
+ if (isNaN(amount)) continue
+ const amountCents = Math.round(Math.abs(amount) * 100) * (cdtDbt === 'debit' ? -1 : 1)
+
+ transactions.push({
+ amountCents,
+ bookingDate: bookDate,
+ valueDate: valDate,
+ partnerName: nameMatch ? nameMatch[1]!.trim() : null,
+ partnerIban: ibanMatch ? ibanMatch[1]!.trim() : null,
+ reference: refMatch ? refMatch[1]!.trim() : null,
+ purpose: ustrdMatches ? ustrdMatches.map((u) => u.replace(/<[^>]+>/g, '').trim()).join(' ') : null,
+ })
+ }
+
+ if (transactions.length === 0) {
+ return NextResponse.json({
+ error: 'no transactions found in XML',
+ detail: 'The XML did not contain valid blocks. Ensure it is a CAMT.053 (camt.053.001.02) bank statement.',
+ xmlLength: xmlString.length,
+ }, { status: 422 })
+ }
+
+ // Content-addressed import batch ID (SHA-256 of the XML content)
+ const importBatchId = 'camt_' + createHash('sha256').update(xmlString).digest('hex').slice(0, 16)
+
+ // Check for duplicate imports (same batch → skip)
+ const existing = await db.bankTransaction.findFirst({ where: { importBatchId } })
+ if (existing) {
+ return NextResponse.json({
+ error: 'duplicate import',
+ detail: `This CAMT.053 file was already imported (batch ${importBatchId}).`,
+ batchId: importBatchId,
+ }, { status: 409 })
+ }
+
+ // Insert all transactions
+ const created: string[] = []
+ for (const tx of transactions) {
+ const record = await db.bankTransaction.create({
+ data: {
+ ...tx,
+ matchedStatus: 'unmatched',
+ importBatchId,
+ },
+ })
+ created.push(record.id)
+ }
+
+ await db.auditLog.create({
+ data: {
+ actor: 'nora@nordlicht.digital',
+ action: 'bank.import:camt053',
+ entity: 'BankTransaction',
+ entityId: importBatchId,
+ after: JSON.stringify({ count: transactions.length, batchId: importBatchId, xmlBytes: xmlString.length }),
+ hash: 'sha256:' + randomBytes(8).toString('hex'),
+ },
+ })
+
+ return NextResponse.json({
+ imported: created.length,
+ batchId: importBatchId,
+ transactions: transactions.map((t) => ({
+ amountCents: t.amountCents,
+ partnerName: t.partnerName,
+ reference: t.reference,
+ bookingDate: t.bookingDate,
+ })),
+ })
+}
diff --git a/src/app/api/aos/accounting/vat/[id]/submit/route.ts b/src/app/api/aos/accounting/vat/[id]/submit/route.ts
index b817a7f..f798235 100644
--- a/src/app/api/aos/accounting/vat/[id]/submit/route.ts
+++ b/src/app/api/aos/accounting/vat/[id]/submit/route.ts
@@ -3,8 +3,16 @@ import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
-// PATCH /api/aos/accounting/vat/[id]/submit — submit VAT return to ELSTER (mock ERiC).
-// Transitions computed → submitted → accepted, generates elsterRef, writes audit.
+// PATCH /api/aos/accounting/vat/[id]/submit
+//
+// Generates the real UStVA (Umsatzsteuer-Voranmeldung) data structure in the
+// format ELSTER expects, validates completeness, and marks the period as
+// "prepared". Actual transmission to ELSTER requires the ERiC client library
+// + a registered tax-certificate — we do NOT fake acceptance.
+//
+// If ERiC is configured (env: ERIC_CERT_PATH + ERIC_CERT_PIN), we would
+// transmit here. Otherwise we return the prepared XML for manual submission
+// or handoff to the tax advisor.
export async function PATCH(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -14,31 +22,102 @@ export async function PATCH(
if (!vat) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (vat.status === 'accepted') return NextResponse.json({ error: 'already accepted' }, { status: 409 })
- const monthName = new Date(vat.periodYear, vat.periodMonth - 1, 1).toLocaleDateString('de-DE', { month: 'short' })
- const elsterRef = `ERiC-${vat.periodYear}-${String(vat.periodMonth).padStart(2, '0')}-NORDLICHT-${Math.floor(Math.random() * 9000 + 1000)}`
+ // --- Real validation ---
+ const errors: string[] = []
+ if (vat.k81StandardNetCents < 0) errors.push('KZ 81 (19% Umsatz) darf nicht negativ sein')
+ if (vat.k86ReducedNetCents < 0) errors.push('KZ 86 (7% Umsatz) darf nicht negativ sein')
+ if (vat.k37InputTaxCents < 0) errors.push('KZ 37 (Vorsteuer) darf nicht negativ sein')
+ // cross-check: 83 should equal 81 * 19%
+ const expected83 = Math.round(vat.k81StandardNetCents * 0.19)
+ if (vat.k83TaxStandardCents !== expected83 && vat.k81StandardNetCents > 0) {
+ errors.push(`KZ 83 (${vat.k83TaxStandardCents}) passt nicht zu KZ 81 × 19% (${expected83})`)
+ }
+ const expected65 = Math.round(vat.k86ReducedNetCents * 0.07)
+ if (vat.k65TaxReducedCents !== expected65 && vat.k86ReducedNetCents > 0) {
+ errors.push(`KZ 65 (${vat.k65TaxReducedCents}) passt nicht zu KZ 86 × 7% (${expected65})`)
+ }
+ if (errors.length > 0) {
+ return NextResponse.json({ error: 'validation_failed', details: errors }, { status: 422 })
+ }
+ // --- Generate real UStVA XML (ELSTER-compliant structure) ---
+ const periodLabel = `${vat.periodYear}-${String(vat.periodMonth).padStart(2, '0')}`
+ const ustvaXml = `
+
+
+ ${new Date().toISOString()}
+ Nordlicht Digital GmbH
+
+
+
+ 048/221/30291
+ Nordlicht Digital GmbH
+ Mühlendamm 14
+ 23552
+ Lübeck
+
+
+ ${vat.periodYear}
+ ${String(vat.periodMonth).padStart(2, '0')}
+
+ ${(vat.k81StandardNetCents / 100).toFixed(2)}
+ ${(vat.k86ReducedNetCents / 100).toFixed(2)}
+ ${(vat.k66TaxFreeCents / 100).toFixed(2)}
+ ${(vat.k83TaxStandardCents / 100).toFixed(2)}
+ ${(vat.k65TaxReducedCents / 100).toFixed(2)}
+ ${(vat.k37InputTaxCents / 100).toFixed(2)}
+
+
+ ${(vat.outputTaxCents / 100).toFixed(2)}
+ ${(vat.inputTaxCents / 100).toFixed(2)}
+ ${(vat.payableCents / 100).toFixed(2)}
+
+
+
+`
+
+ // --- Check if ERiC is actually configured ---
+ const ericConfigured = !!process.env.ERIC_CERT_PATH && !!process.env.ERIC_CERT_PIN
+
+ if (ericConfigured) {
+ // Real ERiC transmission would happen here via a native module / subprocess.
+ // Since the ERiC library is closed-source and requires registration with
+ // the German tax authority, this branch is for production deployments only.
+ // We do NOT fake it in dev.
+ return NextResponse.json({
+ error: 'ERIC transmission not implemented in this build',
+ detail: 'ERiC requires the official ERiC library + certificate. Configure ERIC_LIB_PATH to enable.',
+ }, { status: 501 })
+ }
+
+ // --- Not configured: prepare XML, mark as "prepared" (honest), return XML ---
const updated = await db.vatReturn.update({
where: { id },
data: {
- status: 'accepted',
- submittedAt: new Date(),
- acceptedAt: new Date(),
- elsterRef,
- notes: `Umsatzsteuer-Voranmeldung ${monthName} ${vat.periodYear} via ERiC übermittelt und vom Finanzamt bestätigt.`,
+ status: 'prepared',
+ notes: `UStVA XML für ${periodLabel} erstellt. Übermittlung an ELSTER via ERiC ausstehend (Zertifikat nicht konfiguriert). XML an Steuerberater oder Finanzamt-Portal übergeben.`,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
- action: 'vat.submit:elster',
+ action: 'vat.prepare:xml',
entity: 'VatReturn',
entityId: id,
before: JSON.stringify({ status: vat.status }),
- after: JSON.stringify({ status: 'accepted', elsterRef, payableCents: vat.payableCents }),
+ after: JSON.stringify({ status: 'prepared', period: periodLabel, payableCents: vat.payableCents, xmlLength: ustvaXml.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
- return NextResponse.json({ vat: updated, elsterRef })
+ return NextResponse.json({
+ vat: updated,
+ period: periodLabel,
+ payableCents: vat.payableCents,
+ xml: ustvaXml,
+ xmlSize: ustvaXml.length,
+ transmitted: false,
+ message: 'UStVA-XML erstellt. Übermittlung an ELSTER erfordert ERiC-Zertifikat (ERIC_CERT_PATH env). XML kann manuell an Finanzamt-Portal übergeben werden.',
+ })
}
diff --git a/src/app/api/aos/contracts/[id]/sign/route.ts b/src/app/api/aos/contracts/[id]/sign/route.ts
index 7fc8062..bd6903e 100644
--- a/src/app/api/aos/contracts/[id]/sign/route.ts
+++ b/src/app/api/aos/contracts/[id]/sign/route.ts
@@ -1,10 +1,31 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
+import { randomBytes, createHash } from 'crypto'
export const dynamic = 'force-dynamic'
-// PATCH /api/aos/contracts/[id]/sign — e-signature hook (mock)
-// Transitions draft/sent → signed, records signer + IP + token, writes audit.
+// Extract the real client IP from the request (behind Caddy/proxy).
+function getClientIp(req: Request): string {
+ const headers = new Headers(req.headers)
+ // Caddy/Traefik standard forwarded headers
+ const xff = headers.get('x-forwarded-for')
+ if (xff) return xff.split(',')[0]!.trim()
+ const realIp = headers.get('x-real-ip')
+ if (realIp) return realIp
+ return 'unbekannt'
+}
+
+// PATCH /api/aos/contracts/[id]/sign
+//
+// Portal-based contract acceptance (eIDAS Art. 25(2) advanced electronic
+// signature). Records the REAL signer IP (not a fake), a cryptographically
+// random acceptance token, and a SHA-256 of the contract content at
+// acceptance time (tamper-evidence).
+//
+// NOTE: This is NOT a qualified electronic signature (QES, eIDAS Art. 26),
+// which requires a qualified trust service provider. For contracts that
+// legally require QES (e.g. some real-estate transactions), integrate a TSP
+// like D-Trust / DATEV Sign. We do not fake QES.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -18,15 +39,29 @@ export async function PATCH(
return NextResponse.json({ error: 'already signed' }, { status: 409 })
}
- const token = 'sig_' + Math.random().toString(36).slice(2, 14)
+ // Real cryptographic acceptance token (256-bit)
+ const token = 'acc_' + randomBytes(24).toString('hex')
+ const clientIp = getClientIp(req)
+
+ // SHA-256 of the contract's essential fields → tamper-evidence hash
+ const contentHash = createHash('sha256')
+ .update([contract.number, contract.companyId, contract.type, contract.title, contract.valueCents].join('|'))
+ .digest('hex')
+
const updated = await db.contract.update({
where: { id },
data: {
status: 'signed',
signedAt: new Date(),
signedBy: body.signedBy ?? 'Kunde (via Portal)',
- signedIp: '91.64.x.x (portal)',
+ signedIp: clientIp,
signToken: token,
+ notes: (contract.notes ? contract.notes + '\n\n' : '') +
+ `Akzeptiert via Portal am ${new Date().toISOString()}\n` +
+ `Signatur-Typ: AdES (eIDAS Art. 25 Abs. 2) — fortgeschrittene elektronische Signatur\n` +
+ `Inhalts-Hash (SHA-256): ${contentHash}\n` +
+ `Client-IP: ${clientIp}\n` +
+ `Akzeptanz-Token: ${token}`,
},
})
@@ -37,10 +72,22 @@ export async function PATCH(
entity: 'Contract',
entityId: id,
before: JSON.stringify({ status: contract.status }),
- after: JSON.stringify({ status: 'signed', signedBy: updated.signedBy, token }),
+ after: JSON.stringify({
+ status: 'signed',
+ signedBy: updated.signedBy,
+ ip: clientIp,
+ signatureLevel: 'AdES',
+ contentHash,
+ tokenPrefix: token.slice(0, 12),
+ }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
- return NextResponse.json({ contract: updated })
+ return NextResponse.json({
+ contract: updated,
+ signatureLevel: 'AdES (eIDAS Art. 25 Abs. 2)',
+ contentHash,
+ ip: clientIp,
+ })
}
diff --git a/src/app/api/aos/setup/test-mail/route.ts b/src/app/api/aos/setup/test-mail/route.ts
index 9ba7ca9..43dcc11 100644
--- a/src/app/api/aos/setup/test-mail/route.ts
+++ b/src/app/api/aos/setup/test-mail/route.ts
@@ -1,11 +1,80 @@
import { NextResponse } from 'next/server'
+import * as net from 'net'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
+interface MailTestResult {
+ ok: boolean
+ host: string | null
+ port: number | null
+ message: string
+ banner?: string
+ error?: string
+}
+
+// Real TCP connection test to an SMTP/IMAP server.
+// Attempts to open a socket, read the greeting banner, then close.
+// No fake success — returns the actual connection result or error.
+function testConnection(host: string, port: number, timeoutMs = 8000): Promise {
+ return new Promise((resolve) => {
+ if (!host || !port) {
+ resolve({ ok: false, host, port, message: 'Host/Port fehlt', error: 'incomplete' })
+ return
+ }
+ let banner = ''
+ let resolved = false
+ const socket = net.createConnection({ host, port }, () => {
+ // connected — wait briefly for the server greeting banner
+ })
+ socket.setTimeout(timeoutMs)
+ socket.on('data', (data) => {
+ banner += data.toString('utf8').trim()
+ // SMTP/IMAP banners end with CRLF; once we have the first line, done
+ if (banner.includes('\n') || banner.length > 0) {
+ if (!resolved) {
+ resolved = true
+ socket.destroy()
+ resolve({
+ ok: true,
+ host,
+ port,
+ message: `Verbindung hergestellt zu ${host}:${port}`,
+ banner: banner.split('\n')[0].slice(0, 120),
+ })
+ }
+ }
+ })
+ socket.on('timeout', () => {
+ if (!resolved) {
+ resolved = true
+ socket.destroy()
+ resolve({
+ ok: true, // TCP connected but no banner within timeout (still reachable)
+ host,
+ port,
+ message: `TCP-Verbindung zu ${host}:${port} hergestellt (Banner-Timeout)`,
+ banner: banner || null,
+ })
+ }
+ })
+ socket.on('error', (err) => {
+ if (!resolved) {
+ resolved = true
+ resolve({
+ ok: false,
+ host,
+ port,
+ message: `Verbindung fehlgeschlagen: ${host}:${port}`,
+ error: err.message,
+ })
+ }
+ })
+ })
+}
+
// POST /api/aos/setup/test-mail
-// Wizard step 4: test SMTP/IMAP connection.
-// In a real deployment this would actually connect; here we simulate the test.
+// Real SMTP + IMAP connection test. No simulation.
export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as {
smtpHost?: string
@@ -15,15 +84,15 @@ export async function POST(req: Request) {
user?: string
}
- // simulate a connection check with a short delay
- await new Promise((r) => setTimeout(r, 800))
-
- const smtpOk = !!body.smtpHost && !!body.smtpPort && !!body.user
- const imapOk = !!body.imapHost && !!body.imapPort
+ const [smtp, imap] = await Promise.all([
+ testConnection(body.smtpHost ?? '', Number(body.smtpPort) || 0),
+ testConnection(body.imapHost ?? '', Number(body.imapPort) || 0),
+ ])
return NextResponse.json({
- smtp: { ok: smtpOk, host: body.smtpHost ?? null, port: body.smtpPort ?? null, message: smtpOk ? 'Verbindung erfolgreich (220 smtp.nordlicht.digital ESMTP)' : 'SMTP-Konfiguration unvollständig' },
- imap: { ok: imapOk, host: body.imapHost ?? null, port: body.imapPort ?? null, message: imapOk ? 'INBOX erreichbar (3 ungelesene)' : 'IMAP-Konfiguration unvollständig' },
+ smtp,
+ imap,
+ user: body.user ?? null,
testedAt: new Date().toISOString(),
})
}
diff --git a/src/components/aos/views/accounting.tsx b/src/components/aos/views/accounting.tsx
index e5f3959..5b9eb5a 100644
--- a/src/components/aos/views/accounting.tsx
+++ b/src/components/aos/views/accounting.tsx
@@ -86,6 +86,7 @@ interface AccountingData {
const VAT_STATUS_META: Record = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
computed: { label: 'Berechnet', cls: 'bg-foreground/50 text-background' },
+ prepared: { label: 'XML erstellt', cls: 'bg-foreground/70 text-background' },
submitted: { label: 'Übermittelt', cls: 'bg-foreground/70 text-background' },
accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' },
corrected: { label: 'Korrigiert', cls: 'bg-muted text-muted-foreground' },
@@ -100,6 +101,7 @@ const CATEGORY_LABELS: Record = {
export function AccountingView() {
const { data, loading, refetch } = useFetch('/api/aos/accounting')
const [submitting, setSubmitting] = useState(false)
+ const [preparedXml, setPreparedXml] = useState(null)
if (loading || !data) {
return (
@@ -114,15 +116,24 @@ export function AccountingView() {
const submitVat = async (vatId: string) => {
setSubmitting(true)
- const t = toast.loading('Voranmeldung wird an ELSTER übermittelt (ERiC)…')
+ setPreparedXml(null)
+ const t = toast.loading('UStVA-XML wird erstellt und validiert…')
try {
const r = await fetch(`/api/aos/accounting/vat/${vatId}/submit`, { method: 'PATCH' })
- if (!r.ok) {
- const err = await r.json().catch(() => ({}))
- throw new Error(err.error || 'submit failed')
- }
const res = await r.json()
- toast.success(`Akzeptiert — ELSTER-Ref: ${res.elsterRef}`, { id: t })
+ if (!r.ok) {
+ if (res.details && Array.isArray(res.details)) {
+ throw new Error('Validierung fehlgeschlagen: ' + res.details.join('; '))
+ }
+ throw new Error(res.error || 'submit failed')
+ }
+ if (res.transmitted === false) {
+ // Honest path: XML prepared, transmission requires ERiC certificate
+ setPreparedXml(res.xml)
+ toast.success(`UStVA-XML erstellt (${res.xmlSize} Bytes) — Übermittlung an ERiC ausstehend`, { id: t, duration: 6000 })
+ } else {
+ toast.success('An ELSTER übermittelt', { id: t })
+ }
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Übermittlung fehlgeschlagen', { id: t })
@@ -217,10 +228,10 @@ export function AccountingView() {
Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Übermittlung via ERiC-Schnittstelle. Vor dem Absenden: Vorprüfung jeder Kennzahl mit Herkunftsnachweis.
+
Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Tatsächliche Übermittlung via ERiC erfordert ERIC_CERT_PATH + ERIC_CERT_PIN (registriertes Steuerausweis-Zertifikat). Ohne Konfiguration wird nur das UStVA-XML erstellt — kein Fake-Acceptance.
+
+ {/* Prepared XML preview (real, not mock) */}
+ {preparedXml && (
+
+ Status: prepared — XML kann an Steuerberater (DATEV) oder manuell an das ELSTER-Portal übergeben werden. Kein automatischer Versand ohne ERiC-Zertifikat.
+