123 lines
4.2 KiB
JavaScript
123 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
// Batch 3: OFX/QFX import — parser handles SGML (OFX 1.x, unclosed leaf tags) and
|
|
// XML (OFX 2.x / QFX); commit inserts with the same dedupe scope as the CSV path.
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-ofx-${process.pid}.sqlite`);
|
|
process.env.DB_PATH = dbPath;
|
|
|
|
const { getDb, closeDb } = require('../db/database');
|
|
const { parseOfx, previewOfxTransactions, commitOfxTransactions } = require('../services/ofxImportService');
|
|
|
|
// OFX 1.x SGML — container tags closed, leaf tags unclosed.
|
|
const OFX_SGML = `OFXHEADER:100
|
|
DATA:OFXSGML
|
|
VERSION:102
|
|
|
|
<OFX>
|
|
<BANKMSGSRSV1><STMTTRNRS><STMTRS>
|
|
<CURDEF>USD
|
|
<BANKACCTFROM><ACCTID>000123456789</BANKACCTFROM>
|
|
<BANKTRANLIST>
|
|
<STMTTRN>
|
|
<TRNTYPE>DEBIT
|
|
<DTPOSTED>20260615120000
|
|
<TRNAMT>-42.50
|
|
<FITID>TXN-1001
|
|
<NAME>Netflix
|
|
<MEMO>Monthly subscription
|
|
</STMTTRN>
|
|
<STMTTRN>
|
|
<TRNTYPE>CREDIT
|
|
<DTPOSTED>20260616
|
|
<TRNAMT>1500.00
|
|
<FITID>TXN-1002
|
|
<NAME>Payroll
|
|
</STMTTRN>
|
|
</BANKTRANLIST>
|
|
</STMTRS></STMTTRNRS></BANKMSGSRSV1>
|
|
</OFX>`;
|
|
|
|
// OFX 2.x / QFX — XML (closed leaf tags) + Intuit tag we must ignore.
|
|
const QFX_XML = `<?xml version="1.0"?>
|
|
<OFX>
|
|
<SONRS><INTU.BID>00123</INTU.BID></SONRS>
|
|
<STMTTRN>
|
|
<TRNTYPE>DEBIT</TRNTYPE>
|
|
<DTPOSTED>20260701</DTPOSTED>
|
|
<TRNAMT>-9.99</TRNAMT>
|
|
<FITID>X-1</FITID>
|
|
<NAME>Spotify & Co</NAME>
|
|
</STMTTRN>
|
|
</OFX>`;
|
|
|
|
let userId;
|
|
test.before(() => {
|
|
const db = getDb();
|
|
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('ofx-user','x','user',1)").run().lastInsertRowid;
|
|
});
|
|
test.after(() => {
|
|
closeDb();
|
|
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
|
|
});
|
|
|
|
test('parseOfx reads SGML transactions (date, signed cents, fitid, name/memo)', () => {
|
|
const txns = parseOfx(OFX_SGML);
|
|
assert.equal(txns.length, 2);
|
|
const netflix = txns[0];
|
|
assert.equal(netflix.posted_date, '2026-06-15');
|
|
assert.equal(netflix.amount, -4250, 'signed cents');
|
|
assert.equal(netflix.provider_transaction_id, 'ofx:id:TXN-1001');
|
|
assert.equal(netflix.payee, 'Netflix');
|
|
assert.equal(netflix.memo, 'Monthly subscription');
|
|
assert.equal(txns[1].amount, 150000, 'credit is positive cents');
|
|
});
|
|
|
|
test('parseOfx reads XML/QFX + decodes entities + ignores Intuit tags', () => {
|
|
const txns = parseOfx(QFX_XML);
|
|
assert.equal(txns.length, 1);
|
|
assert.equal(txns[0].amount, -999);
|
|
assert.equal(txns[0].posted_date, '2026-07-01');
|
|
assert.equal(txns[0].payee, 'Spotify & Co');
|
|
});
|
|
|
|
test('parseOfx rejects non-OFX and empty files', () => {
|
|
assert.throws(() => parseOfx('just some text'), /does not look like|OFX/i);
|
|
assert.throws(() => parseOfx('<OFX></OFX>'), /No transactions/i);
|
|
});
|
|
|
|
test('preview → commit inserts, and re-commit dedupes', () => {
|
|
const db = getDb();
|
|
const before = db.prepare('SELECT COUNT(*) n FROM transactions WHERE user_id=?').get(userId).n;
|
|
|
|
const p1 = previewOfxTransactions(userId, Buffer.from(OFX_SGML), { original_filename: 'stmt.ofx' });
|
|
assert.equal(p1.count, 2);
|
|
const r1 = commitOfxTransactions(userId, p1.import_session_id);
|
|
assert.equal(r1.imported, 2);
|
|
assert.equal(r1.skipped, 0);
|
|
|
|
const mid = db.prepare('SELECT COUNT(*) n FROM transactions WHERE user_id=?').get(userId).n;
|
|
assert.equal(mid - before, 2);
|
|
|
|
// amounts persisted as integer cents
|
|
const amt = db.prepare("SELECT amount FROM transactions WHERE provider_transaction_id='ofx:id:TXN-1001' AND user_id=?").get(userId).amount;
|
|
assert.equal(amt, -4250);
|
|
|
|
// re-import the same file → all skipped, no new rows
|
|
const p2 = previewOfxTransactions(userId, Buffer.from(OFX_SGML), { original_filename: 'stmt.ofx' });
|
|
const r2 = commitOfxTransactions(userId, p2.import_session_id);
|
|
assert.equal(r2.imported, 0);
|
|
assert.equal(r2.skipped, 2);
|
|
const after = db.prepare('SELECT COUNT(*) n FROM transactions WHERE user_id=?').get(userId).n;
|
|
assert.equal(after, mid, 'no duplicates on re-import');
|
|
|
|
// logged to import_history
|
|
const hist = db.prepare("SELECT file_type, rows_created, rows_skipped FROM import_history WHERE user_id=? ORDER BY id DESC LIMIT 1").get(userId);
|
|
assert.equal(hist.file_type, 'ofx_transactions');
|
|
});
|