Skip to content

Commit

Permalink
Merge branch 'disc-cli'
Browse files Browse the repository at this point in the history
  • Loading branch information
dckc committed Jul 22, 2024
2 parents 17347d7 + b501940 commit 94a64e1
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 106 deletions.
47 changes: 47 additions & 0 deletions packages/discover-dl/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @ts-check
import { parseArgs } from 'node:util';
import { csv2ofx } from './csv2ofx.js';

/** @type { import('node:util').ParseArgsConfig['options'] } */
const options = /** @type {const} */ ({
acctId: { type: 'string' },
});

export const Usage = 'cli --acctId I file.csv...';

/**
* @param {string[]} args
* @param {object} io
* @param {Pick<typeof import('fs/promises'), 'readFile' | 'writeFile'>} io.fsp
* @param {() => number} io.now
*/
const main = async (args, { fsp: { readFile, writeFile }, now }) => {
const { positionals: paths, values } = parseArgs({
args,
options,
allowPositionals: true,
});
const { acctId } = values;
if (!(paths.length && acctId)) throw Usage;
const dtServer = new Date(now());
for await (const path of paths) {
/** @type {string} */
const content = await readFile(path, { encoding: 'utf-8' });
const ofx = csv2ofx({ acctId, dtServer, content });
const outPath = `${path}.ofx`;
console.info('writing OFX to', outPath);
await writeFile(outPath, ofx);
}
};

main(process.argv.slice(2), {
fsp: await import('node:fs/promises'),
now: () => Date.now(),
}).catch(reason => {
if (typeof reason === 'string') {
console.error(reason);
} else {
console.error(reason);
}
process.exit(1);
});
125 changes: 125 additions & 0 deletions packages/discover-dl/csv2ofx.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// @ts-check
// grr... eslint doesn't grok "exports" in package.json???
/* eslint-disable import/no-unresolved */
import { parse } from 'csv-parse/sync';
import { OFX, ccStatement, fmtDate } from './ofx.js';

const { isArray } = Array;
const { keys } = Object;
const { stringify: lit } = JSON;

// eslint-disable-next-line no-unused-vars
export const example = {
record: {
'Trans. Date': '07/14/2023',
'Post Date': '07/14/2023',
Description: 'TEAS',
Amount: '31.00',
Category: 'Supermarkets',
},
};
/** @typedef {typeof example.record} DiscoverExport */

// ack: Linus Unnebäck Nov 18 '12
// http://stackoverflow.com/a/13440842
/** @type { <ORD extends number|string>(a: ORD[]) => ORD } */
const min = arr => arr.reduce((p, v) => (p < v ? p : v));
/** @type { <ORD extends number|string>(a: ORD[]) => ORD } */
const max = arr => arr.reduce((p, v) => (p > v ? p : v));

/** @type {(xs: unknown[], ys: unknown[]) => boolean} */
const arrayEqual = (xs, ys) =>
isArray(xs) &&
isArray(ys) &&
xs.length === ys.length &&
xs.every((x, ix) => x === ys[ix]);

/** @param {string} s */
const hashCode = s => {
let hash = 0;
let i;
let chr;
if (s.length === 0) return hash;
for (i = 0; i < s.length; i += 1) {
chr = s.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + chr;
// eslint-disable-next-line no-bitwise
hash |= 0; // Convert to 32bit integer
}
return hash;
};

const datePatt = /(?<mm>\d{2})\/(?<dd>\d{2})\/(?<yyyy>\d{4})/;

const parseDate = mdy => {
const m = datePatt.exec(mdy);
if (!(m && m.groups)) throw RangeError(mdy);
const { mm, dd, yyyy } = m.groups;
return new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10));
};

/**
* @param {string} acctId
* @param {import('./csv2ofx.js').DiscoverExport[]} records
*/
const toStatement = (acctId, records) => {
/** @type {import('./ofx').STMTTRN[]} */
const txs = records.map(
({
'Trans. Date': txDate,
'Post Date': postDate,
Description: NAME,
Amount: amt,
Category: cat,
}) => ({
STMTTRN: {
DTPOSTED: fmtDate(parseDate(postDate)),
FITID: `${parseDate(postDate)
.toISOString()
.slice(0, 10)}-${amt}-${hashCode(NAME)}`,
NAME,
TRNAMT: -parseFloat(amt),
TRNTYPE: parseFloat(amt) > 0 ? 'DEBIT' : 'CREDIT',
DTUSER: fmtDate(parseDate(txDate)),
MEMO: cat,
},
}),
);
const millis = records.map(r => parseDate(r['Post Date']).valueOf());
const dtStart = new Date(min(millis));
const dtEnd = new Date(max(millis));
const endBalance = 0; // ???
const stmt = ccStatement(acctId, dtStart, dtEnd, endBalance, txs);
return stmt;
};

