import { describe, expect, it } from 'vitest'; import { generateKeyPairSync } from 'node:crypto'; import { decodeProtectedHeader, jwtVerify } from 'jose'; import { amountToCents, externalIdFor, findMatch, mapBankTransaction, planAccountMapping } from './bank-sync'; import { createEnableBankingClient, parseAuthCode, type BankTransaction } from './enable-banking'; const tx = (overrides: Partial = {}): BankTransaction => ({ entry_reference: 'ref-1', transaction_amount: { currency: 'EUR', amount: '12.34' }, credit_debit_indicator: 'DBIT', status: 'BOOK', booking_date: '2026-10-20', value_date: '2026-10-19', creditor: { name: 'Albert Heijn' }, remittance_information: ['AH 1234 Gouda'], ...overrides }); describe('bank transaction mapping', () => { it('parses decimal amounts exactly', () => { expect(amountToCents('12.34')).toBe(1234); expect(amountToCents('-0.5')).toBe(-50); expect(amountToCents('100')).toBe(10000); expect(amountToCents('1.005')).toBe(101); }); it('signs debits negative and uses the counterparty as description', () => { const row = mapBankTransaction(tx())!; expect(row).toMatchObject({ externalId: 'ref-1', date: '2026-10-20', valueDate: '2026-10-19', amountCents: -1234, description: 'Albert Heijn', counterparty: 'Albert Heijn', bookingStatus: 'BOOK' }); const credit = mapBankTransaction( tx({ credit_debit_indicator: 'CRDT', creditor: null, debtor: { name: 'Juhee' } }) )!; expect(credit.amountCents).toBe(1234); expect(credit.description).toBe('Juhee'); }); it('skips pending transactions', () => { expect(mapBankTransaction(tx({ status: 'PDNG' }))).toBeNull(); }); it('falls back to remittance info and to a stable content hash', () => { const anonymous = tx({ entry_reference: null, creditor: null }); const row = mapBankTransaction(anonymous)!; expect(row.description).toBe('AH 1234 Gouda'); expect(row.externalId).toMatch(/^h:[0-9a-f]{32}$/); expect(externalIdFor(anonymous)).toBe(row.externalId); expect( externalIdFor({ ...anonymous, transaction_amount: { currency: 'EUR', amount: '1.00' } }) ).not.toBe(row.externalId); }); }); describe('reconcile matching', () => { const rows = [ { id: 1, date: '2026-10-18', amountCents: -1234, description: 'Groceries' }, { id: 2, date: '2026-10-21', amountCents: -1234, description: 'Albert Heijn' }, { id: 3, date: '2026-10-20', amountCents: -999, description: 'Albert Heijn' } ]; it('matches same amount within one day, preferring description overlap', () => { const m = findMatch( { date: '2026-10-20', amountCents: -1234, description: 'Albert Heijn' }, rows, new Set() ); expect(m?.id).toBe(2); }); it('never reuses a row and ignores rows more than a day away', () => { expect( findMatch({ date: '2026-10-20', amountCents: -1234, description: 'x' }, rows, new Set([2])) ).toBeNull(); }); }); describe('account mapping', () => { const tracker = [ { id: 1, name: 'Revolut', scope: 'personal', iban: null, external_uid: null }, { id: 4, name: 'Joint', scope: 'joint', iban: null, external_uid: null } ]; it('maps the joint account and the personal EUR account', () => { const plan = planAccountMapping( [ { uid: 'a', account_id: { iban: 'LT1' }, name: 'Stan Runge', currency: 'EUR' }, { uid: 'b', account_id: { iban: 'LT2' }, name: 'Stan Runge & Juhee', currency: 'EUR' }, { uid: 'c', account_id: { iban: 'LT3' }, name: 'Stan Runge', currency: 'USD' } ], tracker ); expect(plan.map((p) => [p.bank.uid, p.tracker?.name ?? null])).toEqual([ ['b', 'Joint'], ['a', 'Revolut'], ['c', null] ]); }); it('honours overrides and existing IBANs', () => { const plan = planAccountMapping( [ { uid: 'a', account_id: { iban: 'LT1' }, name: 'X', currency: 'EUR' }, { uid: 'b', account_id: { iban: 'LT2' }, name: 'Y', currency: 'EUR' } ], [{ ...tracker[0], iban: 'LT2' }, tracker[1]], { a: 'Joint' } ); expect(plan.map((p) => [p.bank.uid, p.tracker?.name])).toEqual([ ['a', 'Joint'], ['b', 'Revolut'] ]); }); }); describe('enable banking client', () => { it('parses codes from redirect URLs, query strings and bare codes', () => { expect( parseAuthCode( 'https://stanrunge.dev/projects/money-tracker/bank-callback?state=s&code=abc-123' ) ).toEqual({ code: 'abc-123', state: 's', error: undefined }); expect(parseAuthCode('abc-123').code).toBe('abc-123'); expect(parseAuthCode('?error=access_denied&error_description=Cancelled').error).toBe( 'access_denied: Cancelled' ); }); it('signs RS256 JWTs with the app id as kid and pages through transactions', async () => { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const calls: string[] = []; const fakeFetch = (async (url: string, init: RequestInit) => { calls.push(url); const auth = String((init.headers as Record).Authorization).slice(7); expect(decodeProtectedHeader(auth)).toMatchObject({ alg: 'RS256', kid: 'app-id', typ: 'JWT' }); const { payload } = await jwtVerify(auth, publicKey, { issuer: 'enablebanking.com', audience: 'api.enablebanking.com' }); expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(86400); const page = new URL(url).searchParams.get('continuation_key'); return new Response( JSON.stringify( page ? { transactions: [tx({ entry_reference: 'r2' })] } : { transactions: [tx()], continuation_key: 'next' } ) ); }) as unknown as typeof fetch; const client = createEnableBankingClient({ appId: 'app-id', privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), fetch: fakeFetch }); const all = await client.transactions('uid-1', { dateFrom: '2026-01-01' }); expect(all.map((t) => t.entry_reference)).toEqual(['ref-1', 'r2']); expect(calls[1]).toContain('continuation_key=next'); expect(calls[0]).toContain('date_from=2026-01-01'); }); });