Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support of multi accounts files #10 #64

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: Node.js CI

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [10.x, 12.x, 14.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build --if-present
- run: npm test
12 changes: 0 additions & 12 deletions .travis.yml

This file was deleted.

8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ qif2json.parseFile(filePath, options, function(err, qifData){
});
```

If installed globally, the `qif2json` command can also be used with an input file and the output JSON will be pretty-printed to the console
If installed globally, the `qif2json` command can also be used with an input file, and the output JSON will be pretty-printed to the console

## Options

* `dateFormat` - The format of dates within the file. The `fetcha` module is used for parsing them into Date objects. See https://www.npmjs.com/package/fecha#formatting-tokens for available formatting tokens. The special format `"us"` will use us-format MM/DD/YYYY dates. Dates are normalised before parsing so `/`, `'` become `-` and spaces are removed. On the commandline you can specify multiple date formats comma-delimited.
* `encoding` - Package try detect encoding, but if want to override it, use this option.

## Contributing
Take care to maintain the existing coding style. Add unit tests for any new or changed functionality. Lint and test your code using `npm test`.
Expand All @@ -38,6 +39,9 @@ Take care to maintain the existing coding style. Add unit tests for any new or c
* 0.1.0 Support for Money 97 and "partial" transactions
* 0.1.1 Installs on node 0.12 and iojs 1.6
* 0.2.0 Added normalTransactions.qif example file + tests

* 0.3.0 Add description to partial transactions
* 0.3.1 Dependencies and docs updated
* 0.4.0 Added date formatting options
* 0.4.1 Support for multi accounts QIF files
## License
Licensed under the MIT license.
139 changes: 102 additions & 37 deletions lib/qif2json.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@
* Copyright (c) 2012 Steve Mason
* Licensed under the MIT license.
*/


const fs = require('fs');
const jschardet = require('jschardet');
const Iconv = require('iconv-lite');
const fecha = require('fecha');
const debug = require('debug')('qif2json');
const { TYPES } = require('./types');

const US_DATE_FORMATS = ['MM-DD-YYYYHH:mm:ss', 'MM-DD-YYYY', 'MM-DD-YY'];
const UK_DATE_FORMATS = ['DD-MM-YYYYHH:mm:ss', 'DD-MM-YYYY', 'DD-MM-YY'];

function parseDate(dateStr, formats) {
if (formats === 'us') {
if (formats === 'us' || dateStr.includes("'")) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems a bit risky to say that any dateStr that includes a ' is a US date format

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any better proposition? This code do not breaks compatibility but is open also for other date format, that I am using.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per #26 you can pass in a dateFormat option to control how the dates are parsed. us is only supported for backward compatibility

formats = US_DATE_FORMATS;
}
if (!formats) {
Expand All @@ -28,13 +27,12 @@ function parseDate(dateStr, formats) {
let str = dateStr.replace(/ /g, '');
str = str.replace(/\//g, '-');
str = str.replace(/'/g, '-');
str = str.replace(/(^|[^0-9])([0-9])([^0-9]|$)/g, '$10$2$3');
str = str.split(/\s*-\s*/g).map((s) => s.padStart(2, '0')).join('-');
debug(`input date ${dateStr} became ${str}`);

while (formats.length) {
const format = formats.shift();
const formatted = fecha.parse(str, format);

if (formatted) {
debug(`input date ${str} parses correctly with ${format}`);
return fecha.format(formatted, 'YYYY-MM-DDTHH:mm:ss');
Expand All @@ -44,63 +42,111 @@ function parseDate(dateStr, formats) {
return `<invalid date:"${dateStr}">`;
}

function appendEntity(data, type, entity, currentBankName, isMultiAccount) {
if (isMultiAccount && currentBankName && type.list_name === 'transactions') {
entity.account = currentBankName;
}
if (!isMultiAccount && type.list_name === 'accounts') {
data.type = entity.type.replace('Type:', '');
return data;
}

if (type.list_name === 'accounts'
&& Object.hasOwnProperty.call(data, 'accounts')
&& data.accounts.find((a) => a.name === entity.name)
) {
return data; // skip duplicates
}

if (Object.hasOwnProperty.call(data, type.list_name)) {
data[type.list_name].push(entity);
} else {
data[type.list_name] = [entity];
}

return data;
}

function clean(line) {
line = line.trim();
if (line.charCodeAt(0) === 239 && line.charCodeAt(1) === 187 && line.charCodeAt(2) === 191) {
line = line.substring(3);
}
return line;
}

exports.parse = function parse(qif, options) {
/* eslint no-multi-assign: "off", no-param-reassign: "off", no-cond-assign: "off",
no-continue: "off", prefer-destructuring: "off", no-case-declarations: "off" */
const lines = qif.split('\n');
let line = lines.shift();
const type = /!Type:([^$]*)$/.exec(line.trim());
const data = {};
const transactions = data.transactions = [];
let transaction = {};
let type = { }; // /^(!Type:([^$]*)|!Account)$/.exec(line.trim());
let currentBankName = '';
let isMultiAccount = false;

options = options || {};
let data = {};

if (!type || !type.length) {
throw new Error(`File does not appear to be a valid qif file: ${line}`);
}
data.type = type[1];
let entity = {};

options = options || {};

let division = {};
let line;

let i = 0;

while (line = lines.shift()) {
line = line.trim();
line = clean(line);
i += 1;
debug(i, line, line.charCodeAt(0), [...line], [...line].map((c) => c.charCodeAt(0)));

if (line === '^') {
transactions.push(transaction);
transaction = {};
if (type.list_name === 'accounts') {
currentBankName = entity.name;
}
data = appendEntity(data, type, entity, currentBankName, isMultiAccount);
entity = {};
continue;
}
switch (line[0]) {
case 'D':
transaction.date = parseDate(line.substring(1), options.dateFormat);
if (type.list_name === 'transactions') {
entity.date = parseDate(line.substring(1), options.dateFormat);
} else {
entity.description = line.substring(1);
}
break;
case 'T':
transaction.amount = parseFloat(line.substring(1).replace(',', ''));
if (type.list_name === 'transactions') {
entity.amount = parseFloat(line.substring(1).replace(',', ''));
} else {
entity.type = line.substring(1);
}
break;
case 'U':
// Looks like a legacy repeat of 'T'
break;
case 'N':
transaction.number = line.substring(1);
const propName = type.list_name === 'transactions' ? 'number' : 'name';
entity[propName] = line.substring(1);
break;
case 'M':
transaction.memo = line.substring(1);
entity.memo = line.substring(1);
break;
case 'A':
transaction.address = (transaction.address || []).concat(line.substring(1));
entity.address = (entity.address || []).concat(line.substring(1));
break;
case 'P':
transaction.payee = line.substring(1).replace(/&amp;/g, '&');
entity.payee = line.substring(1).replace(/&amp;/g, '&');
break;
case 'L':
const lArray = line.substring(1).split(':');
transaction.category = lArray[0];
entity.category = lArray[0];
if (lArray[1] !== undefined) {
transaction.subcategory = lArray[1];
entity.subcategory = lArray[1];
}
break;
case 'C':
transaction.clearedStatus = line.substring(1);
entity.clearedStatus = line.substring(1);
break;
case 'S':
const sArray = line.substring(1).split(':');
Expand All @@ -114,35 +160,54 @@ exports.parse = function parse(qif, options) {
break;
case '$':
division.amount = parseFloat(line.substring(1));
if (!(transaction.division instanceof Array)) {
transaction.division = [];
if (!(entity.division instanceof Array)) {
entity.division = [];
}
transaction.division.push(division);
entity.division.push(division);
division = {};

break;
case '!':
const typeName = line.substring(1);
type = TYPES.find(({ name }) => name === typeName);
if (typeName === 'Account') {
isMultiAccount = true;
}
if (!type && typeName.startsWith('Type:')) {
type = {
type: typeName,
list_name: 'transactions',
};
}
if (isMultiAccount === false) {
data = appendEntity(data, { list_name: 'accounts' }, { type: typeName }, currentBankName, isMultiAccount);
}

if (!type) {
throw new Error(`File does not appear to be a valid qif file: ${line}. Type ${typeName} is not supported.`);
}
break;
default:
throw new Error(`Unknown Detail Code: ${line[0]}`);
throw new Error(`Unknown Detail Code: ${line[0]} in line ${i} with content: "${line}"`);
}
}

if (Object.keys(transaction).length) {
transactions.push(transaction);
if (Object.keys(entity).length) {
data = appendEntity(data, type, entity, currentBankName, isMultiAccount);
}

return data;
};

exports.parseInput = function parseInput(qifData, options, callback) {
const { encoding } = jschardet.detect(qifData);
let err;

exports.parseInput = function parseInput(qifData, options, callback) {
if (!callback) {
callback = options;
options = {};
}

const { encoding } = { ...jschardet.detect(qifData), ...options };
let err;

if (encoding.toUpperCase() !== 'UTF-8' && encoding.toUpperCase() !== 'ASCII') {
qifData = Iconv.decode(Buffer.from(qifData), encoding);
} else {
Expand Down
61 changes: 61 additions & 0 deletions lib/types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const TYPES = [
{
name: 'Type:Cash',
description: 'Cash Flow: Cash Account',
list_name: 'transactions',
},
{
name: 'Type:Bank',
description: 'Cash Flow: Checking & Savings Account',
list_name: 'transactions',
},
{
name: 'Type:CCard',
description: 'Cash Flow: Checking & Savings Account',
list_name: 'transactions',
},
{
name: 'Type:Invst',
description: 'Investing: Investment Account',
list_name: 'transactions',
},
{
name: 'Type:Oth A',
description: 'Property & Debt: Asset',
list_name: 'transactions',
},
{
name: 'Type:Oth L',
description: 'Property & Debt: Liability',
list_name: 'transactions',
},
{
name: 'Type:Invoice',
description: 'Invoice (Quicken for Business only)',
list_name: 'transactions',
},
{
name: 'Account',
description: 'Account list or which account follows',
list_name: 'accounts',
},
{
name: 'Type:Cat',
description: 'Category list',
list_name: 'categories',
},
{
name: 'Type:Class',
description: 'Class list',
list_name: 'classes',
},
{
name: 'Type:Memorized',
description: 'Memorized transaction list',
list_name: 'memorized_lists',
},
];

module.exports = {
TYPES,
};
Loading