BillTracker/tests/ofxImportService.test.js

123 lines
4.2 KiB
JavaScript
Raw Normal View History

'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 &amp; 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');
});