UNPKG

mt940js

Version:

javascript mt940 bank statement parser

356 lines (330 loc) 14 kB
/** * MT940 parser class * @module lib/parser */ const Tags = require('./tags'); const helperModels = require('./helperModels'); const Field86Structure = require('./field86structure'); /** * Main parser class, parses input text (e.g. read from a file) into array of statements. * Each statement is validated for: all strictly required tags, * opening/closing balance currency is the same, opening balance + turnover = closing balance. * One input may return one or more statements (as array). Each statement contains transactions * array, where each contains data of tag 61 (and tag 86 for details). * <p>Output statement contains:</p> * @property {string} transactionReference - tag 20 reference * @property {string} relatedReference - tag 21 reference, optional * @property {string} accountIdentification - tag 25 own bank account identification * @property {string} number.statement - tag 28 main statement number * @property {string} number.sequence - tag 28 statement sub number (sequence) * @property {string} number.section - tag 28 statement sub sub number (present on some banks) * @property {Date} openingBalanceDate - tag 60 statement opening date * @property {Date} closingBalanceDate - tag 62 statement closing date * @property {Date} closingAvailableBalanceDate - closing available balance date (field 64) * @property {Date} forwardAvailableBalanceDate - forward available balance date (field 65) * @property {Date} statementDate - abstraction for statement date = `closingBalanceDate` * @property {string} currency - statement currency * @property {Number} openingBalance - beginning balance of the statement * @property {Number} closingBalance - ending balance of the statement * @property {Number} closingAvailableBalance - closing available balance (field 64) * @property {Number} forwardAvailableBalance - forward available balance (field 65) * @property {string} informationToAccountOwner - statement level additional details * @property {object} messageBlocks - statement message blocks, if present (EXPERIMENTAL) * * @property {array} transactions - collection of transactions * @property {Date} transaction.date - transaction date * @property {Number} transaction.amount - transaction amount (with sign, Credit+, Debit-) * @property {Boolean} transaction.isReversal - reversal transaction * @property {string} transaction.currency - transaction currency (copy of statement currency) * @property {string} transaction.details - content of relevant 86 tag(s), may be multiline (`\n` separated) * @property {string} transaction.transactionType - MT940 transaction type code (e.g. NTRF ...) * @property {string} transaction.reference - payment reference field * @property {Date} transaction.entryDate - optional, entry date field * @property {string} transaction.fundsCode - optional, funds code field * @property {string} transaction.bankReference - optional, bank reference * @property {string} transaction.extraDetails - optional, extra details (supplementary details) * @property {Object} transaction.structuredDetails - optional, if detected, parsed details in form of { subtag: value } * @property {string} transaction.nonSwift - optional, content of NS tags which happened in the context of transaction (after tags 61 or 86), can be multiline (separated by `\n`) * @example * const mt940parser = new Parser(); * const statements = parser.parse(fs.readFileSync(path, 'utf8')); * for (let i of statements) { * console.log(i.number.statement, i.statementDate); * for (let t of i.transactions) { * console.log(t.amount, t.currency); * } * } */ class Parser { /** * Constructor, params are given as object fields { no86Structure: true } * @constructor * @param {boolean} no86Structure - don't parse 86 field structure */ constructor ({ no86Structure } = {}) { this.params = { no86Structure, }; this.postParseMiddlewareStack = []; } /** * Parse text data into array of statements * @param {string} data - text unparsed bank statement in MT940 format * @param {boolean} withTags - tags will be copied to output statements in `tags` attribute for further analysis * @return {array} Array of statements @see class documentation for details */ parse(data, withTags = false) { const factory = new Tags.TagFactory(); const dataLines = this._splitAndNormalize(data); const tagLines = [...this._parseLines(dataLines)]; const tags = tagLines.map(i => factory.createTag(i.id, i.subId, i.data.join('\n'))); const tagGroups = this._groupTags(tags); const statements = tagGroups.map((grp, idx) => { this._validateGroup(grp, idx+1); return this._buildStatement(grp, withTags); }); for (let s of statements) { this._applyPostParseMiddlewares(s); } return statements; } /** * usePostParse - use middleware(s) after parsing, before result return * @param {function} fn - middleware fn(statement, next) */ usePostParse(fn) { if (typeof fn !== 'function') throw Error('middleware must be a function'); this.postParseMiddlewareStack.push(fn); } /** * _aaplyPostParse - internal apply post parse middlewares * @param {object} statement - statement to process */ _applyPostParseMiddlewares(statement) { if (this.postParseMiddlewareStack.length === 0) return; const chainFn = this.postParseMiddlewareStack .reverse() .reduce((next, fn) => fn.bind(null, statement, next), () => {}); chainFn(statement); } /** * Split text into lines, replace clutter, remove empty lines ... * @private */ _splitAndNormalize(data) { return data .split(/\r?\n/) .filter(line => !!line && line !== '-'); } /** * Convert lines into separate tags * @private */ *_parseLines(lines) { const reTag = /^:([0-9]{2}|NS)([A-Z])?:/; let tag = null; for (let i of lines) { // Detect new tag start const match = i.match(reTag); if (match || i.startsWith('-}') || i.startsWith('{')) { if (tag) yield tag; // Yield previous tag = match // Start new tag ? { id: match[1], subId: match[2] || '', data: [i.substr(match[0].length)] } : { id: 'MB', subId: '', data: [i.trim()], }; } else { // Add a line to previous tag tag.data.push(i); } } if (tag) { yield tag } // Yield last } /** * Group tags into statements * @private */ _groupTags(tags) { if (tags.length === 0) return []; const hasMessageBlocks = (tags[0] instanceof Tags.TagMessageBlock); const groups = []; let curGroup; for (let i of tags) { if (hasMessageBlocks && i instanceof Tags.TagMessageBlock && i.isStarting || !hasMessageBlocks && i instanceof Tags.TagTransactionReferenceNumber) { groups.push(curGroup = []); // Statement starting tag -> start new group } curGroup.push(i); } return groups; } /** * Validate group of tags (required tags present, currency is consistent, consistent balances vs turnover) * @private */ _validateGroup(group, idx) { // Check mandatory tags const mandatoryTags = [ Tags.TagTransactionReferenceNumber, //20 Tags.TagAccountIdentification, //25 Tags.TagStatementNumber, //28 Tags.TagOpeningBalance, //60 Tags.TagClosingBalance //62 ]; for (let Tag of mandatoryTags) { if (!group.find(t => t instanceof Tag)) { throw Error(`Mandatory tag ${Tag.ID} is missing in group ${idx}`); } } // Check same currency let currency = ''; for (let i of group.filter(i => i instanceof Tags.TagBalance)) { if (!currency) { currency = i.fields.currency; } else if (currency !== i.fields.currency) { throw Error(`Currency markers are differ [${currency}, ${i.fields.currency}] in group ${idx}`); } } // Check turnover const ob = group.find(i => i instanceof Tags.TagOpeningBalance); const cb = group.find(i => i instanceof Tags.TagClosingBalance); const turnover = cb.fields.amount - ob.fields.amount; const sumLines = group .filter(i => i instanceof Tags.TagStatementLine) .reduce((prev, cur) => prev + cur.fields.amount, 0.0); if (!helperModels.Amount.isEqual(sumLines, turnover)) { throw Error(`Sum of lines (${sumLines}) != turnover (${turnover}) in group ${idx}`); } } /** * Build statement objects * @private */ _buildStatement(group, withTags) { let statement = { transactionReference: '', relatedReference: '', accountIdentification: '', number: { statement: '', sequence: '', section: '' }, statementDate: null, openingBalanceDate: null, closingBalanceDate: null, currency: '', openingBalance: 0.0, closingBalance: 0.0, transactions: [], closingAvailableBalanceDate: null, forwardAvailableBalanceDate: null, closingAvailableBalance: 0.0, forwardAvailableBalance: 0.0, }; let prevTag; for (let tag of group) { if (tag instanceof Tags.TagMessageBlock) { if (!statement.messageBlocks) statement.messageBlocks = {}; for (let [key, value] of Object.entries(tag.fields)) { if (!value || key === 'EOB') continue; statement.messageBlocks[key] = { value }; } } if (tag instanceof Tags.TagTransactionReferenceNumber) { statement.transactionReference = tag.fields.transactionReference; } if (tag instanceof Tags.TagRelatedReference) { statement.relatedReference = tag.fields.relatedReference; } if (tag instanceof Tags.TagAccountIdentification) { statement.accountIdentification = tag.fields.accountIdentification; } if (tag instanceof Tags.TagStatementNumber) { statement.number.statement = tag.fields.statementNumber; statement.number.sequence = tag.fields.sequenceNumber; statement.number.section = tag.fields.sectionNumber; } if (tag instanceof Tags.TagOpeningBalance) { statement.openingBalanceDate = tag.fields.date; statement.openingBalance = tag.fields.amount; statement.currency = tag.fields.currency; } if (tag instanceof Tags.TagClosingBalance) { statement.closingBalanceDate = tag.fields.date; statement.statementDate = tag.fields.date; statement.closingBalance = tag.fields.amount; } if (tag instanceof Tags.TagClosingAvailableBalance) { statement.closingAvailableBalanceDate = tag.fields.date; statement.closingAvailableBalance = tag.fields.amount; } if (tag instanceof Tags.TagForwardAvailableBalance) { statement.forwardAvailableBalanceDate = tag.fields.date; statement.forwardAvailableBalance = tag.fields.amount; } if (tag instanceof Tags.TagStatementLine) { statement.transactions.push(Object.assign({}, tag.fields, { currency: statement.currency, details: '' } )); } if (tag instanceof Tags.TagTransactionDetails) { if (prevTag instanceof Tags.TagStatementLine) { let t = statement.transactions[statement.transactions.length - 1]; t.details += (t.details && '\n') + tag.fields.transactionDetails; } else { if (!statement.informationToAccountOwner) statement.informationToAccountOwner = ''; else statement.informationToAccountOwner += '\n'; statement.informationToAccountOwner += tag.fields.transactionDetails; } } if (tag instanceof Tags.TagNonSwift) { if (prevTag instanceof Tags.TagStatementLine || prevTag instanceof Tags.TagTransactionDetails) { let t = statement.transactions[statement.transactions.length - 1]; t.nonSwift = tag.data; } } if (!(tag instanceof Tags.TagNonSwift)) prevTag = tag; } for (let [messageId, message] of Object.entries(statement.messageBlocks || {})) { // EXPERIMENTAL, subject for change !!! const fields = this._parseMessageBlockFields(messageId, message.value); if (fields) message.fields = fields; } if (!this.params.no86Structure) { for (let t of statement.transactions) { let structuredDetails = Field86Structure.parse(t.details); if (structuredDetails) t.structuredDetails = structuredDetails; } } if (withTags) { statement.tags = group } // preserve tags if (!statement.closingAvailableBalanceDate) { statement.closingAvailableBalanceDate = new Date(statement.closingBalanceDate); statement.closingAvailableBalance = statement.closingBalance; } if (!statement.forwardAvailableBalanceDate) { statement.forwardAvailableBalanceDate = new Date(statement.closingAvailableBalanceDate); statement.forwardAvailableBalance = statement.closingAvailableBalance; } return statement; } /** * parses message blocks * @private */ _parseMessageBlockFields(messageId, value) { //eslint-disable-line no-unused-vars // TODO somethings like: // messageBlockFactory.createTag(messageId, value) // returns { field1: '', field2: '', subTags: { 108: { fieldA: '', fieldB: '' } } } } } module.exports = Parser;