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
JavaScript
;
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;
}