UNPKG

statement-parser-fab

Version:

Parse bank and credit card statements. Updated fork with FAB (First Abu Dhabi Bank) support and maintained dependencies.

190 lines (189 loc) 8.18 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.citiCostcoVisaCreditCardParser = void 0; const augment_vir_1 = require("augment-vir"); const pdf_text_reader_1 = require("pdf-text-reader"); const date_1 = require("../../augments/date"); const read_pdf_1 = require("../../pdf/read-pdf"); const statement_parser_1 = require("../statement-parser"); var State; (function (State) { State["Header"] = "header"; State["Payment"] = "payment"; State["Purchase"] = "purchase"; State["PurchaseFiller"] = "purchase filler"; State["End"] = "end"; })(State || (State = {})); var ParsingTriggers; (function (ParsingTriggers) { ParsingTriggers["BillingPeriod"] = "billing period:"; ParsingTriggers["AccountNumber"] = "account number ending in:"; ParsingTriggers["Payments"] = "payments, credits and adjustments"; ParsingTriggers["Purchases"] = "standard purchases"; ParsingTriggers["AccountSummary"] = "Account Summary"; })(ParsingTriggers || (ParsingTriggers = {})); const billingPeriodRegExp = new RegExp(`^\\s*${ParsingTriggers.BillingPeriod}\\s+(\\d{1,2}/\\d{1,2}/\\d{1,2})-(\\d{1,2}/\\d{1,2}/\\d{1,2})\\s*$`, 'i'); const accountNumberRegExp = new RegExp(`${ParsingTriggers.AccountNumber}\\s+(\\S+)\\s*$`, 'i'); exports.citiCostcoVisaCreditCardParser = (0, statement_parser_1.createStatementParser)({ action: performStateAction, next: nextState, initialState: State.Header, endState: State.End, parserKeywords: (0, augment_vir_1.getEnumTypedValues)(ParsingTriggers), pdfProcessing: readCitiCostcoVisaPdf, outputValidation: outputValidation, }); async function readCitiCostcoVisaPdf(path) { const doc = await (0, read_pdf_1.getPdfDocument)(path); const pageCount = doc.numPages; let pages = []; /** * The costco card has a right column with costco rewards information that totally screws up the * parsing of actual transactions and payments. Here, we find where that column is so that it * can be removed. */ const firstPageItems = (await (await doc.getPage(1)).getTextContent()).items; const rightColumnItem = firstPageItems.find((item) => 'str' in item && item.str === ParsingTriggers.AccountSummary); if (!rightColumnItem) { throw new Error('Could not find right column.'); } const columnX = Math.floor('transform' in rightColumnItem && rightColumnItem.transform[4]); for (let i = 0; i < pageCount; i++) { const pageItems = (await (await doc.getPage(i + 1)).getTextContent()).items; const filteredItems = pageItems.filter((item) => { if (!('str' in item)) { return false; } // filter out the right column const beforeColumn = item.transform[4] < columnX; const justSpaces = item.str.match(/^\s+$/); return !justSpaces && beforeColumn; }); pages.push((0, pdf_text_reader_1.parsePageItems)(filteredItems).lines); pages = pages.concat(); } return pages; } function outputValidation(output) { // Verifying that the "lineParse as BaseTransaction" assumption below is true output.incomes.forEach((income) => { if (income.amount === undefined) { throw new Error(`Invalid amount for income transaction: ${income}`); } }); output.expenses.forEach((expense) => { if (expense.amount === undefined) { throw new Error(`Invalid amount for expense transaction: ${expense}`); } }); } const amountRegExp = /^-?\$([\d,\.]+)\s*$/i; function parseAmount(input, negate) { const [, amountMatch,] = (0, augment_vir_1.safeMatch)(input, amountRegExp); if (amountMatch) { const amount = Number((0, augment_vir_1.removeCommasFromNumberString)(amountMatch)); let multiplier = negate ? -1 : 1; if (input[0] === '-') { multiplier *= -1; } return amount * multiplier; } else { throw new Error(`Failed to parse a dollar amount: "${input}"`); } } function parseTransactionLine(line, output, negate) { if (!output.startDate || !output.endDate) { throw new Error(`Tried to parse a transaction but no start date (${output.startDate}) or end date (${output.endDate}) were found yet`); } const [, monthString, dayString, description, amountString,] = (0, augment_vir_1.safeMatch)(line, /(?:\d{1,2}\/\d{1,2}\s*)?(\d{1,2})\/(\d{1,2})\s+(\S.+)\s+(-?\$[\d\.,]+)?\s*$/i); if (description) { const transaction = { date: (0, date_1.dateWithinRange)(output.startDate, output.endDate, Number(monthString), Number(dayString)), amount: undefined, description: (0, augment_vir_1.collapseSpaces)(description), originalText: [line], }; if (amountString) { transaction.amount = parseAmount(amountString, negate); } return transaction; } else { const amountMatch = line.match(amountRegExp); if (amountMatch) { return parseAmount(line, negate); } else { return (0, augment_vir_1.collapseSpaces)(line); } } } function performStateAction(currentState, line, output, parserOptions) { if (currentState === State.Header) { const [, startDateString, endDateString,] = (0, augment_vir_1.safeMatch)(line, billingPeriodRegExp); const [, accountSuffixString,] = (0, augment_vir_1.safeMatch)(line, accountNumberRegExp); if (startDateString && endDateString) { output.startDate = (0, augment_vir_1.createDateFromSlashFormat)(startDateString, parserOptions.yearPrefix); output.endDate = (0, augment_vir_1.createDateFromSlashFormat)(endDateString, parserOptions.yearPrefix); } else if (accountSuffixString) { output.accountSuffix = accountSuffixString; } } else if (line !== '' && (currentState === State.Purchase || currentState === State.Payment)) { const array = currentState === State.Purchase ? output.expenses : output.incomes; const lineParse = parseTransactionLine(line, output, currentState === State.Payment); const lastTransaction = array[array.length - 1]; if (typeof lineParse === 'string' && lastTransaction) { lastTransaction.description += '\n' + lineParse; lastTransaction.originalText.push(line); } else if (typeof lineParse === 'number' && lastTransaction) { lastTransaction.amount = lineParse; lastTransaction.originalText.push(line); } else { // because a transaction's amount may not be on its first line, we must make sure we actually got the amount // before moving onto the next transaction if (lastTransaction && lastTransaction.amount === undefined) { throw new Error(`Moving onto next transaction but last one's amount is still undefined. last transaction: ${lastTransaction} current line: "${line}"`); } // This assumption is not always true! However, it should become true later. // It must be verified later that it indeed did come true. array.push(lineParse); } } return output; } function nextState(currentState, line) { line = line.toLowerCase(); switch (currentState) { case State.Header: if (line === ParsingTriggers.Payments) { return State.Payment; } else if (line === ParsingTriggers.Purchases) { return State.Purchase; } break; case State.Payment: if (line === '') { return State.PurchaseFiller; } break; case State.PurchaseFiller: if (line === ParsingTriggers.Purchases) { return State.Purchase; } break; case State.Purchase: if (line === '') { return State.End; } break; } return currentState; }