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]*?)`)) 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}) { 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, })), }) }