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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/actual-jev.test.d.ts +1 -0
- package/dist/actual-jev.test.js +111 -0
- package/dist/actual.d.ts +47 -0
- package/dist/actual.js +148 -0
- package/dist/actual.test.d.ts +1 -0
- package/dist/actual.test.js +139 -0
- package/dist/args.d.ts +9 -0
- package/dist/args.js +90 -0
- package/dist/args.test.d.ts +1 -0
- package/dist/args.test.js +84 -0
- package/dist/choices.d.ts +8 -0
- package/dist/choices.js +26 -0
- package/dist/choices.test.d.ts +1 -0
- package/dist/choices.test.js +29 -0
- package/dist/classifier.d.ts +56 -0
- package/dist/classifier.js +147 -0
- package/dist/classifier.test.d.ts +1 -0
- package/dist/classifier.test.js +257 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +169 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +130 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.js +135 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +98 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/output.d.ts +4 -0
- package/dist/output.js +36 -0
- package/dist/setup.d.ts +30 -0
- package/dist/setup.js +84 -0
- package/dist/setup.test.d.ts +1 -0
- package/dist/setup.test.js +138 -0
- package/dist/workflow.d.ts +38 -0
- package/dist/workflow.js +102 -0
- package/dist/workflow.test.d.ts +1 -0
- package/dist/workflow.test.js +284 -0
- package/docs/library.md +44 -0
- package/package.json +51 -0
package/.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Fill in these values and run actual-jev --env-file .env --dry-run.
|
|
2
|
+
ACTUAL_SERVER_URL=http://localhost:5006
|
|
3
|
+
ACTUAL_PASSWORD=your-actual-password
|
|
4
|
+
ACTUAL_SYNC_ID=your-budget-sync-id
|
|
5
|
+
# Leave empty unless the budget uses end-to-end encryption.
|
|
6
|
+
ACTUAL_ENCRYPTION_PASSWORD=
|
|
7
|
+
TYPESAFE_API_KEY=your-typesafe-api-key
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alessandro Cifani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# actual-jev
|
|
2
|
+
|
|
3
|
+
Categorize uncategorized [Actual Budget](https://actualbudget.org/) transactions with [TypeSafe Jev](https://docs.typesafe.ai/sdk/javascript).
|
|
4
|
+
|
|
5
|
+
## CLI
|
|
6
|
+
|
|
7
|
+
Requires Node.js 24 (24.10 or newer), an Actual server, and a TypeSafe API key.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
actual-jev setup # Connect to Actual, choose a budget, save credentials
|
|
11
|
+
actual-jev --dry-run # Preview suggestions without changing transactions
|
|
12
|
+
actual-jev # Review each suggestion and choose a category or skip
|
|
13
|
+
actual-jev --auto # Apply suggestions with confidence of at least 0.9
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Setup saves your settings locally, so the CLI works from any folder. Rerun `setup` to change them. `actual-jev config show` displays settings and storage paths with secrets hidden.
|
|
17
|
+
|
|
18
|
+
Use `--threshold` to change automatic confidence, `--account`, `--from`, and `--to` to narrow the scan, or `--max-examples-per-category 0` to omit historical examples. See `actual-jev --help` for all options.
|
|
19
|
+
|
|
20
|
+
The CLI handles on-budget transactions and uncategorized split items, skips internal transfers, and leaves conflicting payee history for manual review in automatic mode. Predictions send TypeSafe transaction details, category names and notes, and relevant categorized history (up to three examples per category by default).
|
|
21
|
+
|
|
22
|
+
## Automation
|
|
23
|
+
|
|
24
|
+
Supply the connection settings in [.env.example](.env.example) as exported environment variables or an explicit environment file:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
actual-jev --auto # Use exported variables
|
|
28
|
+
actual-jev --env-file .env --auto # Read variables from a file
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Either uses environment configuration independently of saved setup. Supply all required connection settings; exported values override matching values in the file. Find your budget's sync ID under **Actual → Settings → Show advanced settings → Sync ID**.
|
|
32
|
+
|
|
33
|
+
## Library
|
|
34
|
+
|
|
35
|
+
Pass initialized Actual and TypeSafe clients to `ActualJev`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const classifier = new ActualJev({ actual, jev });
|
|
39
|
+
const result = await classifier.classify({ payee_name: 'Fresh Market', amount: -2350 });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The library returns suggestions without updating Actual. See [library usage](docs/library.md) for complete examples, including supplying categories without an Actual server.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { q } from '@actual-app/api';
|
|
4
|
+
import { ActualJev } from './actual.js';
|
|
5
|
+
function jev(states) {
|
|
6
|
+
return {
|
|
7
|
+
systemOne(request) {
|
|
8
|
+
states.push(request.state);
|
|
9
|
+
return Promise.resolve({
|
|
10
|
+
answers: {
|
|
11
|
+
category: {
|
|
12
|
+
choice: 'category_0',
|
|
13
|
+
confidence: 0.9,
|
|
14
|
+
probabilities: { category_0: 0.9, none_of_the_above: 0.1 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
void test('loads Actual data once on concurrent first calls and refreshes the snapshot', async () => {
|
|
22
|
+
let revision = 1;
|
|
23
|
+
let queries = 0;
|
|
24
|
+
const states = [];
|
|
25
|
+
const actual = {
|
|
26
|
+
q,
|
|
27
|
+
aqlQuery(query) {
|
|
28
|
+
assert.equal(query.state.table, 'transactions');
|
|
29
|
+
queries++;
|
|
30
|
+
return Promise.resolve({
|
|
31
|
+
data: [
|
|
32
|
+
{
|
|
33
|
+
id: 'old',
|
|
34
|
+
account: 'checking',
|
|
35
|
+
date: '2026-09-01',
|
|
36
|
+
amount: -1000,
|
|
37
|
+
payee: 'shop',
|
|
38
|
+
category: `category-${revision}`,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'tracking',
|
|
42
|
+
account: 'tracking',
|
|
43
|
+
date: '2026-09-01',
|
|
44
|
+
amount: -1000,
|
|
45
|
+
payee: 'shop',
|
|
46
|
+
category: `category-${revision}`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
getAccounts: () => Promise.resolve([
|
|
52
|
+
{ id: 'checking', name: 'Checking', offbudget: false },
|
|
53
|
+
{ id: 'tracking', name: 'Tracking', offbudget: true },
|
|
54
|
+
]),
|
|
55
|
+
getCategoryGroups: () => Promise.resolve([
|
|
56
|
+
{
|
|
57
|
+
id: 'food',
|
|
58
|
+
name: 'Food',
|
|
59
|
+
categories: [{ id: `category-${revision}`, name: 'Groceries', group_id: 'food' }],
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
getPayees: () => Promise.resolve([{ id: 'shop', name: 'Fresh Market' }]),
|
|
63
|
+
getNote: () => Promise.resolve(null),
|
|
64
|
+
};
|
|
65
|
+
const classifier = new ActualJev({ actual, jev: jev(states) });
|
|
66
|
+
assert.deepEqual(classifier.categories, []);
|
|
67
|
+
const input = { id: 'new', account: 'checking', payee: 'shop', amount: -2350 };
|
|
68
|
+
const results = await Promise.all([classifier.classify(input), classifier.classify(input)]);
|
|
69
|
+
assert.equal(queries, 1);
|
|
70
|
+
assert.deepEqual(results.map((result) => result.categoryId), ['category-1', 'category-1']);
|
|
71
|
+
const state = states[0];
|
|
72
|
+
assert.equal(state.transaction.account, 'Checking');
|
|
73
|
+
assert.equal(state.transaction.payee, 'Fresh Market');
|
|
74
|
+
assert.equal(state.relevant_examples.length, 1);
|
|
75
|
+
revision = 2;
|
|
76
|
+
await classifier.refresh();
|
|
77
|
+
assert.equal(queries, 2);
|
|
78
|
+
assert.equal((await classifier.classify(input)).categoryId, 'category-2');
|
|
79
|
+
});
|
|
80
|
+
void test('retries a failed Actual load', async () => {
|
|
81
|
+
let queries = 0;
|
|
82
|
+
const actual = {
|
|
83
|
+
q,
|
|
84
|
+
aqlQuery() {
|
|
85
|
+
queries++;
|
|
86
|
+
if (queries === 1)
|
|
87
|
+
return Promise.reject(new Error('query failed'));
|
|
88
|
+
return Promise.resolve({ data: [] });
|
|
89
|
+
},
|
|
90
|
+
getAccounts: () => Promise.resolve([]),
|
|
91
|
+
getCategoryGroups: () => Promise.resolve([]),
|
|
92
|
+
getPayees: () => Promise.resolve([]),
|
|
93
|
+
getNote: () => Promise.resolve(null),
|
|
94
|
+
};
|
|
95
|
+
const classifier = new ActualJev({ actual, jev: jev([]) });
|
|
96
|
+
await assert.rejects(classifier.classify({}), /query failed/);
|
|
97
|
+
assert.equal((await classifier.classify({})).categoryId, null);
|
|
98
|
+
assert.equal(queries, 2);
|
|
99
|
+
});
|
|
100
|
+
void test('uses manual constructor data without an Actual client', async () => {
|
|
101
|
+
const states = [];
|
|
102
|
+
const classifier = new ActualJev({
|
|
103
|
+
jev: jev(states),
|
|
104
|
+
categories: [{ id: 'groceries', name: 'Groceries', groupName: 'Food' }],
|
|
105
|
+
examples: [{ categoryId: 'groceries', payeeName: 'Fresh Market' }],
|
|
106
|
+
});
|
|
107
|
+
await classifier.refresh();
|
|
108
|
+
const result = await classifier.classify({ payeeName: 'Fresh Market', amount: -2350 });
|
|
109
|
+
assert.equal(result.categoryId, 'groceries');
|
|
110
|
+
assert.equal(states[0].relevant_examples.length, 1);
|
|
111
|
+
});
|
package/dist/actual.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type * as ActualApi from '@actual-app/api';
|
|
2
|
+
import { type CategoryCandidate, type CategorizedExample, type Classification, type ClassifierOptions, type JevChoiceClient, type TransactionDetails } from './classifier.js';
|
|
3
|
+
export type ActualTransaction = Awaited<ReturnType<typeof ActualApi.getTransactions>>[number];
|
|
4
|
+
export type ActualClient = Pick<typeof ActualApi, 'getCategoryGroups' | 'getPayees' | 'getNote'>;
|
|
5
|
+
export type ActualDataClient = ActualClient & Pick<typeof ActualApi, 'getAccounts' | 'aqlQuery' | 'q'>;
|
|
6
|
+
export type ActualTransactionInput = Partial<ActualTransaction> & {
|
|
7
|
+
payee_name?: string;
|
|
8
|
+
accountName?: string;
|
|
9
|
+
payeeDefaultCategoryId?: string;
|
|
10
|
+
};
|
|
11
|
+
export interface ActualClassifier {
|
|
12
|
+
readonly categories: readonly CategoryCandidate[];
|
|
13
|
+
classify(transaction: ActualTransactionInput): Promise<Classification>;
|
|
14
|
+
refresh(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export interface ActualClassifierOptions extends ClassifierOptions {
|
|
17
|
+
history?: readonly ActualTransaction[];
|
|
18
|
+
eligibleAccountIds?: ReadonlySet<string>;
|
|
19
|
+
}
|
|
20
|
+
interface JevConfig {
|
|
21
|
+
jev: JevChoiceClient;
|
|
22
|
+
model?: string;
|
|
23
|
+
maxExamplesPerCategory?: number;
|
|
24
|
+
}
|
|
25
|
+
export type ActualJevConfig = JevConfig & ({
|
|
26
|
+
actual: ActualDataClient;
|
|
27
|
+
categories?: never;
|
|
28
|
+
examples?: never;
|
|
29
|
+
} | {
|
|
30
|
+
actual?: never;
|
|
31
|
+
categories: readonly CategoryCandidate[];
|
|
32
|
+
examples?: readonly CategorizedExample[];
|
|
33
|
+
});
|
|
34
|
+
/** Reuses a loaded category catalog when classifying several imported transactions. */
|
|
35
|
+
export declare function createActualClassifier(actual: ActualClient, options?: ActualClassifierOptions): Promise<ActualClassifier>;
|
|
36
|
+
/** Classifies with either caller-supplied data or a lazily loaded Actual budget snapshot. */
|
|
37
|
+
export declare class ActualJev {
|
|
38
|
+
private readonly config;
|
|
39
|
+
private snapshot;
|
|
40
|
+
private loading;
|
|
41
|
+
constructor(config: ActualJevConfig);
|
|
42
|
+
get categories(): readonly CategoryCandidate[];
|
|
43
|
+
private load;
|
|
44
|
+
refresh(): Promise<void>;
|
|
45
|
+
classify(transaction: ActualTransactionInput | TransactionDetails): Promise<Classification>;
|
|
46
|
+
}
|
|
47
|
+
export {};
|
package/dist/actual.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { classifyTransaction, } from './classifier.js';
|
|
2
|
+
function usefulNote(note) {
|
|
3
|
+
const text = note
|
|
4
|
+
?.split('\n')
|
|
5
|
+
.filter((line) => !/^\s*#(?:template|goal)\b/i.test(line))
|
|
6
|
+
.join('\n')
|
|
7
|
+
.trim();
|
|
8
|
+
return text || undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Reuses a loaded category catalog when classifying several imported transactions. */
|
|
11
|
+
export async function createActualClassifier(actual, options = {}) {
|
|
12
|
+
let categories = [];
|
|
13
|
+
let payees = new Map();
|
|
14
|
+
let examples = [];
|
|
15
|
+
async function refresh() {
|
|
16
|
+
const [groups, currentPayees] = await Promise.all([
|
|
17
|
+
actual.getCategoryGroups({ hidden: false }),
|
|
18
|
+
actual.getPayees(),
|
|
19
|
+
]);
|
|
20
|
+
const visible = groups.flatMap((group) => (group.categories ?? []).map((category) => ({
|
|
21
|
+
id: category.id,
|
|
22
|
+
name: category.name,
|
|
23
|
+
groupName: group.name,
|
|
24
|
+
isIncome: category.is_income ?? group.is_income ?? false,
|
|
25
|
+
})));
|
|
26
|
+
categories = await Promise.all(visible.map(async (category) => ({
|
|
27
|
+
...category,
|
|
28
|
+
note: usefulNote((await actual.getNote(category.id))?.note),
|
|
29
|
+
})));
|
|
30
|
+
payees = new Map(currentPayees.map((payee) => [payee.id, payee.name]));
|
|
31
|
+
const transferPayees = new Set(currentPayees.filter((payee) => payee.transfer_acct).map((payee) => payee.id));
|
|
32
|
+
const visibleIds = new Set(categories.map((category) => category.id));
|
|
33
|
+
examples = [...(options.examples ?? [])];
|
|
34
|
+
for (const row of options.history ?? []) {
|
|
35
|
+
if (options.eligibleAccountIds && !options.eligibleAccountIds.has(row.account))
|
|
36
|
+
continue;
|
|
37
|
+
for (const transaction of row.subtransactions?.length ? row.subtransactions : [row]) {
|
|
38
|
+
if (!transaction.category ||
|
|
39
|
+
!visibleIds.has(transaction.category) ||
|
|
40
|
+
row.transfer_id ||
|
|
41
|
+
transaction.transfer_id ||
|
|
42
|
+
row.is_child)
|
|
43
|
+
continue;
|
|
44
|
+
const payee = transaction.payee ?? row.payee;
|
|
45
|
+
if (payee && transferPayees.has(payee))
|
|
46
|
+
continue;
|
|
47
|
+
examples.push({
|
|
48
|
+
id: transaction.id,
|
|
49
|
+
categoryId: transaction.category,
|
|
50
|
+
payeeName: payee ? payees.get(payee) : undefined,
|
|
51
|
+
importedPayee: transaction.imported_payee ?? row.imported_payee,
|
|
52
|
+
notes: transaction.notes ?? row.notes,
|
|
53
|
+
amount: transaction.amount,
|
|
54
|
+
date: transaction.date ?? row.date,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await refresh();
|
|
60
|
+
return {
|
|
61
|
+
get categories() {
|
|
62
|
+
return categories;
|
|
63
|
+
},
|
|
64
|
+
refresh,
|
|
65
|
+
classify(transaction) {
|
|
66
|
+
const details = {
|
|
67
|
+
id: transaction.id,
|
|
68
|
+
payeeName: transaction.payee_name ?? (transaction.payee ? payees.get(transaction.payee) : undefined),
|
|
69
|
+
importedPayee: transaction.imported_payee,
|
|
70
|
+
notes: transaction.notes,
|
|
71
|
+
amount: transaction.amount,
|
|
72
|
+
date: transaction.date,
|
|
73
|
+
accountName: transaction.accountName,
|
|
74
|
+
payeeDefaultCategoryId: transaction.payeeDefaultCategoryId,
|
|
75
|
+
};
|
|
76
|
+
return classifyTransaction(details, categories, { ...options, examples });
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Classifies with either caller-supplied data or a lazily loaded Actual budget snapshot. */
|
|
81
|
+
export class ActualJev {
|
|
82
|
+
config;
|
|
83
|
+
snapshot;
|
|
84
|
+
loading;
|
|
85
|
+
constructor(config) {
|
|
86
|
+
this.config = config;
|
|
87
|
+
if (!config.jev)
|
|
88
|
+
throw new TypeError('A configured Jev client is required');
|
|
89
|
+
}
|
|
90
|
+
get categories() {
|
|
91
|
+
return 'actual' in this.config ? (this.snapshot?.classifier.categories ?? []) : this.config.categories;
|
|
92
|
+
}
|
|
93
|
+
async load() {
|
|
94
|
+
if (!this.config.actual)
|
|
95
|
+
throw new TypeError('Manual data cannot be refreshed from Actual');
|
|
96
|
+
const actual = this.config.actual;
|
|
97
|
+
const [accounts, queryResult] = await Promise.all([
|
|
98
|
+
actual.getAccounts(),
|
|
99
|
+
actual.aqlQuery(actual.q('transactions').select('*').options({ splits: 'grouped' })),
|
|
100
|
+
]);
|
|
101
|
+
const history = queryResult.data;
|
|
102
|
+
if (!Array.isArray(history))
|
|
103
|
+
throw new Error('ActualQL did not return transaction rows');
|
|
104
|
+
const classifier = await createActualClassifier(actual, {
|
|
105
|
+
client: this.config.jev,
|
|
106
|
+
model: this.config.model,
|
|
107
|
+
maxExamplesPerCategory: this.config.maxExamplesPerCategory,
|
|
108
|
+
history,
|
|
109
|
+
eligibleAccountIds: new Set(accounts.filter((account) => !account.offbudget).map((account) => account.id)),
|
|
110
|
+
});
|
|
111
|
+
return { classifier, accountNames: new Map(accounts.map((account) => [account.id, account.name])) };
|
|
112
|
+
}
|
|
113
|
+
async refresh() {
|
|
114
|
+
if (!this.config.actual)
|
|
115
|
+
return;
|
|
116
|
+
if (!this.loading) {
|
|
117
|
+
const loading = this.load();
|
|
118
|
+
this.loading = loading;
|
|
119
|
+
void loading.then((snapshot) => {
|
|
120
|
+
this.snapshot = snapshot;
|
|
121
|
+
if (this.loading === loading)
|
|
122
|
+
this.loading = undefined;
|
|
123
|
+
}, () => {
|
|
124
|
+
if (this.loading === loading)
|
|
125
|
+
this.loading = undefined;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
await this.loading;
|
|
129
|
+
}
|
|
130
|
+
async classify(transaction) {
|
|
131
|
+
if (!this.config.actual) {
|
|
132
|
+
return classifyTransaction(transaction, this.config.categories, {
|
|
133
|
+
client: this.config.jev,
|
|
134
|
+
model: this.config.model,
|
|
135
|
+
examples: this.config.examples,
|
|
136
|
+
maxExamplesPerCategory: this.config.maxExamplesPerCategory,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (!this.snapshot)
|
|
140
|
+
await this.refresh();
|
|
141
|
+
const snapshot = this.snapshot;
|
|
142
|
+
const input = transaction;
|
|
143
|
+
return snapshot.classifier.classify({
|
|
144
|
+
...input,
|
|
145
|
+
accountName: input.accountName ?? (input.account ? snapshot.accountNames.get(input.account) : undefined),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { createActualClassifier } from './actual.js';
|
|
4
|
+
void test('loads and refreshes the catalog and payees while preserving transaction mapping', async () => {
|
|
5
|
+
let revision = 1;
|
|
6
|
+
let loads = 0;
|
|
7
|
+
const states = [];
|
|
8
|
+
const actual = {
|
|
9
|
+
getCategoryGroups(options) {
|
|
10
|
+
assert.deepEqual(options, { hidden: false });
|
|
11
|
+
loads++;
|
|
12
|
+
return Promise.resolve([
|
|
13
|
+
{
|
|
14
|
+
id: 'group',
|
|
15
|
+
name: 'Income',
|
|
16
|
+
is_income: true,
|
|
17
|
+
categories: [{ id: `category-${revision}`, name: 'Salary', group_id: 'group' }],
|
|
18
|
+
},
|
|
19
|
+
]);
|
|
20
|
+
},
|
|
21
|
+
getPayees() {
|
|
22
|
+
return Promise.resolve([{ id: 'employer', name: `Employer ${revision}` }]);
|
|
23
|
+
},
|
|
24
|
+
getNote() {
|
|
25
|
+
return Promise.resolve(null);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const client = {
|
|
29
|
+
systemOne(request) {
|
|
30
|
+
assert.equal(request.model, 'custom-model');
|
|
31
|
+
assert.equal(request.questions.category.criteria.category_0, 'Income / Salary (income)');
|
|
32
|
+
states.push(request.state);
|
|
33
|
+
return Promise.resolve({
|
|
34
|
+
answers: {
|
|
35
|
+
category: {
|
|
36
|
+
choice: 'category_0',
|
|
37
|
+
confidence: 0.9,
|
|
38
|
+
probabilities: { category_0: 0.9, none_of_the_above: 0.1 },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const classifier = await createActualClassifier(actual, { client, model: 'custom-model' });
|
|
45
|
+
const transaction = {
|
|
46
|
+
payee: 'employer',
|
|
47
|
+
imported_payee: 'Deposit',
|
|
48
|
+
notes: 'Monthly',
|
|
49
|
+
amount: 10000,
|
|
50
|
+
date: '2026-09-01',
|
|
51
|
+
accountName: 'Checking',
|
|
52
|
+
};
|
|
53
|
+
assert.equal((await classifier.classify(transaction)).categoryId, 'category-1');
|
|
54
|
+
assert.deepEqual(states[0], {
|
|
55
|
+
transaction: {
|
|
56
|
+
payee: 'Employer 1',
|
|
57
|
+
imported_payee: 'Deposit',
|
|
58
|
+
notes: 'Monthly',
|
|
59
|
+
amount_minor_units: 10000,
|
|
60
|
+
date: '2026-09-01',
|
|
61
|
+
account: 'Checking',
|
|
62
|
+
},
|
|
63
|
+
payee_default_category: null,
|
|
64
|
+
relevant_examples: [],
|
|
65
|
+
});
|
|
66
|
+
await classifier.classify({ ...transaction, payee_name: 'Explicit name' });
|
|
67
|
+
assert.equal(states[1]?.transaction.payee, 'Explicit name');
|
|
68
|
+
assert.equal(loads, 1);
|
|
69
|
+
revision = 2;
|
|
70
|
+
await classifier.refresh();
|
|
71
|
+
assert.equal(loads, 2);
|
|
72
|
+
assert.equal(classifier.categories[0]?.id, 'category-2');
|
|
73
|
+
assert.equal((await classifier.classify(transaction)).categoryId, 'category-2');
|
|
74
|
+
assert.equal(states[2]?.transaction.payee, 'Employer 2');
|
|
75
|
+
});
|
|
76
|
+
void test('loads category notes and builds examples only from eligible categorized history', async () => {
|
|
77
|
+
const notes = [];
|
|
78
|
+
const actual = {
|
|
79
|
+
getCategoryGroups: () => Promise.resolve([
|
|
80
|
+
{
|
|
81
|
+
id: 'food',
|
|
82
|
+
name: 'Food',
|
|
83
|
+
categories: [
|
|
84
|
+
{ id: 'groceries', name: 'Groceries', group_id: 'food' },
|
|
85
|
+
{ id: 'restaurants', name: 'Restaurants', group_id: 'food' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
]),
|
|
89
|
+
getPayees: () => Promise.resolve([
|
|
90
|
+
{ id: 'shop', name: 'Fresh Market' },
|
|
91
|
+
{ id: 'transfer', name: 'Transfer', transfer_acct: 'other' },
|
|
92
|
+
]),
|
|
93
|
+
getNote(id) {
|
|
94
|
+
notes.push(id);
|
|
95
|
+
return Promise.resolve(id === 'restaurants' ? { id, note: '#template 250\nPrepared deli meals\n#goal 1000' } : null);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const base = { account: 'checking', date: '2026-09-01', amount: -1000 };
|
|
99
|
+
const history = [
|
|
100
|
+
{
|
|
101
|
+
...base,
|
|
102
|
+
id: 'parent',
|
|
103
|
+
payee: 'shop',
|
|
104
|
+
subtransactions: [
|
|
105
|
+
{ ...base, id: 'grocery', category: 'groceries', is_child: true, notes: 'Produce' },
|
|
106
|
+
{ ...base, id: 'deli', category: 'restaurants', is_child: true, notes: 'Deli lunch' },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{ ...base, id: 'transfer', payee: 'transfer', category: 'groceries' },
|
|
110
|
+
{ ...base, id: 'tracking', account: 'tracking', payee: 'shop', category: 'groceries' },
|
|
111
|
+
{ ...base, id: 'uncategorized', payee: 'shop' },
|
|
112
|
+
];
|
|
113
|
+
const client = {
|
|
114
|
+
systemOne(request) {
|
|
115
|
+
assert.equal(request.questions.category.criteria.category_1, 'Food / Restaurants. Category note: Prepared deli meals');
|
|
116
|
+
const state = request.state;
|
|
117
|
+
assert.deepEqual(state.relevant_examples.map((example) => [example.notes, example.category]), [
|
|
118
|
+
['Produce', 'category_0'],
|
|
119
|
+
['Deli lunch', 'category_1'],
|
|
120
|
+
]);
|
|
121
|
+
return Promise.resolve({
|
|
122
|
+
answers: {
|
|
123
|
+
category: {
|
|
124
|
+
choice: 'category_1',
|
|
125
|
+
confidence: 0.9,
|
|
126
|
+
probabilities: { category_0: 0.1, category_1: 0.9, none_of_the_above: 0 },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const classifier = await createActualClassifier(actual, {
|
|
133
|
+
client,
|
|
134
|
+
history,
|
|
135
|
+
eligibleAccountIds: new Set(['checking']),
|
|
136
|
+
});
|
|
137
|
+
assert.deepEqual(notes, ['groceries', 'restaurants']);
|
|
138
|
+
assert.equal((await classifier.classify({ ...base, id: 'new', payee: 'shop' })).requiresReview, true);
|
|
139
|
+
});
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RunOptions } from './workflow.js';
|
|
2
|
+
export type CliOptions = RunOptions & {
|
|
3
|
+
maxExamplesPerCategory?: number;
|
|
4
|
+
dataDir?: string;
|
|
5
|
+
envFile?: string;
|
|
6
|
+
help: boolean;
|
|
7
|
+
command: 'run' | 'setup' | 'config-show';
|
|
8
|
+
};
|
|
9
|
+
export declare function parseArgs(args: readonly string[]): CliOptions;
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function parseDate(value, flag) {
|
|
2
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
3
|
+
Number.isNaN(Date.parse(`${value}T00:00:00Z`)) ||
|
|
4
|
+
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value) {
|
|
5
|
+
throw new Error(`${flag} requires a valid YYYY-MM-DD date`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function parseArgs(args) {
|
|
10
|
+
let command = 'run';
|
|
11
|
+
if (args[0] === 'setup') {
|
|
12
|
+
command = 'setup';
|
|
13
|
+
args = args.slice(1);
|
|
14
|
+
}
|
|
15
|
+
else if (args[0] === 'config' && args[1] === 'show') {
|
|
16
|
+
command = 'config-show';
|
|
17
|
+
args = args.slice(2);
|
|
18
|
+
}
|
|
19
|
+
let mode;
|
|
20
|
+
const options = {
|
|
21
|
+
command,
|
|
22
|
+
mode: 'interactive',
|
|
23
|
+
threshold: 0.9,
|
|
24
|
+
account: undefined,
|
|
25
|
+
from: undefined,
|
|
26
|
+
to: undefined,
|
|
27
|
+
dataDir: undefined,
|
|
28
|
+
help: false,
|
|
29
|
+
};
|
|
30
|
+
for (let index = 0; index < args.length; index++) {
|
|
31
|
+
const arg = args[index];
|
|
32
|
+
if (command === 'setup' && arg !== '--help' && arg !== '-h')
|
|
33
|
+
throw new Error('setup accepts only --help; it edits saved configuration');
|
|
34
|
+
if (command === 'config-show' &&
|
|
35
|
+
!['--env-file', '--threshold', '--max-examples-per-category', '--data-dir', '--help', '-h'].includes(arg))
|
|
36
|
+
throw new Error('config show accepts only --env-file, --threshold, --max-examples-per-category, --data-dir, and --help');
|
|
37
|
+
function value() {
|
|
38
|
+
const next = args[++index];
|
|
39
|
+
if (!next || next.startsWith('--'))
|
|
40
|
+
throw new Error(`${arg} requires a value`);
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
switch (arg) {
|
|
44
|
+
case '--help':
|
|
45
|
+
case '-h':
|
|
46
|
+
options.help = true;
|
|
47
|
+
break;
|
|
48
|
+
case '--auto':
|
|
49
|
+
case '--interactive':
|
|
50
|
+
case '--dry-run':
|
|
51
|
+
if (mode)
|
|
52
|
+
throw new Error('Choose only one mode');
|
|
53
|
+
mode = arg.slice(2);
|
|
54
|
+
options.mode = mode;
|
|
55
|
+
break;
|
|
56
|
+
case '--threshold':
|
|
57
|
+
options.threshold = Number(value());
|
|
58
|
+
if (!Number.isFinite(options.threshold) || options.threshold < 0 || options.threshold > 1)
|
|
59
|
+
throw new Error('--threshold must be between 0 and 1');
|
|
60
|
+
break;
|
|
61
|
+
case '--max-examples-per-category':
|
|
62
|
+
options.maxExamplesPerCategory = Number(value());
|
|
63
|
+
if (!Number.isSafeInteger(options.maxExamplesPerCategory) ||
|
|
64
|
+
options.maxExamplesPerCategory < 0 ||
|
|
65
|
+
options.maxExamplesPerCategory > 100)
|
|
66
|
+
throw new Error('--max-examples-per-category must be an integer between 0 and 100');
|
|
67
|
+
break;
|
|
68
|
+
case '--account':
|
|
69
|
+
options.account = value();
|
|
70
|
+
break;
|
|
71
|
+
case '--from':
|
|
72
|
+
options.from = parseDate(value(), arg);
|
|
73
|
+
break;
|
|
74
|
+
case '--to':
|
|
75
|
+
options.to = parseDate(value(), arg);
|
|
76
|
+
break;
|
|
77
|
+
case '--env-file':
|
|
78
|
+
options.envFile = value();
|
|
79
|
+
break;
|
|
80
|
+
case '--data-dir':
|
|
81
|
+
options.dataDir = value();
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (options.from && options.to && options.from > options.to)
|
|
88
|
+
throw new Error('--from must be on or before --to');
|
|
89
|
+
return options;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|