156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
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,
|
|
})),
|
|
})
|
|
}
|