c2a5e509-7a51-4eb1-8d84-a68d55cde77a

This commit is contained in:
Z User committed 2026-10-26 16:24:56 +00:00
1 parent 85c4006803
commit 9c58e29472
11 files changed
+493 -44

No files matched your search

@@ -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 <Ntry>...</Ntry> block
const entryRegex = /<Ntry>([\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 <Amt Ccy="EUR"> 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 <BookgDt><Dt>2026-10-20</Dt></BookgDt>, 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 <Ntry> 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,
})),
})
}
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<Elster xmlns="http://www.elster.de/2002/ustva">
<TransferHeader version="9">
<Erstellungsdatum>${new Date().toISOString()}</Erstellungsdatum>
<DatenLieferant>Nordlicht Digital GmbH</DatenLieferant>
</TransferHeader>
<DatenTeil>
<Nutzer>
<Steuernummer>048/221/30291</Steuernummer>
<Name>Nordlicht Digital GmbH</Name>
<Strasse>Mühlendamm 14</Strasse>
<PLZ>23552</PLZ>
<Ort>Lübeck</Ort>
</Nutzer>
<Steuerfall Art="UStVA">
<Jahr>${vat.periodYear}</Jahr>
<Zeitraum>${String(vat.periodMonth).padStart(2, '0')}</Zeitraum>
<Kennzahlen>
<KZ nr="81">${(vat.k81StandardNetCents / 100).toFixed(2)}</KZ>
<KZ nr="86">${(vat.k86ReducedNetCents / 100).toFixed(2)}</KZ>
<KZ nr="66">${(vat.k66TaxFreeCents / 100).toFixed(2)}</KZ>
<KZ nr="83">${(vat.k83TaxStandardCents / 100).toFixed(2)}</KZ>
<KZ nr="65">${(vat.k65TaxReducedCents / 100).toFixed(2)}</KZ>
<KZ nr="37">${(vat.k37InputTaxCents / 100).toFixed(2)}</KZ>
</Kennzahlen>
<Berechnung>
<Umsatzsteuer>${(vat.outputTaxCents / 100).toFixed(2)}</Umsatzsteuer>
<Vorsteuer>${(vat.inputTaxCents / 100).toFixed(2)}</Vorsteuer>
<Zahllast>${(vat.payableCents / 100).toFixed(2)}</Zahllast>
</Berechnung>
</Steuerfall>
</DatenTeil>
</Elster>`
// --- 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.',
})
}
+53 -6
View File
@@ -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,
})
}
+78 -9
View File
@@ -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<MailTestResult> {
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(),
})
}