actual-jev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js ADDED
@@ -0,0 +1,84 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { saveConfig, validateConfig } from './config.js';
5
+ async function secretSetting(port, name, previous, clear = false) {
6
+ if (previous !== undefined) {
7
+ const action = await port.select(name, [
8
+ { name: 'Keep saved value', value: 'keep' },
9
+ { name: 'Replace', value: 'replace' },
10
+ ...(clear ? [{ name: 'Clear', value: 'clear' }] : []),
11
+ ]);
12
+ if (action === 'keep')
13
+ return previous;
14
+ if (action === 'clear')
15
+ return undefined;
16
+ }
17
+ return port.secret(name);
18
+ }
19
+ export async function runSetup(saved, configFile, port) {
20
+ port.print(`Credentials will be saved locally in ${configFile}.`);
21
+ const serverURL = (await port.input('Actual server URL', saved.serverURL)).trim();
22
+ validateConfig({ version: 1, serverURL });
23
+ const password = (await secretSetting(port, 'Actual server password', saved.password));
24
+ const candidate = { ...saved, version: 1, serverURL, password };
25
+ const dataDir = await mkdtemp(join(tmpdir(), 'actual-jev-setup-'));
26
+ let initialized = false;
27
+ let budgetName;
28
+ try {
29
+ try {
30
+ await port.actual.init({ serverURL, password, dataDir, verbose: false });
31
+ initialized = true;
32
+ }
33
+ catch {
34
+ throw new Error('Could not connect to Actual. Check the server URL and password, then run actual-jev setup again.');
35
+ }
36
+ let budgets;
37
+ try {
38
+ budgets = await port.actual.getBudgets();
39
+ }
40
+ catch {
41
+ throw new Error('Could not list budgets. Check the Actual connection and run actual-jev setup again.');
42
+ }
43
+ // Actual downloads by groupId (the sync ID); cloudFileId identifies the server file.
44
+ const unique = [
45
+ ...new Map(budgets
46
+ .filter((budget) => Boolean(budget.cloudFileId && budget.groupId))
47
+ .map((budget) => [budget.groupId, budget])).values(),
48
+ ];
49
+ if (!unique.length)
50
+ throw new Error('No synced budgets found. Upload a budget to your Actual server, then run actual-jev setup again.');
51
+ const syncId = await port.select('Choose your budget', unique.map((budget) => ({
52
+ name: unique.filter((other) => other.name === budget.name).length > 1
53
+ ? `${budget.name} (${budget.groupId})`
54
+ : budget.name,
55
+ value: budget.groupId,
56
+ })), saved.serverURL === serverURL ? saved.syncId : undefined);
57
+ const budget = unique.find((budget) => budget.groupId === syncId);
58
+ candidate.syncId = syncId;
59
+ budgetName = budget.name;
60
+ const previousEncryption = saved.serverURL === serverURL && saved.syncId === syncId ? saved.encryptionPassword : undefined;
61
+ candidate.encryptionPassword = budget.encryptKeyId
62
+ ? await secretSetting(port, 'Budget encryption password', previousEncryption, true)
63
+ : undefined;
64
+ try {
65
+ await port.actual.downloadBudget(syncId, { password: candidate.encryptionPassword });
66
+ }
67
+ catch {
68
+ throw new Error('Could not open the budget. Check its encryption password and connection, then run actual-jev setup again.');
69
+ }
70
+ candidate.apiKey = await secretSetting(port, 'TypeSafe API key', saved.apiKey);
71
+ validateConfig(candidate);
72
+ }
73
+ finally {
74
+ try {
75
+ if (initialized)
76
+ await port.actual.shutdown();
77
+ }
78
+ finally {
79
+ await rm(dataDir, { recursive: true, force: true });
80
+ }
81
+ }
82
+ await saveConfig(configFile, candidate);
83
+ port.print(`Saved configuration to ${configFile}.\nBudget: ${budgetName}\nTry actual-jev --dry-run to preview suggestions and verify your TypeSafe API key.`);
84
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { readConfig, saveConfig } from './config.js';
7
+ import { runSetup } from './setup.js';
8
+ function fixture(encrypted = false) {
9
+ const calls = [];
10
+ const port = {
11
+ input: () => Promise.resolve('https://actual.example'),
12
+ secret: (message) => {
13
+ calls.push(message);
14
+ return Promise.resolve('fixture-secret');
15
+ },
16
+ select: (message, choices) => {
17
+ calls.push(message);
18
+ return Promise.resolve(choices[0].value);
19
+ },
20
+ print: (message) => calls.push(message),
21
+ actual: {
22
+ init: () => {
23
+ calls.push('init');
24
+ return Promise.resolve();
25
+ },
26
+ getBudgets: () => Promise.resolve([
27
+ { name: 'Unsynced', cloudFileId: 'unsynced-file', groupId: null },
28
+ {
29
+ name: 'Household',
30
+ cloudFileId: 'cloud-file',
31
+ groupId: 'budget',
32
+ encryptKeyId: encrypted ? 'encryption-key' : null,
33
+ },
34
+ ]),
35
+ downloadBudget: (syncId, options) => {
36
+ assert.equal(syncId, 'budget', 'downloadBudget requires groupId, not cloudFileId');
37
+ calls.push(`download:${options.password ?? 'none'}`);
38
+ return Promise.resolve();
39
+ },
40
+ shutdown: () => {
41
+ calls.push('shutdown');
42
+ return Promise.resolve();
43
+ },
44
+ },
45
+ };
46
+ return { port, calls };
47
+ }
48
+ void test('setup opens and saves the sync ID rather than the cloud file ID, including encrypted budgets', async (t) => {
49
+ const dir = await mkdtemp(join(tmpdir(), 'jev-setup-'));
50
+ t.after(() => rm(dir, { recursive: true, force: true }));
51
+ for (const encrypted of [false, true]) {
52
+ const { port, calls } = fixture(encrypted);
53
+ const file = join(dir, `config-${encrypted}.json`);
54
+ await runSetup({ version: 1 }, file, port);
55
+ const config = await readConfig(file);
56
+ assert.equal(config.syncId, 'budget');
57
+ assert.equal(config.apiKey, 'fixture-secret');
58
+ assert.equal(config.encryptionPassword, encrypted ? 'fixture-secret' : undefined);
59
+ assert.ok(calls.includes('shutdown'));
60
+ assert.ok(calls.some((line) => line.includes('Budget: Household')));
61
+ }
62
+ });
63
+ void test('updates keep saved secrets and deduplicate budget choices', async (t) => {
64
+ const dir = await mkdtemp(join(tmpdir(), 'jev-setup-'));
65
+ t.after(() => rm(dir, { recursive: true, force: true }));
66
+ const file = join(dir, 'config.json');
67
+ const saved = {
68
+ version: 1,
69
+ serverURL: 'https://actual.example',
70
+ password: 'old-password',
71
+ apiKey: 'old-key',
72
+ syncId: 'budget',
73
+ encryptionPassword: 'old-encryption',
74
+ maxExamplesPerCategory: 2,
75
+ };
76
+ const { port } = fixture(true);
77
+ port.actual.getBudgets = () => Promise.resolve([
78
+ { name: 'Home', cloudFileId: 'cloud-file', groupId: 'budget', encryptKeyId: 'key' },
79
+ { name: 'Home', cloudFileId: 'cloud-file', groupId: 'budget', encryptKeyId: 'key' },
80
+ ]);
81
+ port.select = (message, choices, initial) => {
82
+ if (message === 'Choose your budget') {
83
+ assert.equal(choices.length, 1);
84
+ assert.equal(choices[0].value, 'budget');
85
+ assert.equal(initial, 'budget');
86
+ }
87
+ return Promise.resolve(choices[0].value);
88
+ };
89
+ port.secret = () => {
90
+ throw new Error('Should retain saved secret');
91
+ };
92
+ await runSetup(saved, file, port);
93
+ assert.deepEqual(await readConfig(file), saved);
94
+ });
95
+ void test('cancellation and connection failures preserve saved configuration', async (t) => {
96
+ const dir = await mkdtemp(join(tmpdir(), 'jev-setup-'));
97
+ t.after(() => rm(dir, { recursive: true, force: true }));
98
+ const file = join(dir, 'config.json');
99
+ const saved = { version: 1, apiKey: 'old-key' };
100
+ await saveConfig(file, saved);
101
+ for (const stage of ['init', 'list', 'empty', 'download', 'cancel']) {
102
+ const { port, calls } = fixture();
103
+ if (stage === 'init')
104
+ port.actual.init = () => Promise.reject(new Error('sensitive details'));
105
+ if (stage === 'list')
106
+ port.actual.getBudgets = () => Promise.reject(new Error('sensitive details'));
107
+ if (stage === 'empty')
108
+ port.actual.getBudgets = () => Promise.resolve([]);
109
+ if (stage === 'download')
110
+ port.actual.downloadBudget = () => Promise.reject(new Error('sensitive details'));
111
+ if (stage === 'cancel')
112
+ port.select = () => Promise.reject(new Error('Cancelled'));
113
+ await assert.rejects(runSetup(saved, file, port), (error) => error instanceof Error && !error.message.includes('sensitive details'));
114
+ assert.deepEqual(await readConfig(file), saved);
115
+ if (stage !== 'init')
116
+ assert.ok(calls.includes('shutdown'));
117
+ }
118
+ });
119
+ void test('setup replaces credentials and clears encryption for an unencrypted budget', async (t) => {
120
+ const dir = await mkdtemp(join(tmpdir(), 'jev-setup-'));
121
+ t.after(() => rm(dir, { recursive: true, force: true }));
122
+ const saved = {
123
+ version: 1,
124
+ serverURL: 'https://actual.example',
125
+ password: 'old',
126
+ apiKey: 'old',
127
+ syncId: 'budget',
128
+ encryptionPassword: 'old',
129
+ };
130
+ const { port } = fixture();
131
+ port.select = (_message, choices) => Promise.resolve(choices.find((choice) => choice.value === 'replace')?.value ?? choices[0].value);
132
+ const file = join(dir, 'config.json');
133
+ await runSetup(saved, file, port);
134
+ const config = await readConfig(file);
135
+ assert.equal(config.password, 'fixture-secret');
136
+ assert.equal(config.apiKey, 'fixture-secret');
137
+ assert.equal(config.encryptionPassword, undefined);
138
+ });
@@ -0,0 +1,38 @@
1
+ import type { ActualTransaction } from './actual.js';
2
+ import type { CategoryCandidate, Classification } from './classifier.js';
3
+ export type RunMode = 'auto' | 'interactive' | 'dry-run';
4
+ export interface RunOptions {
5
+ mode: RunMode;
6
+ threshold: number;
7
+ account?: string;
8
+ from?: string;
9
+ to?: string;
10
+ }
11
+ export interface AccountInfo {
12
+ id: string;
13
+ name: string;
14
+ offbudget?: boolean;
15
+ }
16
+ export interface RunSummary {
17
+ examined: number;
18
+ applied: number;
19
+ wouldApply: number;
20
+ skipped: number;
21
+ transfersSkipped: number;
22
+ }
23
+ export interface WorkflowPort {
24
+ accounts: readonly AccountInfo[];
25
+ transactions: readonly ActualTransaction[];
26
+ transferPayeeAccountIds: ReadonlyMap<string, string>;
27
+ payeeNames?: ReadonlyMap<string, string>;
28
+ categories: readonly CategoryCandidate[];
29
+ classify(transaction: ActualTransaction & {
30
+ accountName?: string;
31
+ }): Promise<Classification>;
32
+ updateTransaction(id: string, fields: Partial<ActualTransaction>): Promise<unknown>;
33
+ choose?(transaction: ActualTransaction, result: Classification): Promise<string | null>;
34
+ print(line: string): void;
35
+ color?: boolean;
36
+ }
37
+ /** Process grouped ActualQL rows, classifying each split child once. */
38
+ export declare function runCategorization(port: WorkflowPort, options: RunOptions): Promise<RunSummary>;
@@ -0,0 +1,102 @@
1
+ import { decision, describeSuggestion } from './output.js';
2
+ function isSkippableTransfer(transaction, transferPayeeAccountIds, accountById, transactionById) {
3
+ const targetAccountId = (transaction.payee && transferPayeeAccountIds.get(transaction.payee)) ||
4
+ (transaction.transfer_id && transactionById.get(transaction.transfer_id)?.account);
5
+ if (targetAccountId && accountById.get(targetAccountId)?.offbudget)
6
+ return false;
7
+ return Boolean(transaction.transfer_id || (transaction.payee && transferPayeeAccountIds.has(transaction.payee)));
8
+ }
9
+ function inRange(transaction, options) {
10
+ return (!options.from || transaction.date >= options.from) && (!options.to || transaction.date <= options.to);
11
+ }
12
+ /** Process grouped ActualQL rows, classifying each split child once. */
13
+ export async function runCategorization(port, options) {
14
+ const accountById = new Map(port.accounts.map((account) => [account.id, account]));
15
+ const selectedAccounts = options.account
16
+ ? port.accounts.filter((account) => account.id === options.account || account.name === options.account)
17
+ : port.accounts;
18
+ if (options.account && selectedAccounts.length !== 1) {
19
+ throw new Error(`Account must match exactly one ID or name: ${options.account}`);
20
+ }
21
+ const allowedAccounts = new Set(selectedAccounts.filter((account) => !account.offbudget).map((account) => account.id));
22
+ const transactionById = new Map(port.transactions
23
+ .flatMap((transaction) => [transaction, ...(transaction.subtransactions ?? [])])
24
+ .map((transaction) => [transaction.id, transaction]));
25
+ const categoryById = new Map(port.categories.map((category) => [category.id, category]));
26
+ const summary = { examined: 0, applied: 0, wouldApply: 0, skipped: 0, transfersSkipped: 0 };
27
+ const color = Boolean(port.color);
28
+ async function process(transaction, accountName) {
29
+ summary.examined++;
30
+ const result = await port.classify({ ...transaction, accountName });
31
+ const suggestion = result.categoryId ? categoryById.get(result.categoryId) : undefined;
32
+ if (result.categoryId && !suggestion)
33
+ throw new Error('Classifier returned a category outside the visible catalog');
34
+ const line = describeSuggestion(transaction, accountName, suggestion, result.confidence, port.payeeNames, color);
35
+ let selected = result.categoryId;
36
+ let skipped = 'Skipped';
37
+ const interactive = options.mode === 'interactive';
38
+ if (interactive) {
39
+ if (!port.choose)
40
+ throw new Error('Interactive mode requires a choice handler');
41
+ port.print(line);
42
+ selected = await port.choose(transaction, result);
43
+ if (selected && !categoryById.has(selected))
44
+ throw new Error('Selected category is outside the visible catalog');
45
+ }
46
+ else if (!selected || result.requiresReview || result.confidence < options.threshold) {
47
+ skipped += !selected
48
+ ? ' · no match'
49
+ : result.requiresReview
50
+ ? ' · conflicting evidence'
51
+ : ' · below threshold';
52
+ selected = null;
53
+ }
54
+ let message;
55
+ let applied = false;
56
+ if (!selected) {
57
+ summary.skipped++;
58
+ message = skipped;
59
+ }
60
+ else if (options.mode === 'dry-run') {
61
+ summary.wouldApply++;
62
+ message = 'Would apply';
63
+ }
64
+ else {
65
+ await port.updateTransaction(transaction.id, { category: selected });
66
+ summary.applied++;
67
+ applied = true;
68
+ const category = categoryById.get(selected);
69
+ message = interactive ? `Applied ${category.groupName} / ${category.name}` : 'Applied';
70
+ }
71
+ port.print(`${interactive ? '' : `${line}\n`}${decision(message, color, applied)}`);
72
+ }
73
+ for (const transaction of port.transactions) {
74
+ if (!allowedAccounts.has(transaction.account))
75
+ continue;
76
+ const accountName = accountById.get(transaction.account)?.name ?? transaction.account;
77
+ const children = transaction.subtransactions?.length ? transaction.subtransactions : undefined;
78
+ if (!children && transaction.is_child)
79
+ continue;
80
+ const parentTransfer = isSkippableTransfer(transaction, port.transferPayeeAccountIds, accountById, transactionById);
81
+ for (const row of children ?? [transaction]) {
82
+ if (row.category || !inRange(row, options))
83
+ continue;
84
+ if (parentTransfer ||
85
+ (children && isSkippableTransfer(row, port.transferPayeeAccountIds, accountById, transactionById))) {
86
+ summary.transfersSkipped++;
87
+ continue;
88
+ }
89
+ // Eligibility uses the original child; inherit display/classification details afterward.
90
+ const candidate = children
91
+ ? {
92
+ ...row,
93
+ payee: row.payee ?? transaction.payee,
94
+ imported_payee: row.imported_payee ?? transaction.imported_payee,
95
+ date: row.date ?? transaction.date,
96
+ }
97
+ : row;
98
+ await process(candidate, accountName);
99
+ }
100
+ }
101
+ return summary;
102
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,284 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { runCategorization } from './workflow.js';
4
+ const category = { id: 'groceries', name: 'Groceries', groupName: 'Food' };
5
+ const base = { account: 'checking', date: '2026-09-01', amount: -1000 };
6
+ function fixture(confidence = 0.95) {
7
+ const updates = [];
8
+ const lines = [];
9
+ const port = {
10
+ accounts: [{ id: 'checking', name: 'Checking' }],
11
+ transactions: [
12
+ { ...base, id: 'ordinary', payee: 'shop' },
13
+ { ...base, id: 'transfer', payee: 'transfer-payee', transfer_id: 'other-side' },
14
+ {
15
+ ...base,
16
+ id: 'split',
17
+ is_parent: true,
18
+ amount: -3000,
19
+ subtransactions: [
20
+ { ...base, id: 'child-1', parent_id: 'split', is_child: true, amount: -1000 },
21
+ { ...base, id: 'child-2', parent_id: 'split', is_child: true, amount: -2000, category: 'rent' },
22
+ ],
23
+ },
24
+ ],
25
+ transferPayeeAccountIds: new Map([['transfer-payee', 'checking']]),
26
+ payeeNames: new Map([['shop', 'shop']]),
27
+ categories: [category],
28
+ classify() {
29
+ return Promise.resolve({
30
+ categoryId: category.id,
31
+ confidence,
32
+ candidates: [{ ...category, probability: 0.95 }],
33
+ noMatchProbability: 0.05,
34
+ });
35
+ },
36
+ updateTransaction(id, fields) {
37
+ updates.push({ id, fields });
38
+ return Promise.resolve();
39
+ },
40
+ print(line) {
41
+ lines.push(line);
42
+ },
43
+ };
44
+ return { port, updates, lines };
45
+ }
46
+ void test('automatic mode updates ordinary transactions and only eligible split children', async () => {
47
+ const { port, updates, lines } = fixture();
48
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
49
+ assert.equal(summary.applied, 2);
50
+ assert.equal(summary.transfersSkipped, 1);
51
+ assert.deepEqual(updates.map((update) => update.id), ['ordinary', 'child-1']);
52
+ assert.deepEqual(updates[0]?.fields, { category: 'groceries' });
53
+ assert.deepEqual(updates[1]?.fields, { category: 'groceries' });
54
+ assert.equal(lines.filter((line) => /Decision\s+Applied/.test(line)).length, 2);
55
+ });
56
+ void test('skips off-budget accounts before classifying ordinary or split transactions', async () => {
57
+ const { port, updates, lines } = fixture();
58
+ port.accounts = [...port.accounts, { id: 'tracking', name: 'Tracking', offbudget: true }];
59
+ port.transactions = [
60
+ ...port.transactions,
61
+ { ...base, id: 'off-budget', account: 'tracking' },
62
+ {
63
+ ...base,
64
+ id: 'off-budget-split',
65
+ account: 'tracking',
66
+ is_parent: true,
67
+ subtransactions: [{ ...base, id: 'off-budget-child', account: 'tracking', is_child: true }],
68
+ },
69
+ ];
70
+ const classified = [];
71
+ const classify = port.classify.bind(port);
72
+ port.classify = (transaction) => {
73
+ classified.push(transaction.id);
74
+ return classify(transaction);
75
+ };
76
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
77
+ assert.equal(summary.examined, 2);
78
+ assert.deepEqual(classified, ['ordinary', 'child-1']);
79
+ assert.deepEqual(updates.map((update) => update.id), ['ordinary', 'child-1']);
80
+ assert.equal(lines.length, 2);
81
+ });
82
+ void test('categorizes on-budget transfers to off-budget accounts but skips their off-budget sides', async () => {
83
+ const { port, updates } = fixture();
84
+ port.accounts = [...port.accounts, { id: 'tracking', name: 'Tracking', offbudget: true }];
85
+ port.transferPayeeAccountIds = new Map([
86
+ ['transfer-payee', 'checking'],
87
+ ['tracking-payee', 'tracking'],
88
+ ]);
89
+ port.transactions = [
90
+ ...port.transactions,
91
+ { ...base, id: 'to-tracking', payee: 'tracking-payee', transfer_id: 'tracking-side' },
92
+ { ...base, id: 'tracking-side', account: 'tracking', transfer_id: 'to-tracking' },
93
+ { ...base, id: 'to-tracking-by-id', transfer_id: 'tracking-side-by-id' },
94
+ { ...base, id: 'tracking-side-by-id', account: 'tracking', transfer_id: 'to-tracking-by-id' },
95
+ ];
96
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
97
+ assert.equal(summary.applied, 4);
98
+ assert.equal(summary.transfersSkipped, 1);
99
+ assert.deepEqual(updates.map((update) => update.id), ['ordinary', 'child-1', 'to-tracking', 'to-tracking-by-id']);
100
+ });
101
+ void test('dry-run simulates auto without any writes', async () => {
102
+ const { port, updates, lines } = fixture();
103
+ const summary = await runCategorization(port, { mode: 'dry-run', threshold: 0.9 });
104
+ assert.equal(summary.wouldApply, 2);
105
+ assert.deepEqual(updates, []);
106
+ assert.equal(lines.length, 2);
107
+ assert.deepEqual((lines[0] ?? '').split('\n').slice(0, 4), ['', ' -10.00', ' shop', ' 2026-09-01 · Checking']);
108
+ assert.match(lines[0] ?? '', /Suggestion\s+Food \/ Groceries · 95% confidence/);
109
+ assert.match(lines[0] ?? '', /Decision\s+Would apply/);
110
+ });
111
+ void test('weak suggestions are skipped automatically but can be chosen interactively', async () => {
112
+ const { port, updates, lines } = fixture(0.4);
113
+ const automatic = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
114
+ assert.equal(automatic.applied, 0);
115
+ assert.equal(lines.filter((line) => line.includes('Skipped · below threshold')).length, 2);
116
+ port.choose = (transaction) => Promise.resolve(transaction.id === 'ordinary' ? 'groceries' : null);
117
+ const interactive = await runCategorization(port, { mode: 'interactive', threshold: 0.9 });
118
+ assert.equal(interactive.applied, 1);
119
+ assert.ok(lines.some((line) => /Decision\s+Applied Food \/ Groceries/.test(line)));
120
+ assert.ok(lines.some((line) => /Decision\s+Skipped/.test(line)));
121
+ assert.deepEqual(updates.map((update) => update.id), ['ordinary']);
122
+ });
123
+ void test('conflicting evidence blocks automatic writes but remains selectable interactively', async () => {
124
+ const { port, updates, lines } = fixture(0.99);
125
+ const classify = port.classify.bind(port);
126
+ port.classify = async (transaction) => ({ ...(await classify(transaction)), requiresReview: true });
127
+ const automatic = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
128
+ assert.equal(automatic.applied, 0);
129
+ assert.ok(lines.some((line) => line.includes('conflicting evidence')));
130
+ port.choose = () => Promise.resolve('groceries');
131
+ const interactive = await runCategorization(port, { mode: 'interactive', threshold: 0.9 });
132
+ assert.equal(interactive.applied, 2);
133
+ assert.equal(updates.length, 2);
134
+ });
135
+ void test('no-match decisions are visible without making writes', async () => {
136
+ const { port, updates, lines } = fixture();
137
+ port.classify = () => Promise.resolve({ categoryId: null, confidence: 0.8, candidates: [], noMatchProbability: 0.8 });
138
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9 });
139
+ assert.equal(summary.skipped, 2);
140
+ assert.deepEqual(updates, []);
141
+ assert.equal(lines.filter((line) => /Suggestion\s+no match · 80% confidence/.test(line)).length, 2);
142
+ assert.equal(lines.filter((line) => /Decision\s+Skipped · no match/.test(line)).length, 2);
143
+ });
144
+ void test('presents the merchant from a verbose card description', async () => {
145
+ const { port, lines } = fixture(0.53);
146
+ port.transactions = [
147
+ {
148
+ ...base,
149
+ id: 'card',
150
+ date: '2026-09-23',
151
+ amount: -660,
152
+ imported_payee: 'Operazione Mastercard Del 21/09/2026 Alle Ore 08:10 Con Carta Xxxxxxxxxxxx0230 Div=Eur Importo in Divisa=6.6 / Importo in Euro=6.6 Presso Pasticceria San Marone - Transazione C-Less',
153
+ },
154
+ ];
155
+ const summary = await runCategorization(port, { mode: 'dry-run', threshold: 0.9 });
156
+ assert.equal(summary.skipped, 1);
157
+ assert.deepEqual((lines[0] ?? '').split('\n').slice(0, 4), [
158
+ '',
159
+ ' -6.60',
160
+ ' Pasticceria San Marone',
161
+ ' 2026-09-23 · Checking',
162
+ ]);
163
+ assert.doesNotMatch(lines[0] ?? '', /Xxxxxxxxxxxx0230/);
164
+ });
165
+ void test('wraps long unrecognized payees without dropping their text', async () => {
166
+ const { port, lines } = fixture();
167
+ const description = 'A long imported description with no merchant marker '.repeat(3).trim();
168
+ port.transactions = [{ ...base, id: 'long-payee', imported_payee: description }];
169
+ await runCategorization(port, { mode: 'dry-run', threshold: 0.9 });
170
+ assert.ok((lines[0] ?? '').includes('\n A long imported description'));
171
+ assert.match(lines[0] ?? '', /merchant marker$/m);
172
+ assert.ok((lines[0] ?? '').split('\n').filter((line) => line.includes('merchant marker')).length > 1);
173
+ });
174
+ void test('uses emphasis only for terminal output', async () => {
175
+ const { port, lines } = fixture();
176
+ port.color = true;
177
+ await runCategorization(port, { mode: 'dry-run', threshold: 0.9 });
178
+ assert.ok((lines[0] ?? '').includes('\u001b[1;36m-10.00\u001b[0m'));
179
+ assert.ok((lines[0] ?? '').includes('\u001b[1m shop\u001b[0m'));
180
+ assert.ok((lines[0] ?? '').includes('\u001b[36mFood / Groceries\u001b[0m'));
181
+ });
182
+ void test('filters split children before inheriting details and skips duplicate top-level children', async () => {
183
+ const { port, updates } = fixture();
184
+ const child = { ...base, id: 'child', is_child: true };
185
+ port.transactions = [
186
+ {
187
+ ...base,
188
+ id: 'parent',
189
+ payee: 'shop',
190
+ imported_payee: 'Imported shop',
191
+ subtransactions: [child, { ...child, id: 'old', date: '2026-08-31' }],
192
+ },
193
+ child,
194
+ { ...base, id: 'later', date: '2026-09-02' },
195
+ ];
196
+ const classified = [];
197
+ const classify = port.classify.bind(port);
198
+ port.classify = (transaction) => {
199
+ classified.push(transaction);
200
+ return classify(transaction);
201
+ };
202
+ const summary = await runCategorization(port, {
203
+ mode: 'auto',
204
+ threshold: 0.95,
205
+ account: 'Checking',
206
+ from: base.date,
207
+ to: base.date,
208
+ });
209
+ assert.deepEqual(summary, { examined: 1, applied: 1, wouldApply: 0, skipped: 0, transfersSkipped: 0 });
210
+ assert.deepEqual(classified, [
211
+ { ...child, payee: 'shop', imported_payee: 'Imported shop', accountName: 'Checking' },
212
+ ]);
213
+ assert.deepEqual(updates, [{ id: 'child', fields: { category: 'groceries' } }]);
214
+ });
215
+ void test('counts eligible split transfers once using both parent and child transfer metadata', async () => {
216
+ const { port, updates } = fixture();
217
+ port.accounts = [...port.accounts, { id: 'tracking', name: 'Tracking', offbudget: true }];
218
+ port.transferPayeeAccountIds = new Map([
219
+ ['internal', 'checking'],
220
+ ['external', 'tracking'],
221
+ ]);
222
+ const child = { ...base, is_child: true };
223
+ port.transactions = [
224
+ {
225
+ ...base,
226
+ id: 'internal-parent',
227
+ payee: 'internal',
228
+ subtransactions: [
229
+ { ...child, id: 'inherited-transfer' },
230
+ { ...child, id: 'categorized', category: 'rent' },
231
+ { ...child, id: 'old', date: '2026-08-31' },
232
+ ],
233
+ },
234
+ {
235
+ ...base,
236
+ id: 'external-parent',
237
+ payee: 'external',
238
+ subtransactions: [
239
+ { ...child, id: 'eligible' },
240
+ { ...child, id: 'child-transfer', payee: 'internal' },
241
+ { ...child, id: 'unresolved-transfer', transfer_id: 'missing' },
242
+ ],
243
+ },
244
+ ];
245
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9, from: base.date });
246
+ assert.deepEqual(summary, { examined: 1, applied: 1, wouldApply: 0, skipped: 0, transfersSkipped: 3 });
247
+ assert.deepEqual(updates, [{ id: 'eligible', fields: { category: 'groceries' } }]);
248
+ });
249
+ void test('requires a unique account match and excludes explicitly selected off-budget accounts', async () => {
250
+ const { port } = fixture();
251
+ await assert.rejects(runCategorization(port, { mode: 'auto', threshold: 0.9, account: 'missing' }), /exactly one/);
252
+ port.accounts = [...port.accounts, { id: 'tracking', name: 'Checking', offbudget: true }];
253
+ await assert.rejects(runCategorization(port, { mode: 'auto', threshold: 0.9, account: 'Checking' }), /exactly one/);
254
+ const summary = await runCategorization(port, { mode: 'auto', threshold: 0.9, account: 'tracking' });
255
+ assert.equal(summary.examined, 0);
256
+ });
257
+ void test('rejects invalid suggestions and interactive choices before writing', async () => {
258
+ const { port, updates } = fixture();
259
+ const classify = port.classify.bind(port);
260
+ port.classify = async (transaction) => ({ ...(await classify(transaction)), categoryId: 'unknown' });
261
+ await assert.rejects(runCategorization(port, { mode: 'auto', threshold: 0.9 }), /Classifier returned/);
262
+ port.classify = classify;
263
+ await assert.rejects(runCategorization(port, { mode: 'interactive', threshold: 0.9 }), /choice handler/);
264
+ port.choose = () => Promise.resolve('unknown');
265
+ await assert.rejects(runCategorization(port, { mode: 'interactive', threshold: 0.9 }), /Selected category/);
266
+ assert.deepEqual(updates, []);
267
+ });
268
+ void test('prints the suggestion before prompting and stops on a failed write', async () => {
269
+ const { port, lines } = fixture();
270
+ const events = [];
271
+ port.choose = () => {
272
+ assert.match(lines.at(-1) ?? '', /Suggestion/);
273
+ events.push('choose');
274
+ return Promise.resolve('groceries');
275
+ };
276
+ port.updateTransaction = () => {
277
+ events.push('write');
278
+ return Promise.reject(new Error('write failed'));
279
+ };
280
+ await assert.rejects(runCategorization(port, { mode: 'interactive', threshold: 0.9 }), /write failed/);
281
+ assert.deepEqual(events, ['choose', 'write']);
282
+ assert.equal(lines.length, 1);
283
+ assert.doesNotMatch(lines[0] ?? '', /Decision/);
284
+ });