177 lines
5.9 KiB
TypeScript
177 lines
5.9 KiB
TypeScript
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> = {}): 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<string, string>).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');
|
|
});
|
|
});
|