/**
* 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 }
}