import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { parseOfx } from '../src/import/ofx'; import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers'; // Made-up OFX files in both flavors banks send: the older SGML style with // unclosed tags (bank account), and XML (credit card, as in a .qfx). const SGML = `OFXHEADER:100 DATA:OFXSGML VERSION:102 SECURITY:NONE ENCODING:USASCII CHARSET:1252 0INFO20261007120000ENGBank of America5959 10INFO USD000000000000099991234CHECKING 2026090120260930 CREDIT20260905120000.000[-5:EST]3000.00A1ACME CORP PAYROLLPPD ID: 123 DEBIT20260912-54.20A2KROGER #123 CHARLOTTE NC XFER20260915-300.00A3Online Banking transfer to SAV5678 Confirmation# 1 DEBIT20260920-12,50A4JOES COFFEE & BAGELS 4633.30202609304633.3020260930 `; const XML = ` 1USD 4111XXXXXXXX9876 2026090120260930 DEBIT2026090320260902-23.99C1AMAZON MKTPL*AB12CD3E4 CREDIT2026090718.50C2AMAZON MKTPL*QR56ST7U8Return DEBIT20260912-15.49C3NETFLIX.COM -412.3320260930 `; describe('reading OFX', () => { it('reads the older SGML style', () => { const s = parseOfx(SGML); expect(s).toMatchObject({ kind: 'checking', institution: 'Bank of America', accountLast4: '1234', ledger: { cents: 463330, date: '2026-09-30' } }); expect(s.transactions.map((t) => [t.date, t.amountCents, t.name])).toEqual([ ['2026-09-05', 300000, 'ACME CORP PAYROLL'], ['2026-09-12', -5420, 'KROGER #123 CHARLOTTE NC'], ['2026-09-15', -30000, 'Online Banking transfer to SAV'], ['2026-09-20', -1250, 'JOES COFFEE & BAGELS'], ]); }); it('reads XML, using the purchase date when there is one', () => { const s = parseOfx(XML); expect(s).toMatchObject({ kind: 'credit_card', accountLast4: '9876' }); expect(s.transactions[0]).toMatchObject({ date: '2026-09-02', postedDate: '2026-09-03', amountCents: -2399 }); expect(s.transactions[1]).toMatchObject({ amountCents: 1850, memo: 'Return' }); }); }); describe('importing OFX', () => { let t: TestApp; let admin: Client; beforeEach(async () => { t = await makeApp(); admin = await setUpAdmin(t); }); afterEach(() => t.cleanup()); it('imports with no mapping, matches the account by its last digits, and skips repeats', async () => { const checking = (await admin.post('/api/v1/accounts', { name: 'Checking', kind: 'checking', institution: 'Bank of America', last4: '1234' })).json.id; await admin.post('/api/v1/accounts', { name: 'Prime Visa', kind: 'credit_card', institution: 'Chase', last4: '9876' }); const preview = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); expect(preview.json).toMatchObject({ layout: { key: 'ofx' }, account: { id: checking, confidence: 'certain' }, counts: { new: 4 } }); const done = await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: checking }); expect(done.status).toBe(200); const page = (await admin.get(`/api/v1/transactions?accountId=${checking}`)).json; expect(page.items.map((x: { merchant: string }) => x.merchant)).toContain('Kroger'); const account = ((await admin.get('/api/v1/accounts')).json as { id: number; balanceCents: number; balanceConfirmedOn: string }[]).find((a) => a.id === checking)!; expect(account).toMatchObject({ balanceCents: 463330, balanceConfirmedOn: '2026-09-20' }); const again = await admin.upload('/api/v1/imports/files', 'export.qfx', SGML); expect(again.json.counts).toMatchObject({ new: 0, duplicates: 4 }); const card = await admin.upload('/api/v1/imports/files', 'Statement.qfx', XML); expect(card.json.account.confidence).toBe('certain'); expect(card.json.accountHint).toMatchObject({ kind: 'credit_card' }); }); });