/** * CAMT file parser for the manual bank-statement import (online-banking export). * * Input: the uploaded files (a ZIP of XML pages, or plain XML). Output: per * account (IBAN) the booked entries + every balance found, and — via * `buildDailyStatements()` — ONE statement per booking day with a derived * opening/closing balance chain. * * Why per day: the Volksbank online export is ONE report paginated over the * whole period (page 1 OPBD … CLBD/INTM, page 2 OPBD/INTM … CLBD), not one * report per day like the EBICS camt.053 feed. Splitting by booking day gives * the same statement shape EBICS produces. The derived chain was verified * against the MT940 export of the same period (all daily `:62F:` closings * matched exactly). * * Field mapping mirrors the Laravel EBICS parser * (`ProcessEbicsStatementRepository::parseFiles()`), so file-imported lines look * identical to EBICS-imported ones — notably `AcctSvcrRef` → `bankReference`, * which is the duplicate-detection key (`C_BankStatementLine.EftTrxID`). * * Accepts camt.052 (`BkToCstmrAcctRpt/Rpt`) and camt.053 (`BkToCstmrStmt/Stmt`) * in any `.001.xx` version — no XSD validation, tolerant field access. Amounts * are handled as integer cents to keep the balance chain exact. */ import { unzipSync } from 'fflate' import { XMLParser } from 'fast-xml-parser' export interface CamtEntry { file: string bookingDate: string valueDate: string | null amountCents: number amount: number currency: string bankReference: string | null endToEndId: string | null mandateId: string | null counterpartName: string | null counterpartIban: string | null remittanceInfo: string | null transactionCode: string | null additionalInfo: string | null isReversal: boolean batchCount: number } export interface CamtBalance { file: string type: string // OPBD | CLBD | PRCD | ITBD | CLAV | … interim: boolean // SubTp INTM (page balance of a paginated report) amountCents: number currency: string date: string } export interface CamtAccount { iban: string currency: string ownerName: string bankName: string entries: CamtEntry[] balances: CamtBalance[] skipped: { pending: number; duplicateInUpload: number } } export interface CamtParseResult { files: string[] errors: { file: string; error: string }[] accounts: CamtAccount[] } export interface CamtDay { date: string openingCents: number | null closingCents: number | null netCents: number entries: CamtEntry[] } export interface CamtBalanceCheck { anchored: boolean anchor: { type: string; date: string; amountCents: number } | null ok: boolean diffs: { type: string; date: string; expectedCents: number; computedCents: number; diffCents: number; file: string }[] } const ARRAY_TAGS = new Set(['Rpt', 'Stmt', 'Ntfctn', 'Bal', 'Ntry', 'NtryDtls', 'TxDtls', 'Ustrd', 'Strd']) const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, // Keep every value a string: AcctSvcrRef / EndToEndId are long digit strings // that must never be coerced to (lossy) numbers. parseTagValue: false, parseAttributeValue: false, trimValues: true, isArray: (name: string) => ARRAY_TAGS.has(name) }) const text = (node: any): string => { if (node === null || node === undefined) return '' if (typeof node === 'object') return String(node['#text'] ?? '').trim() return String(node).trim() } const normIban = (value: any): string => String(value || '').replace(/\s+/g, '').toUpperCase() /** `"258.66"` → 25866 (integer cents, no float drift). */ export const toCents = (value: any): number => { const str = text(value).replace(',', '.') if (!str) return 0 const negative = str.startsWith('-') const [whole, frac = ''] = str.replace(/^[-+]/, '').split('.') const cents = Number(whole || 0) * 100 + Number((frac + '00').slice(0, 2)) return negative ? -cents : cents } export const centsToAmount = (cents: number | null): number | null => { return cents === null || cents === undefined ? null : Math.round(cents) / 100 } /** ISO date from `
` or `` (date part only). */ const isoDate = (node: any): string | null => { if (!node) return null const raw = text(node.Dt) || text(node.DtTm) const match = raw.match(/^(\d{4}-\d{2}-\d{2})/) return match ? match[1] : null } /** Decode by the XML declaration — the Volksbank export is ISO-8859-1. */ const decodeXml = (buffer: Uint8Array): string => { const head = Buffer.from(buffer.slice(0, 200)).toString('latin1') const declared = (head.match(/encoding\s*=\s*["']([^"']+)["']/i)?.[1] || 'utf-8').toLowerCase() let label = 'utf-8' if (/8859-1|latin-?1|windows-1252|cp1252/.test(declared)) label = 'windows-1252' else if (/8859-15/.test(declared)) label = 'iso-8859-15' let xml = new TextDecoder(label).decode(buffer) if (xml.charCodeAt(0) === 0xFEFF) xml = xml.slice(1) return xml } const isZip = (buffer: Uint8Array): boolean => buffer.length > 3 && buffer[0] === 0x50 && buffer[1] === 0x4B /** Content sniff — some exports name their members without a `.xml` extension. */ const looksLikeXml = (buffer: Uint8Array): boolean => { const head = Buffer.from(buffer.slice(0, 120)).toString('latin1').replace(/^|^\xEF\xBB\xBF/, '').trimStart() return head.startsWith(' { const out: { name: string; data: Uint8Array }[] = [] const unpack = (zipName: string, data: Uint8Array, depth: number): number => { let found = 0 let members: Record try { members = unzipSync(data) } catch (err: any) { errors.push({ file: zipName, error: 'ZIP konnte nicht entpackt werden: ' + (err?.message || err) }) return -1 } for (const memberPath of Object.keys(members).sort()) { const base = memberPath.split('/').pop() || '' if (!base || memberPath.endsWith('/') || memberPath.startsWith('__MACOSX/') || base.startsWith('._') || base === '.DS_Store') continue const bytes = members[memberPath] if (!bytes?.length) continue if (isZip(bytes)) { if (depth < MAX_ZIP_DEPTH) found += Math.max(0, unpack(base, bytes, depth + 1)) } else if (/\.xml$/i.test(base) || looksLikeXml(bytes)) { out.push({ name: base, data: bytes }) found++ } } return found } for (const upload of uploads) { if (isZip(upload.data)) { if (unpack(upload.name, upload.data, 1) === 0) errors.push({ file: upload.name, error: 'ZIP enthält keine XML-Datei' }) } else { out.push(upload) } } return out } /** Counterpart = the OTHER side: Debtor on credits, Creditor on debits. */ const readCounterpart = (details: any, isCredit: boolean) => { const parties = details?.RltdPties || {} const pick = (party: any, account: any) => { // v08 nests the name under ; v02 has directly. const name = text(party?.Pty?.Nm) || text(party?.Nm) const iban = normIban(text(account?.Id?.IBAN) || text(account?.Id?.Othr?.Id)) return (name || iban) ? { name: name || null, iban: iban || null } : null } const debtor = pick(parties.Dbtr, parties.DbtrAcct) const creditor = pick(parties.Cdtr, parties.CdtrAcct) return (isCredit ? debtor : creditor) || (isCredit ? creditor : debtor) || { name: null, iban: null } } const readRemittance = (details: any): string | null => { const info = details?.RmtInf if (!info) return null const blocks = (info.Ustrd || []).map((b: any) => text(b)).filter(Boolean) if (!blocks.length) { for (const strd of (info.Strd || [])) { const ref = text(strd?.CdtrRefInf?.Ref) if (ref) blocks.push(ref) const extra = text(strd?.AddtlRmtInf) if (extra) blocks.push(extra) } } const joined = blocks.join(' ').replace(/\s+/g, ' ').trim() return joined || null } const readTransactionCode = (node: any): string | null => { const domain = node?.BkTxCd?.Domn if (!domain) return text(node?.BkTxCd?.Prtry?.Cd) || null const parts = [text(domain.Cd), text(domain.Fmly?.Cd), text(domain.Fmly?.SubFmlyCd)].filter(Boolean) return parts.join('/') || null } const readEntry = (entry: any, file: string, accountCurrency: string): CamtEntry | 'pending' | null => { // v08: BOOK — v02: BOOK. Only booked entries are real. const status = (text(entry.Sts?.Cd) || text(entry.Sts) || 'BOOK').toUpperCase() if (status !== 'BOOK') return 'pending' const bookingDate = isoDate(entry.BookgDt) || isoDate(entry.ValDt) if (!bookingDate) return null const isCredit = text(entry.CdtDbtInd).toUpperCase() !== 'DBIT' const cents = toCents(entry.Amt) const amountCents = isCredit ? cents : -cents const allDetails: any[] = [] for (const block of (entry.NtryDtls || [])) allDetails.push(...(block.TxDtls || [])) // First TxDtls only — same behaviour as the EBICS import (one line per entry). const details = allDetails[0] || null const endToEndId = text(details?.Refs?.EndToEndId) const counterpart = readCounterpart(details, isCredit) return { file, bookingDate, valueDate: isoDate(entry.ValDt), amountCents, amount: amountCents / 100, currency: String(entry.Amt?.['@_Ccy'] || accountCurrency || 'EUR').toUpperCase(), bankReference: text(entry.AcctSvcrRef) || text(details?.Refs?.AcctSvcrRef) || null, endToEndId: (!endToEndId || endToEndId.toUpperCase() === 'NOTPROVIDED') ? null : endToEndId, mandateId: text(details?.Refs?.MndtId) || null, counterpartName: counterpart.name, counterpartIban: counterpart.iban, remittanceInfo: readRemittance(details), transactionCode: readTransactionCode(entry), additionalInfo: text(entry.AddtlNtryInf) || null, isReversal: text(entry.RvslInd).toLowerCase() === 'true', batchCount: allDetails.length } } /** * Parse uploaded CAMT files. Never throws for a bad file — problems land in * `errors[]` so the preview can show them next to the good files. */ export const parseCamtUploads = (uploads: { name: string; data: Uint8Array }[]): CamtParseResult => { const errors: CamtParseResult['errors'] = [] const xmlFiles = expandUploads(uploads, errors) const accounts = new Map() const seen = new Map>() for (const xmlFile of xmlFiles) { let records: any[] = [] try { const doc = xmlParser.parse(decodeXml(xmlFile.data))?.Document const root = doc?.BkToCstmrAcctRpt || doc?.BkToCstmrStmt if (!root) { errors.push({ file: xmlFile.name, error: 'Keine CAMT.052/053-Datei (BkToCstmrAcctRpt/BkToCstmrStmt fehlt)' }) continue } records = [...(root.Rpt || []), ...(root.Stmt || [])] } catch (err: any) { errors.push({ file: xmlFile.name, error: 'XML nicht lesbar: ' + (err?.message || err) }) continue } for (const record of records) { const iban = normIban(text(record.Acct?.Id?.IBAN) || text(record.Acct?.Id?.Othr?.Id)) if (!iban) { errors.push({ file: xmlFile.name, error: 'Report ohne Konto-IBAN übersprungen' }) continue } const currency = (text(record.Acct?.Ccy) || 'EUR').toUpperCase() if (!accounts.has(iban)) { accounts.set(iban, { iban, currency, ownerName: text(record.Acct?.Ownr?.Nm), bankName: text(record.Acct?.Svcr?.FinInstnId?.Nm), entries: [], balances: [], skipped: { pending: 0, duplicateInUpload: 0 } }) seen.set(iban, new Set()) } const account = accounts.get(iban)! const seenKeys = seen.get(iban)! for (const bal of (record.Bal || [])) { const date = isoDate(bal.Dt) if (!date) continue const cents = toCents(bal.Amt) account.balances.push({ file: xmlFile.name, type: text(bal.Tp?.CdOrPrtry?.Cd).toUpperCase(), interim: text(bal.Tp?.SubTp?.Cd).toUpperCase() === 'INTM', amountCents: text(bal.CdtDbtInd).toUpperCase() === 'DBIT' ? -cents : cents, currency: String(bal.Amt?.['@_Ccy'] || currency).toUpperCase(), date }) } // Overlapping exports (two files covering the same days) must not double // the entries. With a bank reference that is exact; without one the n-th // identical booking WITHIN a file is a legit repeat, the same n-th // occurrence in ANOTHER file is the overlap. const occurrence = new Map() for (const raw of (record.Ntry || [])) { const entry = readEntry(raw, xmlFile.name, currency) if (entry === 'pending') { account.skipped.pending++; continue } if (!entry) continue let key: string if (entry.bankReference) { key = 'ref|' + entry.bankReference } else { const base = [entry.bookingDate, entry.amountCents, entry.remittanceInfo || '', entry.counterpartIban || ''].join('|') const n = (occurrence.get(base) || 0) + 1 occurrence.set(base, n) key = 'raw|' + base + '|' + n } if (seenKeys.has(key)) { account.skipped.duplicateInUpload++; continue } seenKeys.add(key) account.entries.push(entry) } } } for (const account of accounts.values()) { // Stable: booking date, then original file/document order (Array.sort is stable). account.entries.sort((a, b) => a.bookingDate.localeCompare(b.bookingDate)) } return { files: xmlFiles.map((f) => f.name), errors, accounts: Array.from(accounts.values()) } } /** * One statement per booking day + the derived balance chain. * * Balance semantics (ISO 20022 / DK): `OPBD` dated D = booked balance at the * START of day D; `PRCD` dated D = closing of the previous booking day D; * `CLBD` dated D = booked balance at the END of day D. Every `OPBD`/`PRCD` * (re-)anchors the chain — so two exports with a gap in between still get * correct day balances — and every balance is also CHECKED against the chain * computed so far (`diffs`). Only EUR entries move the chain. * * `fallbackOpeningCents` anchors the chain when the files carry no opening * balance at all (the caller derives it from iDempiere). */ export const buildDailyStatements = ( account: CamtAccount, fallbackOpeningCents: number | null = null ): { days: CamtDay[]; check: CamtBalanceCheck } => { const byDay = new Map() for (const entry of account.entries) { if (!byDay.has(entry.bookingDate)) byDay.set(entry.bookingDate, []) byDay.get(entry.bookingDate)!.push(entry) } const dates = Array.from(byDay.keys()).sort() const eurEntries = account.entries.filter((e) => e.currency === 'EUR') const sumWhere = (test: (date: string) => boolean): number => eurEntries.filter((e) => test(e.bookingDate)).reduce((sum, e) => sum + e.amountCents, 0) const balances = account.balances.filter((b) => b.currency === 'EUR') const openings = balances.filter((b) => b.type === 'OPBD' || b.type === 'PRCD') const closings = balances.filter((b) => b.type === 'CLBD') const check: CamtBalanceCheck = { anchored: false, anchor: null, ok: true, diffs: [] } const addDiff = (bal: CamtBalance, computedCents: number) => { const diffCents = computedCents - bal.amountCents if (diffCents !== 0) { check.ok = false check.diffs.push({ type: bal.type + (bal.interim ? '/INTM' : ''), date: bal.date, expectedCents: bal.amountCents, computedCents, diffCents, file: bal.file }) } } // Opening of the FIRST day: derived from the earliest stated balance of any // kind (walking back over the entries before it), else the caller's fallback. let running: number | null = null const firstDate = dates[0] if (firstDate) { const earliest = [...openings, ...closings].sort((a, b) => a.date.localeCompare(b.date) || (a.type === 'CLBD' ? 1 : -1))[0] if (earliest) { // OPBD D = balance before the bookings of D; PRCD/CLBD D = balance after them. const before = earliest.type === 'OPBD' ? sumWhere((d) => d < earliest.date) : sumWhere((d) => d <= earliest.date) running = earliest.amountCents - before check.anchored = true check.anchor = { type: earliest.type, date: earliest.date, amountCents: earliest.amountCents } } else if (fallbackOpeningCents !== null) { running = fallbackOpeningCents check.anchored = true check.anchor = { type: 'idempiere', date: firstDate, amountCents: fallbackOpeningCents } } } const days: CamtDay[] = [] let previousDate = '' dates.forEach((date, index) => { // A stated opening for this day: OPBD dated in (previousDate, date] or PRCD // dated in [previousDate, date). It is CHECKED against the chain and then // RE-ANCHORS it — so two exports with a gap between them stay correct. const stated = openings .filter((b) => (b.type === 'OPBD' ? (b.date > previousDate && b.date <= date) : (b.date >= previousDate && b.date < date))) .sort((a, b) => b.date.localeCompare(a.date))[0] if (stated && running !== null) { addDiff(stated, running) running = stated.amountCents } const entries = byDay.get(date)! const netCents = entries.filter((e) => e.currency === 'EUR').reduce((sum, e) => sum + e.amountCents, 0) const openingCents = running const closingCents = running === null ? null : running + netCents days.push({ date, openingCents, closingCents, netCents, entries }) running = closingCents // CLBD dated on this day, or on a bookingless day before the next one. if (closingCents !== null) { const nextDate = dates[index + 1] || '9999-12-31' for (const bal of closings.filter((b) => b.date >= date && b.date < nextDate)) addDiff(bal, closingCents) } previousDate = date }) if (!check.anchored) check.ok = false return { days, check } }