/**
* @param {object} opts
* @param {string} opts.acctId
* @param {Date} [opts.dtServer]
* @param {string} [opts.content]
* @param {DiscoverExport[]} [opts.records]
* @param {object} io
* @param {() => number} [io.now]
*/
export const csv2ofx = (opts, io = {}) => {
const { now = () => new Date().getTime() } = io;
const {
acctId,
dtServer = new Date(now()),
content,
/** @type {DiscoverExport[]} */
records = parse(content, { columns: true }),
} = opts;
console.log('records:', records.length, keys(records[0]));
if (records.length === 0) throw Error(`no records`);
if (!arrayEqual(keys(records[0]), keys(example.record))) {
throw Error(
`expected ${lit(keys(example.record))} got ${lit(keys(records[0]))}`,
);
}
const stmt = toStatement(acctId, records);
const ofx = OFX(dtServer, stmt);
return ofx;
};
110 changes: 4 additions & 106 deletions packages/discover-dl/ui.js
Original file line number Diff line number Diff line change
@@ -1,101 +1,8 @@
// @ts-check
// grr... eslint doesn't grok "exports" in package.json???
/* eslint-disable import/no-unresolved */
import { parse } from 'csv-parse/sync';
import { ccStatement, fmtDate, OFX } from './ofx.js';
import { csv2ofx } from './csv2ofx.js';

console.log('ui module');

const { keys } = Object;
const { isArray } = Array;
const { stringify: lit } = JSON;

// ack: Linus Unnebäck Nov 18 '12
// http://stackoverflow.com/a/13440842
/** @type { <ORD extends number|string>(a: ORD[]) => ORD } */
const min = arr => arr.reduce((p, v) => (p < v ? p : v));
/** @type { <ORD extends number|string>(a: ORD[]) => ORD } */
const max = arr => arr.reduce((p, v) => (p > v ? p : v));

/** @param {string} s */
const hashCode = s => {
let hash = 0;
let i;
let chr;
if (s.length === 0) return hash;
for (i = 0; i < s.length; i += 1) {
chr = s.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + chr;
// eslint-disable-next-line no-bitwise
hash |= 0; // Convert to 32bit integer
}
return hash;
};

// eslint-disable-next-line no-unused-vars
const example = {
record: {
'Trans. Date': '07/14/2023',
'Post Date': '07/14/2023',
Description: 'TEAS',
Amount: '31.00',
Category: 'Supermarkets',
},
};
/** @typedef {typeof example.record} DiscoverExport */

const datePatt = /(?<mm>\d{2})\/(?<dd>\d{2})\/(?<yyyy>\d{4})/;

const parseDate = mdy => {
const m = datePatt.exec(mdy);
if (!(m && m.groups)) throw RangeError(mdy);
const { mm, dd, yyyy } = m.groups;
return new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10));
};

/** @type {(xs: unknown[], ys: unknown[]) => boolean} */
const arrayEqual = (xs, ys) =>
isArray(xs) &&
isArray(ys) &&
xs.length === ys.length &&
xs.every((x, ix) => x === ys[ix]);

/**
* @param {string} acctId
* @param {DiscoverExport[]} records
*/
const toStatement = (acctId, records) => {
/** @type {import('./ofx').STMTTRN[]} */
const txs = records.map(
({
'Trans. Date': txDate,
'Post Date': postDate,
Description: NAME,
Amount: amt,
Category: cat,
}) => ({
STMTTRN: {
DTPOSTED: fmtDate(parseDate(postDate)),
FITID: `${parseDate(postDate)
.toISOString()
.slice(0, 10)}-${amt}-${hashCode(NAME)}`,
NAME,
TRNAMT: -parseFloat(amt),
TRNTYPE: parseFloat(amt) > 0 ? 'DEBIT' : 'CREDIT',
DTUSER: fmtDate(parseDate(txDate)),
MEMO: cat,
},
}),
);
const millis = records.map(r => parseDate(r['Post Date']).valueOf());
const dtStart = new Date(min(millis));
const dtEnd = new Date(max(millis));
const endBalance = 0; // ???
const stmt = ccStatement(acctId, dtStart, dtEnd, endBalance, txs);
return stmt;
};

/**
* @param {object} io
* @param {(sel: string) => HTMLInputElement} io.getInput
Expand All @@ -112,20 +19,11 @@ const ui = ({ getInput, getAnchor, now }) => {

elt.theFile.addEventListener('change', async event => {
console.log('change event:', event);
const acctId = elt.acctId.value;
const dtServer = new Date(now());
const file = event?.target?.files[0];
const content = await file.text();
/** @type {DiscoverExport[]} */
const records = parse(content, { columns: true });
console.log('records:', records.length, records.slice(0, 3));
if (records.length === 0) return;
if (!arrayEqual(keys(records[0]), keys(example.record))) {
throw Error(
`expected ${lit(keys(example.record))} got ${lit(keys(records[0]))}`,
);
}
const stmt = toStatement(elt.acctId.value, records);
const dtServer = new Date(now());
const ofx = OFX(dtServer, stmt);
const ofx = csv2ofx({ acctId, dtServer, content });
elt.ofx.value = ofx;

const blob = new Blob([ofx], { type: 'application/octet-stream' });
Expand Down

0 comments on commit 94a64e1

Please sign in to comment.